feat: sync memories (#19579)

This commit is contained in:
Jason Rasmussen 2025-06-27 12:20:13 -04:00 committed by GitHub
parent 97aabe466e
commit 6feca56da8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 926 additions and 197 deletions

BIN
mobile/openapi/README.md generated

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -13794,11 +13794,11 @@
"enum": [
"UserV1",
"UserDeleteV1",
"PartnerV1",
"PartnerDeleteV1",
"AssetV1",
"AssetDeleteV1",
"AssetExifV1",
"PartnerV1",
"PartnerDeleteV1",
"PartnerAssetV1",
"PartnerAssetBackfillV1",
"PartnerAssetDeleteV1",
@ -13816,10 +13816,125 @@
"AlbumToAssetV1",
"AlbumToAssetDeleteV1",
"AlbumToAssetBackfillV1",
"MemoryV1",
"MemoryDeleteV1",
"MemoryToAssetV1",
"MemoryToAssetDeleteV1",
"SyncAckV1"
],
"type": "string"
},
"SyncMemoryAssetDeleteV1": {
"properties": {
"assetId": {
"type": "string"
},
"memoryId": {
"type": "string"
}
},
"required": [
"assetId",
"memoryId"
],
"type": "object"
},
"SyncMemoryAssetV1": {
"properties": {
"assetId": {
"type": "string"
},
"memoryId": {
"type": "string"
}
},
"required": [
"assetId",
"memoryId"
],
"type": "object"
},
"SyncMemoryDeleteV1": {
"properties": {
"memoryId": {
"type": "string"
}
},
"required": [
"memoryId"
],
"type": "object"
},
"SyncMemoryV1": {
"properties": {
"createdAt": {
"format": "date-time",
"type": "string"
},
"data": {
"type": "object"
},
"deletedAt": {
"format": "date-time",
"nullable": true,
"type": "string"
},
"hideAt": {
"format": "date-time",
"nullable": true,
"type": "string"
},
"id": {
"type": "string"
},
"isSaved": {
"type": "boolean"
},
"memoryAt": {
"format": "date-time",
"type": "string"
},
"ownerId": {
"type": "string"
},
"seenAt": {
"format": "date-time",
"nullable": true,
"type": "string"
},
"showAt": {
"format": "date-time",
"nullable": true,
"type": "string"
},
"type": {
"allOf": [
{
"$ref": "#/components/schemas/MemoryType"
}
]
},
"updatedAt": {
"format": "date-time",
"type": "string"
}
},
"required": [
"createdAt",
"data",
"deletedAt",
"hideAt",
"id",
"isSaved",
"memoryAt",
"ownerId",
"seenAt",
"showAt",
"type",
"updatedAt"
],
"type": "object"
},
"SyncPartnerDeleteV1": {
"properties": {
"sharedById": {
@ -13866,7 +13981,9 @@
"AlbumUsersV1",
"AlbumToAssetsV1",
"AlbumAssetsV1",
"AlbumAssetExifsV1"
"AlbumAssetExifsV1",
"MemoriesV1",
"MemoryToAssetsV1"
],
"type": "string"
},

View file

@ -4063,11 +4063,11 @@ export enum Error2 {
export enum SyncEntityType {
UserV1 = "UserV1",
UserDeleteV1 = "UserDeleteV1",
PartnerV1 = "PartnerV1",
PartnerDeleteV1 = "PartnerDeleteV1",
AssetV1 = "AssetV1",
AssetDeleteV1 = "AssetDeleteV1",
AssetExifV1 = "AssetExifV1",
PartnerV1 = "PartnerV1",
PartnerDeleteV1 = "PartnerDeleteV1",
PartnerAssetV1 = "PartnerAssetV1",
PartnerAssetBackfillV1 = "PartnerAssetBackfillV1",
PartnerAssetDeleteV1 = "PartnerAssetDeleteV1",
@ -4085,6 +4085,10 @@ export enum SyncEntityType {
AlbumToAssetV1 = "AlbumToAssetV1",
AlbumToAssetDeleteV1 = "AlbumToAssetDeleteV1",
AlbumToAssetBackfillV1 = "AlbumToAssetBackfillV1",
MemoryV1 = "MemoryV1",
MemoryDeleteV1 = "MemoryDeleteV1",
MemoryToAssetV1 = "MemoryToAssetV1",
MemoryToAssetDeleteV1 = "MemoryToAssetDeleteV1",
SyncAckV1 = "SyncAckV1"
}
export enum SyncRequestType {
@ -4098,7 +4102,9 @@ export enum SyncRequestType {
AlbumUsersV1 = "AlbumUsersV1",
AlbumToAssetsV1 = "AlbumToAssetsV1",
AlbumAssetsV1 = "AlbumAssetsV1",
AlbumAssetExifsV1 = "AlbumAssetExifsV1"
AlbumAssetExifsV1 = "AlbumAssetExifsV1",
MemoriesV1 = "MemoriesV1",
MemoryToAssetsV1 = "MemoryToAssetsV1"
}
export enum TranscodeHWAccel {
Nvenc = "nvenc",

View file

@ -13,7 +13,7 @@ import {
UserAvatarColor,
UserStatus,
} from 'src/enum';
import { OnThisDayData, UserMetadataItem } from 'src/types';
import { UserMetadataItem } from 'src/types';
export type AuthUser = {
id: string;
@ -95,7 +95,7 @@ export type Memory = {
showAt: Date | null;
hideAt: Date | null;
type: MemoryType;
data: OnThisDayData;
data: object;
ownerId: string;
isSaved: boolean;
assets: MapAsset[];

16
server/src/db.d.ts vendored
View file

@ -19,8 +19,11 @@ import {
SourceType,
SyncEntityType,
} from 'src/enum';
import { MemoryAssetAuditTable } from 'src/schema/tables/memory-asset-audit.table';
import { MemoryAssetTable } from 'src/schema/tables/memory-asset.table';
import { MemoryAuditTable } from 'src/schema/tables/memory-audit.table';
import { UserTable } from 'src/schema/tables/user.table';
import { OnThisDayData, UserMetadataItem } from 'src/types';
import { UserMetadataItem } from 'src/types';
export type ArrayType<T> = ArrayTypeImpl<T> extends (infer U)[] ? U[] : ArrayTypeImpl<T>;
@ -278,7 +281,7 @@ export interface Libraries {
export interface Memories {
createdAt: Generated<Timestamp>;
data: OnThisDayData;
data: object;
deletedAt: Timestamp | null;
hideAt: Timestamp | null;
id: Generated<string>;
@ -307,11 +310,6 @@ export interface Notifications {
readAt: Timestamp | null;
}
export interface MemoriesAssetsAssets {
assetsId: string;
memoriesId: string;
}
export interface Migrations {
id: Generated<number>;
name: string;
@ -512,7 +510,9 @@ export interface DB {
geodata_places: GeodataPlaces;
libraries: Libraries;
memories: Memories;
memories_assets_assets: MemoriesAssetsAssets;
memories_audit: MemoryAuditTable;
memories_assets_assets: MemoryAssetTable;
memory_assets_audit: MemoryAssetAuditTable;
migrations: Migrations;
notifications: Notifications;
move_history: MoveHistory;

View file

@ -1,7 +1,16 @@
/* eslint-disable @typescript-eslint/no-unsafe-function-type */
import { ApiProperty } from '@nestjs/swagger';
import { ArrayMaxSize, IsEnum, IsInt, IsPositive, IsString } from 'class-validator';
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { AlbumUserRole, AssetOrder, AssetType, AssetVisibility, SyncEntityType, SyncRequestType } from 'src/enum';
import {
AlbumUserRole,
AssetOrder,
AssetType,
AssetVisibility,
MemoryType,
SyncEntityType,
SyncRequestType,
} from 'src/enum';
import { Optional, ValidateDate, ValidateUUID } from 'src/validation';
export class AssetFullSyncDto {
@ -34,6 +43,15 @@ export class AssetDeltaSyncResponseDto {
deleted!: string[];
}
export const extraSyncModels: Function[] = [];
export const ExtraModel = (): ClassDecorator => {
return (object: Function) => {
extraSyncModels.push(object);
};
};
@ExtraModel()
export class SyncUserV1 {
id!: string;
name!: string;
@ -41,21 +59,25 @@ export class SyncUserV1 {
deletedAt!: Date | null;
}
@ExtraModel()
export class SyncUserDeleteV1 {
userId!: string;
}
@ExtraModel()
export class SyncPartnerV1 {
sharedById!: string;
sharedWithId!: string;
inTimeline!: boolean;
}
@ExtraModel()
export class SyncPartnerDeleteV1 {
sharedById!: string;
sharedWithId!: string;
}
@ExtraModel()
export class SyncAssetV1 {
id!: string;
ownerId!: string;
@ -74,10 +96,12 @@ export class SyncAssetV1 {
visibility!: AssetVisibility;
}
@ExtraModel()
export class SyncAssetDeleteV1 {
assetId!: string;
}
@ExtraModel()
export class SyncAssetExifV1 {
assetId!: string;
description!: string | null;
@ -116,15 +140,18 @@ export class SyncAssetExifV1 {
fps!: number | null;
}
@ExtraModel()
export class SyncAlbumDeleteV1 {
albumId!: string;
}
@ExtraModel()
export class SyncAlbumUserDeleteV1 {
albumId!: string;
userId!: string;
}
@ExtraModel()
export class SyncAlbumUserV1 {
albumId!: string;
userId!: string;
@ -132,6 +159,7 @@ export class SyncAlbumUserV1 {
role!: AlbumUserRole;
}
@ExtraModel()
export class SyncAlbumV1 {
id!: string;
ownerId!: string;
@ -145,16 +173,53 @@ export class SyncAlbumV1 {
order!: AssetOrder;
}
@ExtraModel()
export class SyncAlbumToAssetV1 {
albumId!: string;
assetId!: string;
}
@ExtraModel()
export class SyncAlbumToAssetDeleteV1 {
albumId!: string;
assetId!: string;
}
@ExtraModel()
export class SyncMemoryV1 {
id!: string;
createdAt!: Date;
updatedAt!: Date;
deletedAt!: Date | null;
ownerId!: string;
@ApiProperty({ enumName: 'MemoryType', enum: MemoryType })
type!: MemoryType;
data!: object;
isSaved!: boolean;
memoryAt!: Date;
seenAt!: Date | null;
showAt!: Date | null;
hideAt!: Date | null;
}
@ExtraModel()
export class SyncMemoryDeleteV1 {
memoryId!: string;
}
@ExtraModel()
export class SyncMemoryAssetV1 {
memoryId!: string;
assetId!: string;
}
@ExtraModel()
export class SyncMemoryAssetDeleteV1 {
memoryId!: string;
assetId!: string;
}
@ExtraModel()
export class SyncAckV1 {}
export type SyncItem = {
@ -182,28 +247,13 @@ export type SyncItem = {
[SyncEntityType.AlbumToAssetV1]: SyncAlbumToAssetV1;
[SyncEntityType.AlbumToAssetBackfillV1]: SyncAlbumToAssetV1;
[SyncEntityType.AlbumToAssetDeleteV1]: SyncAlbumToAssetDeleteV1;
[SyncEntityType.MemoryV1]: SyncMemoryV1;
[SyncEntityType.MemoryDeleteV1]: SyncMemoryDeleteV1;
[SyncEntityType.MemoryToAssetV1]: SyncMemoryAssetV1;
[SyncEntityType.MemoryToAssetDeleteV1]: SyncMemoryAssetDeleteV1;
[SyncEntityType.SyncAckV1]: SyncAckV1;
};
const responseDtos = [
SyncUserV1,
SyncUserDeleteV1,
SyncPartnerV1,
SyncPartnerDeleteV1,
SyncAssetV1,
SyncAssetDeleteV1,
SyncAssetExifV1,
SyncAlbumV1,
SyncAlbumDeleteV1,
SyncAlbumUserV1,
SyncAlbumUserDeleteV1,
SyncAlbumToAssetV1,
SyncAlbumToAssetDeleteV1,
SyncAckV1,
];
export const extraSyncModels = responseDtos;
export class SyncStreamDto {
@IsEnum(SyncRequestType, { each: true })
@ApiProperty({ enumName: 'SyncRequestType', enum: SyncRequestType, isArray: true })

View file

@ -584,19 +584,21 @@ export enum SyncRequestType {
AlbumToAssetsV1 = 'AlbumToAssetsV1',
AlbumAssetsV1 = 'AlbumAssetsV1',
AlbumAssetExifsV1 = 'AlbumAssetExifsV1',
MemoriesV1 = 'MemoriesV1',
MemoryToAssetsV1 = 'MemoryToAssetsV1',
}
export enum SyncEntityType {
UserV1 = 'UserV1',
UserDeleteV1 = 'UserDeleteV1',
PartnerV1 = 'PartnerV1',
PartnerDeleteV1 = 'PartnerDeleteV1',
AssetV1 = 'AssetV1',
AssetDeleteV1 = 'AssetDeleteV1',
AssetExifV1 = 'AssetExifV1',
PartnerV1 = 'PartnerV1',
PartnerDeleteV1 = 'PartnerDeleteV1',
PartnerAssetV1 = 'PartnerAssetV1',
PartnerAssetBackfillV1 = 'PartnerAssetBackfillV1',
PartnerAssetDeleteV1 = 'PartnerAssetDeleteV1',
@ -605,17 +607,26 @@ export enum SyncEntityType {
AlbumV1 = 'AlbumV1',
AlbumDeleteV1 = 'AlbumDeleteV1',
AlbumUserV1 = 'AlbumUserV1',
AlbumUserBackfillV1 = 'AlbumUserBackfillV1',
AlbumUserDeleteV1 = 'AlbumUserDeleteV1',
AlbumAssetV1 = 'AlbumAssetV1',
AlbumAssetBackfillV1 = 'AlbumAssetBackfillV1',
AlbumAssetExifV1 = 'AlbumAssetExifV1',
AlbumAssetExifBackfillV1 = 'AlbumAssetExifBackfillV1',
AlbumToAssetV1 = 'AlbumToAssetV1',
AlbumToAssetDeleteV1 = 'AlbumToAssetDeleteV1',
AlbumToAssetBackfillV1 = 'AlbumToAssetBackfillV1',
MemoryV1 = 'MemoryV1',
MemoryDeleteV1 = 'MemoryDeleteV1',
MemoryToAssetV1 = 'MemoryToAssetV1',
MemoryToAssetDeleteV1 = 'MemoryToAssetDeleteV1',
SyncAckV1 = 'SyncAckV1',
}

View file

@ -652,3 +652,78 @@ where
)
order by
"exif"."updateId" asc
-- SyncRepository.getMemoryUpserts
select
"id",
"createdAt",
"updatedAt",
"deletedAt",
"ownerId",
"type",
"data",
"isSaved",
"memoryAt",
"seenAt",
"showAt",
"hideAt",
"updateId"
from
"memories"
where
"ownerId" = $1
and "updatedAt" < now() - interval '1 millisecond'
order by
"updateId" asc
-- SyncRepository.getMemoryDeletes
select
"id",
"memoryId"
from
"memories_audit"
where
"userId" = $1
and "deletedAt" < now() - interval '1 millisecond'
order by
"id" asc
-- SyncRepository.getMemoryAssetUpserts
select
"memoriesId" as "memoryId",
"assetsId" as "assetId",
"updateId"
from
"memories_assets_assets"
where
"memoriesId" in (
select
"id"
from
"memories"
where
"ownerId" = $1
)
and "updatedAt" < now() - interval '1 millisecond'
order by
"updateId" asc
-- SyncRepository.getMemoryAssetDeletes
select
"id",
"memoryId",
"assetId"
from
"memory_assets_audit"
where
"memoryId" in (
select
"id"
from
"memories"
where
"ownerId" = $1
)
and "deletedAt" < now() - interval '1 millisecond'
order by
"id" asc

View file

@ -13,8 +13,18 @@ type AuditTables =
| 'assets_audit'
| 'albums_audit'
| 'album_users_audit'
| 'album_assets_audit';
type UpsertTables = 'users' | 'partners' | 'assets' | 'exif' | 'albums' | 'albums_shared_users_users';
| 'album_assets_audit'
| 'memories_audit'
| 'memory_assets_audit';
type UpsertTables =
| 'users'
| 'partners'
| 'assets'
| 'exif'
| 'albums'
| 'albums_shared_users_users'
| 'memories'
| 'memories_assets_assets';
@Injectable()
export class SyncRepository {
@ -438,6 +448,61 @@ export class SyncRepository {
.stream();
}
@GenerateSql({ params: [DummyValue.UUID], stream: true })
getMemoryUpserts(userId: string, ack?: SyncAck) {
return this.db
.selectFrom('memories')
.select([
'id',
'createdAt',
'updatedAt',
'deletedAt',
'ownerId',
'type',
'data',
'isSaved',
'memoryAt',
'seenAt',
'showAt',
'hideAt',
])
.select('updateId')
.where('ownerId', '=', userId)
.$call((qb) => this.upsertTableFilters(qb, ack))
.stream();
}
@GenerateSql({ params: [DummyValue.UUID], stream: true })
getMemoryDeletes(userId: string, ack?: SyncAck) {
return this.db
.selectFrom('memories_audit')
.select(['id', 'memoryId'])
.where('userId', '=', userId)
.$call((qb) => this.auditTableFilters(qb, ack))
.stream();
}
@GenerateSql({ params: [DummyValue.UUID], stream: true })
getMemoryAssetUpserts(userId: string, ack?: SyncAck) {
return this.db
.selectFrom('memories_assets_assets')
.select(['memoriesId as memoryId', 'assetsId as assetId'])
.select('updateId')
.where('memoriesId', 'in', (eb) => eb.selectFrom('memories').select('id').where('ownerId', '=', userId))
.$call((qb) => this.upsertTableFilters(qb, ack))
.stream();
}
@GenerateSql({ params: [DummyValue.UUID], stream: true })
getMemoryAssetDeletes(userId: string, ack?: SyncAck) {
return this.db
.selectFrom('memory_assets_audit')
.select(['id', 'memoryId', 'assetId'])
.where('memoryId', 'in', (eb) => eb.selectFrom('memories').select('id').where('ownerId', '=', userId))
.$call((qb) => this.auditTableFilters(qb, ack))
.stream();
}
private auditTableFilters<T extends keyof Pick<DB, AuditTables>, D>(qb: SelectQueryBuilder<DB, T, D>, ack?: SyncAck) {
const builder = qb as SelectQueryBuilder<DB, AuditTables, D>;
return builder

View file

@ -176,3 +176,31 @@ export const album_users_delete_audit = registerFunction({
END`,
synchronize: false,
});
export const memories_delete_audit = registerFunction({
name: 'memories_delete_audit',
returnType: 'TRIGGER',
language: 'PLPGSQL',
body: `
BEGIN
INSERT INTO memories_audit ("memoryId", "userId")
SELECT "id", "ownerId"
FROM OLD;
RETURN NULL;
END`,
synchronize: false,
});
export const memory_assets_delete_audit = registerFunction({
name: 'memory_assets_delete_audit',
returnType: 'TRIGGER',
language: 'PLPGSQL',
body: `
BEGIN
INSERT INTO memory_assets_audit ("memoryId", "assetId")
SELECT "memoriesId", "assetsId" FROM OLD
WHERE "memoriesId" IN (SELECT "id" FROM memories WHERE "id" IN (SELECT "memoriesId" FROM OLD));
RETURN NULL;
END`,
synchronize: false,
});

View file

@ -8,6 +8,8 @@ import {
f_unaccent,
immich_uuid_v7,
ll_to_earth_public,
memories_delete_audit,
memory_assets_delete_audit,
partners_delete_audit,
updated_at,
users_delete_audit,
@ -30,8 +32,10 @@ import { ExifTable } from 'src/schema/tables/exif.table';
import { FaceSearchTable } from 'src/schema/tables/face-search.table';
import { GeodataPlacesTable } from 'src/schema/tables/geodata-places.table';
import { LibraryTable } from 'src/schema/tables/library.table';
import { MemoryAssetAuditTable } from 'src/schema/tables/memory-asset-audit.table';
import { MemoryAssetTable } from 'src/schema/tables/memory-asset.table';
import { MemoryAuditTable } from 'src/schema/tables/memory-audit.table';
import { MemoryTable } from 'src/schema/tables/memory.table';
import { MemoryAssetTable } from 'src/schema/tables/memory_asset.table';
import { MoveTable } from 'src/schema/tables/move.table';
import { NaturalEarthCountriesTable } from 'src/schema/tables/natural-earth-countries.table';
import { NotificationTable } from 'src/schema/tables/notification.table';
@ -75,8 +79,10 @@ export class ImmichDatabase {
FaceSearchTable,
GeodataPlacesTable,
LibraryTable,
MemoryAssetTable,
MemoryTable,
MemoryAuditTable,
MemoryAssetTable,
MemoryAssetAuditTable,
MoveTable,
NaturalEarthCountriesTable,
NotificationTable,
@ -110,6 +116,8 @@ export class ImmichDatabase {
albums_delete_audit,
album_user_after_insert,
album_users_delete_audit,
memories_delete_audit,
memory_assets_delete_audit,
];
enum = [assets_status_enum, asset_face_source_type, asset_visibility_enum];

View file

@ -0,0 +1,80 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`CREATE TABLE "memory_assets_audit" ("id" uuid NOT NULL DEFAULT immich_uuid_v7(), "memoryId" uuid NOT NULL, "assetId" uuid NOT NULL, "deletedAt" timestamp with time zone NOT NULL DEFAULT clock_timestamp());`.execute(db);
await sql`CREATE TABLE "memories_audit" ("id" uuid NOT NULL DEFAULT immich_uuid_v7(), "memoryId" uuid NOT NULL, "userId" uuid NOT NULL, "deletedAt" timestamp with time zone NOT NULL DEFAULT clock_timestamp());`.execute(db);
await sql`ALTER TABLE "memories_assets_assets" ADD "createdAt" timestamp with time zone NOT NULL DEFAULT now();`.execute(db);
await sql`ALTER TABLE "memories_assets_assets" ADD "updatedAt" timestamp with time zone NOT NULL DEFAULT now();`.execute(db);
await sql`ALTER TABLE "memories_assets_assets" ADD "updateId" uuid NOT NULL DEFAULT immich_uuid_v7();`.execute(db);
await sql`ALTER TABLE "memory_assets_audit" ADD CONSTRAINT "PK_35ef16910228f980e0766dcc59b" PRIMARY KEY ("id");`.execute(db);
await sql`ALTER TABLE "memories_audit" ADD CONSTRAINT "PK_19de798c033a710dcfa5c72f81b" PRIMARY KEY ("id");`.execute(db);
await sql`ALTER TABLE "memory_assets_audit" ADD CONSTRAINT "FK_225a204afcb0bd6de015080fb03" FOREIGN KEY ("memoryId") REFERENCES "memories" ("id") ON UPDATE CASCADE ON DELETE CASCADE;`.execute(db);
await sql`CREATE INDEX "IDX_memory_assets_audit_memory_id" ON "memory_assets_audit" ("memoryId")`.execute(db);
await sql`CREATE INDEX "IDX_memory_assets_audit_asset_id" ON "memory_assets_audit" ("assetId")`.execute(db);
await sql`CREATE INDEX "IDX_memory_assets_audit_deleted_at" ON "memory_assets_audit" ("deletedAt")`.execute(db);
await sql`CREATE INDEX "IDX_memory_assets_update_id" ON "memories_assets_assets" ("updateId")`.execute(db);
await sql`CREATE INDEX "IDX_memories_audit_memory_id" ON "memories_audit" ("memoryId")`.execute(db);
await sql`CREATE INDEX "IDX_memories_audit_user_id" ON "memories_audit" ("userId")`.execute(db);
await sql`CREATE INDEX "IDX_memories_audit_deleted_at" ON "memories_audit" ("deletedAt")`.execute(db);
await sql`CREATE OR REPLACE FUNCTION memories_delete_audit()
RETURNS TRIGGER
LANGUAGE PLPGSQL
AS $$
BEGIN
INSERT INTO memories_audit ("memoryId", "userId")
SELECT "id", "ownerId"
FROM OLD;
RETURN NULL;
END
$$;`.execute(db);
await sql`CREATE OR REPLACE FUNCTION memory_assets_delete_audit()
RETURNS TRIGGER
LANGUAGE PLPGSQL
AS $$
BEGIN
INSERT INTO memory_assets_audit ("memoryId", "assetId")
SELECT "memoriesId", "assetsId" FROM OLD
WHERE "memoriesId" IN (SELECT "id" FROM memories WHERE "id" IN (SELECT "memoriesId" FROM OLD));
RETURN NULL;
END
$$;`.execute(db);
await sql`CREATE OR REPLACE TRIGGER "memories_delete_audit"
AFTER DELETE ON "memories"
REFERENCING OLD TABLE AS "old"
FOR EACH STATEMENT
WHEN (pg_trigger_depth() = 0)
EXECUTE FUNCTION memories_delete_audit();`.execute(db);
await sql`CREATE OR REPLACE TRIGGER "memory_assets_delete_audit"
AFTER DELETE ON "memories_assets_assets"
REFERENCING OLD TABLE AS "old"
FOR EACH STATEMENT
WHEN (pg_trigger_depth() <= 1)
EXECUTE FUNCTION memory_assets_delete_audit();`.execute(db);
await sql`CREATE OR REPLACE TRIGGER "memory_assets_updated_at"
BEFORE UPDATE ON "memories_assets_assets"
FOR EACH ROW
EXECUTE FUNCTION updated_at();`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`DROP TRIGGER "memories_delete_audit" ON "memories";`.execute(db);
await sql`DROP TRIGGER "memory_assets_delete_audit" ON "memories_assets_assets";`.execute(db);
await sql`DROP TRIGGER "memory_assets_updated_at" ON "memories_assets_assets";`.execute(db);
await sql`DROP INDEX "IDX_memory_assets_update_id";`.execute(db);
await sql`DROP INDEX "IDX_memory_assets_audit_memory_id";`.execute(db);
await sql`DROP INDEX "IDX_memory_assets_audit_asset_id";`.execute(db);
await sql`DROP INDEX "IDX_memory_assets_audit_deleted_at";`.execute(db);
await sql`DROP INDEX "IDX_memories_audit_memory_id";`.execute(db);
await sql`DROP INDEX "IDX_memories_audit_user_id";`.execute(db);
await sql`DROP INDEX "IDX_memories_audit_deleted_at";`.execute(db);
await sql`ALTER TABLE "memory_assets_audit" DROP CONSTRAINT "FK_225a204afcb0bd6de015080fb03";`.execute(db);
await sql`ALTER TABLE "memory_assets_audit" DROP CONSTRAINT "PK_35ef16910228f980e0766dcc59b";`.execute(db);
await sql`ALTER TABLE "memories_audit" DROP CONSTRAINT "PK_19de798c033a710dcfa5c72f81b";`.execute(db);
await sql`ALTER TABLE "memories_assets_assets" DROP COLUMN "createdAt";`.execute(db);
await sql`ALTER TABLE "memories_assets_assets" DROP COLUMN "updatedAt";`.execute(db);
await sql`ALTER TABLE "memories_assets_assets" DROP COLUMN "updateId";`.execute(db);
await sql`DROP TABLE "memory_assets_audit";`.execute(db);
await sql`DROP TABLE "memories_audit";`.execute(db);
await sql`DROP FUNCTION memories_delete_audit;`.execute(db);
await sql`DROP FUNCTION memory_assets_delete_audit;`.execute(db);
}

View file

@ -13,8 +13,8 @@ import 'src/schema/tables/exif.table';
import 'src/schema/tables/face-search.table';
import 'src/schema/tables/geodata-places.table';
import 'src/schema/tables/library.table';
import 'src/schema/tables/memory-asset.table';
import 'src/schema/tables/memory.table';
import 'src/schema/tables/memory_asset.table';
import 'src/schema/tables/move.table';
import 'src/schema/tables/natural-earth-countries.table';
import 'src/schema/tables/partner-audit.table';

View file

@ -0,0 +1,23 @@
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
import { MemoryTable } from 'src/schema/tables/memory.table';
import { Column, CreateDateColumn, ForeignKeyColumn, Table } from 'src/sql-tools';
@Table('memory_assets_audit')
export class MemoryAssetAuditTable {
@PrimaryGeneratedUuidV7Column()
id!: string;
@ForeignKeyColumn(() => MemoryTable, {
type: 'uuid',
indexName: 'IDX_memory_assets_audit_memory_id',
onDelete: 'CASCADE',
onUpdate: 'CASCADE',
})
memoryId!: string;
@Column({ type: 'uuid', indexName: 'IDX_memory_assets_audit_asset_id' })
assetId!: string;
@CreateDateColumn({ default: () => 'clock_timestamp()', indexName: 'IDX_memory_assets_audit_deleted_at' })
deletedAt!: Date;
}

View file

@ -0,0 +1,36 @@
import { ColumnType } from 'kysely';
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { memory_assets_delete_audit } from 'src/schema/functions';
import { AssetTable } from 'src/schema/tables/asset.table';
import { MemoryTable } from 'src/schema/tables/memory.table';
import { AfterDeleteTrigger, CreateDateColumn, ForeignKeyColumn, Table, UpdateDateColumn } from 'src/sql-tools';
type Timestamp = ColumnType<Date, Date | string, Date | string>;
type Generated<T> =
T extends ColumnType<infer S, infer I, infer U> ? ColumnType<S, I | undefined, U> : ColumnType<T, T | undefined, T>;
@Table('memories_assets_assets')
@UpdatedAtTrigger('memory_assets_updated_at')
@AfterDeleteTrigger({
name: 'memory_assets_delete_audit',
scope: 'statement',
function: memory_assets_delete_audit,
referencingOldTableAs: 'old',
when: 'pg_trigger_depth() <= 1',
})
export class MemoryAssetTable {
@ForeignKeyColumn(() => MemoryTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', primary: true })
memoriesId!: string;
@ForeignKeyColumn(() => AssetTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', primary: true })
assetsId!: string;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
@UpdateDateColumn()
updatedAt!: Generated<Timestamp>;
@UpdateIdColumn({ indexName: 'IDX_memory_assets_update_id' })
updateId!: Generated<string>;
}

View file

@ -0,0 +1,22 @@
import { ColumnType } from 'kysely';
import { PrimaryGeneratedUuidV7Column } from 'src/decorators';
import { Column, CreateDateColumn, Table } from 'src/sql-tools';
type Timestamp = ColumnType<Date, Date | string, Date | string>;
type Generated<T> =
T extends ColumnType<infer S, infer I, infer U> ? ColumnType<S, I | undefined, U> : ColumnType<T, T | undefined, T>;
@Table('memories_audit')
export class MemoryAuditTable {
@PrimaryGeneratedUuidV7Column()
id!: Generated<string>;
@Column({ type: 'uuid', indexName: 'IDX_memories_audit_memory_id' })
memoryId!: string;
@Column({ type: 'uuid', indexName: 'IDX_memories_audit_user_id' })
userId!: string;
@CreateDateColumn({ default: () => 'clock_timestamp()', indexName: 'IDX_memories_audit_deleted_at' })
deletedAt!: Timestamp;
}

View file

@ -1,7 +1,9 @@
import { UpdatedAtTrigger, UpdateIdColumn } from 'src/decorators';
import { MemoryType } from 'src/enum';
import { memories_delete_audit } from 'src/schema/functions';
import { UserTable } from 'src/schema/tables/user.table';
import {
AfterDeleteTrigger,
Column,
CreateDateColumn,
DeleteDateColumn,
@ -10,11 +12,17 @@ import {
Table,
UpdateDateColumn,
} from 'src/sql-tools';
import { MemoryData } from 'src/types';
@Table('memories')
@UpdatedAtTrigger('memories_updated_at')
export class MemoryTable<T extends MemoryType = MemoryType> {
@AfterDeleteTrigger({
name: 'memories_delete_audit',
scope: 'statement',
function: memories_delete_audit,
referencingOldTableAs: 'old',
when: 'pg_trigger_depth() = 0',
})
export class MemoryTable {
@PrimaryGeneratedColumn()
id!: string;
@ -31,10 +39,10 @@ export class MemoryTable<T extends MemoryType = MemoryType> {
ownerId!: string;
@Column()
type!: T;
type!: MemoryType;
@Column({ type: 'jsonb' })
data!: MemoryData[T];
data!: object;
/** unless set to true, will be automatically deleted in the future */
@Column({ type: 'boolean', default: false })

View file

@ -1,12 +0,0 @@
import { AssetTable } from 'src/schema/tables/asset.table';
import { MemoryTable } from 'src/schema/tables/memory.table';
import { ForeignKeyColumn, Table } from 'src/sql-tools';
@Table('memories_assets_assets')
export class MemoryAssetTable {
@ForeignKeyColumn(() => MemoryTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', primary: true })
memoriesId!: string;
@ForeignKeyColumn(() => AssetTable, { onUpdate: 'CASCADE', onDelete: 'CASCADE', primary: true })
assetsId!: string;
}

View file

@ -1,5 +1,6 @@
import { BadRequestException } from '@nestjs/common';
import { MemoryService } from 'src/services/memory.service';
import { OnThisDayData } from 'src/types';
import { factory, newUuid, newUuids } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils';
@ -87,7 +88,7 @@ describe(MemoryService.name, () => {
await expect(
sut.create(factory.auth({ user: { id: userId } }), {
type: memory.type,
data: memory.data,
data: memory.data as OnThisDayData,
memoryAt: memory.memoryAt,
isSaved: memory.isSaved,
assetIds: [assetId],
@ -117,7 +118,7 @@ describe(MemoryService.name, () => {
await expect(
sut.create(factory.auth({ user: { id: userId } }), {
type: memory.type,
data: memory.data,
data: memory.data as OnThisDayData,
assetIds: memory.assets.map((asset) => asset.id),
memoryAt: memory.memoryAt,
}),
@ -135,7 +136,11 @@ describe(MemoryService.name, () => {
mocks.memory.create.mockResolvedValue(memory);
await expect(
sut.create(factory.auth(), { type: memory.type, data: memory.data, memoryAt: memory.memoryAt }),
sut.create(factory.auth(), {
type: memory.type,
data: memory.data as OnThisDayData,
memoryAt: memory.memoryAt,
}),
).resolves.toBeDefined();
});
});

View file

@ -65,6 +65,8 @@ export const SYNC_TYPES_ORDER = [
SyncRequestType.AssetExifsV1,
SyncRequestType.AlbumAssetExifsV1,
SyncRequestType.PartnerAssetExifsV1,
SyncRequestType.MemoriesV1,
SyncRequestType.MemoryToAssetsV1,
];
const throwSessionRequired = () => {
@ -120,108 +122,70 @@ export class SyncService extends BaseService {
const checkpoints = await this.syncRepository.getCheckpoints(sessionId);
const checkpointMap: CheckpointMap = Object.fromEntries(checkpoints.map(({ type, ack }) => [type, fromAck(ack)]));
const handlers: Record<SyncRequestType, () => Promise<void>> = {
[SyncRequestType.UsersV1]: () => this.syncUsersV1(response, checkpointMap),
[SyncRequestType.PartnersV1]: () => this.syncPartnersV1(response, checkpointMap, auth),
[SyncRequestType.AssetsV1]: () => this.syncAssetsV1(response, checkpointMap, auth),
[SyncRequestType.AssetExifsV1]: () => this.syncAssetExifsV1(response, checkpointMap, auth),
[SyncRequestType.PartnerAssetsV1]: () => this.syncPartnerAssetsV1(response, checkpointMap, auth, sessionId),
[SyncRequestType.PartnerAssetExifsV1]: () =>
this.syncPartnerAssetExifsV1(response, checkpointMap, auth, sessionId),
[SyncRequestType.AlbumsV1]: () => this.syncAlbumsV1(response, checkpointMap, auth),
[SyncRequestType.AlbumUsersV1]: () => this.syncAlbumUsersV1(response, checkpointMap, auth, sessionId),
[SyncRequestType.AlbumAssetsV1]: () => this.syncAlbumAssetsV1(response, checkpointMap, auth, sessionId),
[SyncRequestType.AlbumToAssetsV1]: () => this.syncAlbumToAssetsV1(response, checkpointMap, auth, sessionId),
[SyncRequestType.AlbumAssetExifsV1]: () => this.syncAlbumAssetExifsV1(response, checkpointMap, auth, sessionId),
[SyncRequestType.MemoriesV1]: () => this.syncMemoriesV1(response, checkpointMap, auth),
[SyncRequestType.MemoryToAssetsV1]: () => this.syncMemoryAssetsV1(response, checkpointMap, auth),
};
for (const type of SYNC_TYPES_ORDER.filter((type) => dto.types.includes(type))) {
switch (type) {
case SyncRequestType.UsersV1: {
await this.syncUsersV1(response, checkpointMap);
break;
}
case SyncRequestType.PartnersV1: {
await this.syncPartnersV1(response, checkpointMap, auth);
break;
}
case SyncRequestType.AssetsV1: {
await this.syncAssetsV1(response, checkpointMap, auth);
break;
}
case SyncRequestType.AssetExifsV1: {
await this.syncAssetExifsV1(response, checkpointMap, auth);
break;
}
case SyncRequestType.PartnerAssetsV1: {
await this.syncPartnerAssetsV1(response, checkpointMap, auth, sessionId);
break;
}
case SyncRequestType.PartnerAssetExifsV1: {
await this.syncPartnerAssetExifsV1(response, checkpointMap, auth, sessionId);
break;
}
case SyncRequestType.AlbumsV1: {
await this.syncAlbumsV1(response, checkpointMap, auth);
break;
}
case SyncRequestType.AlbumUsersV1: {
await this.syncAlbumUsersV1(response, checkpointMap, auth, sessionId);
break;
}
case SyncRequestType.AlbumAssetsV1: {
await this.syncAlbumAssetsV1(response, checkpointMap, auth, sessionId);
break;
}
case SyncRequestType.AlbumToAssetsV1: {
await this.syncAlbumToAssetsV1(response, checkpointMap, auth, sessionId);
break;
}
case SyncRequestType.AlbumAssetExifsV1: {
await this.syncAlbumAssetExifsV1(response, checkpointMap, auth, sessionId);
break;
}
default: {
this.logger.warn(`Unsupported sync type: ${type}`);
break;
}
}
const handler = handlers[type];
await handler();
}
response.end();
}
private async syncUsersV1(response: Writable, checkpointMap: CheckpointMap) {
const deletes = this.syncRepository.getUserDeletes(checkpointMap[SyncEntityType.UserDeleteV1]);
const deleteType = SyncEntityType.UserDeleteV1;
const deletes = this.syncRepository.getUserDeletes(checkpointMap[deleteType]);
for await (const { id, ...data } of deletes) {
send(response, { type: SyncEntityType.UserDeleteV1, ids: [id], data });
send(response, { type: deleteType, ids: [id], data });
}
const upserts = this.syncRepository.getUserUpserts(checkpointMap[SyncEntityType.UserV1]);
const upsertType = SyncEntityType.UserV1;
const upserts = this.syncRepository.getUserUpserts(checkpointMap[upsertType]);
for await (const { updateId, ...data } of upserts) {
send(response, { type: SyncEntityType.UserV1, ids: [updateId], data });
send(response, { type: upsertType, ids: [updateId], data });
}
}
private async syncPartnersV1(response: Writable, checkpointMap: CheckpointMap, auth: AuthDto) {
const deletes = this.syncRepository.getPartnerDeletes(auth.user.id, checkpointMap[SyncEntityType.PartnerDeleteV1]);
const deleteType = SyncEntityType.PartnerDeleteV1;
const deletes = this.syncRepository.getPartnerDeletes(auth.user.id, checkpointMap[deleteType]);
for await (const { id, ...data } of deletes) {
send(response, { type: SyncEntityType.PartnerDeleteV1, ids: [id], data });
send(response, { type: deleteType, ids: [id], data });
}
const upserts = this.syncRepository.getPartnerUpserts(auth.user.id, checkpointMap[SyncEntityType.PartnerV1]);
const upsertType = SyncEntityType.PartnerV1;
const upserts = this.syncRepository.getPartnerUpserts(auth.user.id, checkpointMap[upsertType]);
for await (const { updateId, ...data } of upserts) {
send(response, { type: SyncEntityType.PartnerV1, ids: [updateId], data });
send(response, { type: upsertType, ids: [updateId], data });
}
}
private async syncAssetsV1(response: Writable, checkpointMap: CheckpointMap, auth: AuthDto) {
const deletes = this.syncRepository.getAssetDeletes(auth.user.id, checkpointMap[SyncEntityType.AssetDeleteV1]);
const deleteType = SyncEntityType.AssetDeleteV1;
const deletes = this.syncRepository.getAssetDeletes(auth.user.id, checkpointMap[deleteType]);
for await (const { id, ...data } of deletes) {
send(response, { type: SyncEntityType.AssetDeleteV1, ids: [id], data });
send(response, { type: deleteType, ids: [id], data });
}
const upserts = this.syncRepository.getAssetUpserts(auth.user.id, checkpointMap[SyncEntityType.AssetV1]);
const upsertType = SyncEntityType.AssetV1;
const upserts = this.syncRepository.getAssetUpserts(auth.user.id, checkpointMap[upsertType]);
for await (const { updateId, ...data } of upserts) {
send(response, { type: SyncEntityType.AssetV1, ids: [updateId], data: mapSyncAssetV1(data) });
send(response, { type: upsertType, ids: [updateId], data: mapSyncAssetV1(data) });
}
}
@ -231,21 +195,17 @@ export class SyncService extends BaseService {
auth: AuthDto,
sessionId: string,
) {
const backfillType = SyncEntityType.PartnerAssetBackfillV1;
const upsertType = SyncEntityType.PartnerAssetV1;
const deleteType = SyncEntityType.PartnerAssetDeleteV1;
const backfillCheckpoint = checkpointMap[backfillType];
const upsertCheckpoint = checkpointMap[upsertType];
const deletes = this.syncRepository.getPartnerAssetDeletes(auth.user.id, checkpointMap[deleteType]);
for await (const { id, ...data } of deletes) {
send(response, { type: deleteType, ids: [id], data });
}
const backfillType = SyncEntityType.PartnerAssetBackfillV1;
const backfillCheckpoint = checkpointMap[backfillType];
const partners = await this.syncRepository.getPartnerBackfill(auth.user.id, backfillCheckpoint?.updateId);
const upsertType = SyncEntityType.PartnerAssetV1;
const upsertCheckpoint = checkpointMap[upsertType];
if (upsertCheckpoint) {
const endId = upsertCheckpoint.updateId;
@ -283,9 +243,10 @@ export class SyncService extends BaseService {
}
private async syncAssetExifsV1(response: Writable, checkpointMap: CheckpointMap, auth: AuthDto) {
const upserts = this.syncRepository.getAssetExifsUpserts(auth.user.id, checkpointMap[SyncEntityType.AssetExifV1]);
const upsertType = SyncEntityType.AssetExifV1;
const upserts = this.syncRepository.getAssetExifsUpserts(auth.user.id, checkpointMap[upsertType]);
for await (const { updateId, ...data } of upserts) {
send(response, { type: SyncEntityType.AssetExifV1, ids: [updateId], data });
send(response, { type: upsertType, ids: [updateId], data });
}
}
@ -296,13 +257,11 @@ export class SyncService extends BaseService {
sessionId: string,
) {
const backfillType = SyncEntityType.PartnerAssetExifBackfillV1;
const upsertType = SyncEntityType.PartnerAssetExifV1;
const backfillCheckpoint = checkpointMap[backfillType];
const upsertCheckpoint = checkpointMap[upsertType];
const partners = await this.syncRepository.getPartnerBackfill(auth.user.id, backfillCheckpoint?.updateId);
const upsertType = SyncEntityType.PartnerAssetExifV1;
const upsertCheckpoint = checkpointMap[upsertType];
if (upsertCheckpoint) {
const endId = upsertCheckpoint.updateId;
@ -336,33 +295,31 @@ export class SyncService extends BaseService {
}
private async syncAlbumsV1(response: Writable, checkpointMap: CheckpointMap, auth: AuthDto) {
const deletes = this.syncRepository.getAlbumDeletes(auth.user.id, checkpointMap[SyncEntityType.AlbumDeleteV1]);
for await (const { id, ...data } of deletes) {
send(response, { type: SyncEntityType.AlbumDeleteV1, ids: [id], data });
}
const upserts = this.syncRepository.getAlbumUpserts(auth.user.id, checkpointMap[SyncEntityType.AlbumV1]);
for await (const { updateId, ...data } of upserts) {
send(response, { type: SyncEntityType.AlbumV1, ids: [updateId], data });
}
}
private async syncAlbumUsersV1(response: Writable, checkpointMap: CheckpointMap, auth: AuthDto, sessionId: string) {
const backfillType = SyncEntityType.AlbumUserBackfillV1;
const upsertType = SyncEntityType.AlbumUserV1;
const deleteType = SyncEntityType.AlbumUserDeleteV1;
const backfillCheckpoint = checkpointMap[backfillType];
const upsertCheckpoint = checkpointMap[upsertType];
const deletes = this.syncRepository.getAlbumUserDeletes(auth.user.id, checkpointMap[deleteType]);
const deleteType = SyncEntityType.AlbumDeleteV1;
const deletes = this.syncRepository.getAlbumDeletes(auth.user.id, checkpointMap[deleteType]);
for await (const { id, ...data } of deletes) {
send(response, { type: deleteType, ids: [id], data });
}
const albums = await this.syncRepository.getAlbumBackfill(auth.user.id, backfillCheckpoint?.updateId);
const upsertType = SyncEntityType.AlbumV1;
const upserts = this.syncRepository.getAlbumUpserts(auth.user.id, checkpointMap[upsertType]);
for await (const { updateId, ...data } of upserts) {
send(response, { type: upsertType, ids: [updateId], data });
}
}
private async syncAlbumUsersV1(response: Writable, checkpointMap: CheckpointMap, auth: AuthDto, sessionId: string) {
const deleteType = SyncEntityType.AlbumUserDeleteV1;
const deletes = this.syncRepository.getAlbumUserDeletes(auth.user.id, checkpointMap[deleteType]);
for await (const { id, ...data } of deletes) {
send(response, { type: deleteType, ids: [id], data });
}
const backfillType = SyncEntityType.AlbumUserBackfillV1;
const backfillCheckpoint = checkpointMap[backfillType];
const albums = await this.syncRepository.getAlbumBackfill(auth.user.id, backfillCheckpoint?.updateId);
const upsertType = SyncEntityType.AlbumUserV1;
const upsertCheckpoint = checkpointMap[upsertType];
if (upsertCheckpoint) {
const endId = upsertCheckpoint.updateId;
@ -397,13 +354,10 @@ export class SyncService extends BaseService {
private async syncAlbumAssetsV1(response: Writable, checkpointMap: CheckpointMap, auth: AuthDto, sessionId: string) {
const backfillType = SyncEntityType.AlbumAssetBackfillV1;
const upsertType = SyncEntityType.AlbumAssetV1;
const backfillCheckpoint = checkpointMap[backfillType];
const upsertCheckpoint = checkpointMap[upsertType];
const albums = await this.syncRepository.getAlbumBackfill(auth.user.id, backfillCheckpoint?.updateId);
const upsertType = SyncEntityType.AlbumAssetV1;
const upsertCheckpoint = checkpointMap[upsertType];
if (upsertCheckpoint) {
const endId = upsertCheckpoint.updateId;
@ -443,13 +397,10 @@ export class SyncService extends BaseService {
sessionId: string,
) {
const backfillType = SyncEntityType.AlbumAssetExifBackfillV1;
const upsertType = SyncEntityType.AlbumAssetExifV1;
const backfillCheckpoint = checkpointMap[backfillType];
const upsertCheckpoint = checkpointMap[upsertType];
const albums = await this.syncRepository.getAlbumBackfill(auth.user.id, backfillCheckpoint?.updateId);
const upsertType = SyncEntityType.AlbumAssetExifV1;
const upsertCheckpoint = checkpointMap[upsertType];
if (upsertCheckpoint) {
const endId = upsertCheckpoint.updateId;
@ -488,22 +439,17 @@ export class SyncService extends BaseService {
auth: AuthDto,
sessionId: string,
) {
const backfillType = SyncEntityType.AlbumToAssetBackfillV1;
const upsertType = SyncEntityType.AlbumToAssetV1;
const backfillCheckpoint = checkpointMap[backfillType];
const upsertCheckpoint = checkpointMap[upsertType];
const deletes = this.syncRepository.getAlbumToAssetDeletes(
auth.user.id,
checkpointMap[SyncEntityType.AlbumToAssetDeleteV1],
);
const deleteType = SyncEntityType.AlbumToAssetDeleteV1;
const deletes = this.syncRepository.getAlbumToAssetDeletes(auth.user.id, checkpointMap[deleteType]);
for await (const { id, ...data } of deletes) {
send(response, { type: SyncEntityType.AlbumToAssetDeleteV1, ids: [id], data });
send(response, { type: deleteType, ids: [id], data });
}
const backfillType = SyncEntityType.AlbumToAssetBackfillV1;
const backfillCheckpoint = checkpointMap[backfillType];
const albums = await this.syncRepository.getAlbumBackfill(auth.user.id, backfillCheckpoint?.updateId);
const upsertType = SyncEntityType.AlbumToAssetV1;
const upsertCheckpoint = checkpointMap[upsertType];
if (upsertCheckpoint) {
const endId = upsertCheckpoint.updateId;
@ -536,6 +482,34 @@ export class SyncService extends BaseService {
}
}
private async syncMemoriesV1(response: Writable, checkpointMap: CheckpointMap, auth: AuthDto) {
const deleteType = SyncEntityType.MemoryDeleteV1;
const deletes = this.syncRepository.getMemoryDeletes(auth.user.id, checkpointMap[SyncEntityType.MemoryDeleteV1]);
for await (const { id, ...data } of deletes) {
send(response, { type: deleteType, ids: [id], data });
}
const upsertType = SyncEntityType.MemoryV1;
const upserts = this.syncRepository.getMemoryUpserts(auth.user.id, checkpointMap[upsertType]);
for await (const { updateId, ...data } of upserts) {
send(response, { type: upsertType, ids: [updateId], data });
}
}
private async syncMemoryAssetsV1(response: Writable, checkpointMap: CheckpointMap, auth: AuthDto) {
const deleteType = SyncEntityType.MemoryToAssetDeleteV1;
const deletes = this.syncRepository.getMemoryAssetDeletes(auth.user.id, checkpointMap[deleteType]);
for await (const { id, ...data } of deletes) {
send(response, { type: deleteType, ids: [id], data });
}
const upsertType = SyncEntityType.MemoryToAssetV1;
const upserts = this.syncRepository.getMemoryAssetUpserts(auth.user.id, checkpointMap[upsertType]);
for await (const { updateId, ...data } of upserts) {
send(response, { type: upsertType, ids: [updateId], data });
}
}
private async upsertBackfillCheckpoint(item: { type: SyncEntityType; sessionId: string; createId: string }) {
const { type, sessionId, createId } = item;
await this.syncRepository.upsertCheckpoints([

View file

@ -4,9 +4,9 @@ import { DateTime } from 'luxon';
import { createHash, randomBytes } from 'node:crypto';
import { Writable } from 'node:stream';
import { AssetFace } from 'src/database';
import { Albums, AssetJobStatus, Assets, DB, Exif, FaceSearch, Person, Sessions } from 'src/db';
import { Albums, AssetJobStatus, Assets, DB, Exif, FaceSearch, Memories, Person, Sessions } from 'src/db';
import { AuthDto } from 'src/dtos/auth.dto';
import { AlbumUserRole, AssetType, AssetVisibility, SourceType, SyncRequestType } from 'src/enum';
import { AlbumUserRole, AssetType, AssetVisibility, MemoryType, SourceType, SyncRequestType } from 'src/enum';
import { AccessRepository } from 'src/repositories/access.repository';
import { ActivityRepository } from 'src/repositories/activity.repository';
import { AlbumUserRepository } from 'src/repositories/album-user.repository';
@ -129,6 +129,17 @@ export class MediumTestContext<S extends BaseService = BaseService> {
return { asset, result };
}
async newMemory(dto: Partial<Insertable<Memories>> = {}) {
const memory = mediumFactory.memoryInsert(dto);
const result = await this.get(MemoryRepository).create(memory, new Set<string>());
return { memory, result };
}
async newMemoryAsset(dto: { memoryId: string; assetId: string }) {
const result = await this.get(MemoryRepository).addAssetIds(dto.memoryId, [dto.assetId]);
return { memoryAsset: dto, result };
}
async newExif(dto: Insertable<Exif>) {
const result = await this.get(AssetRepository).upsertExif(dto);
return { result };
@ -452,6 +463,28 @@ const userInsert = (user: Partial<Insertable<UserTable>> = {}) => {
return { ...defaults, ...user, id };
};
const memoryInsert = (memory: Partial<Insertable<Memories>> = {}) => {
const id = memory.id || newUuid();
const date = newDate();
const defaults: Insertable<Memories> = {
id,
createdAt: date,
updatedAt: date,
deletedAt: null,
type: MemoryType.ON_THIS_DAY,
data: { year: 2025 },
showAt: null,
hideAt: null,
seenAt: null,
isSaved: false,
memoryAt: date,
ownerId: memory.ownerId || newUuid(),
};
return { ...defaults, ...memory, id };
};
class CustomWritable extends Writable {
private data = '';
@ -483,4 +516,5 @@ export const mediumFactory = {
sessionInsert,
syncStream,
userInsert,
memoryInsert,
};

View file

@ -0,0 +1,84 @@
import { Kysely } from 'kysely';
import { DB } from 'src/db';
import { SyncEntityType, SyncRequestType } from 'src/enum';
import { MemoryRepository } from 'src/repositories/memory.repository';
import { SyncTestContext } from 'test/medium.factory';
import { getKyselyDB } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const setup = async (db?: Kysely<DB>) => {
const ctx = new SyncTestContext(db || defaultDatabase);
const { auth, user, session } = await ctx.newSyncAuthUser();
return { auth, user, session, ctx };
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe(SyncEntityType.MemoryToAssetV1, () => {
it('should detect and sync a memory to asset relation', async () => {
const { auth, user, ctx } = await setup();
const { asset } = await ctx.newAsset({ ownerId: user.id });
const { memory } = await ctx.newMemory({ ownerId: user.id });
await ctx.newMemoryAsset({ memoryId: memory.id, assetId: asset.id });
const response = await ctx.syncStream(auth, [SyncRequestType.MemoryToAssetsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
memoryId: memory.id,
assetId: asset.id,
},
type: 'MemoryToAssetV1',
},
]);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.MemoryToAssetsV1])).resolves.toEqual([]);
});
it('should detect and sync a deleted memory to asset relation', async () => {
const { auth, user, ctx } = await setup();
const memoryRepo = ctx.get(MemoryRepository);
const { asset } = await ctx.newAsset({ ownerId: user.id });
const { memory } = await ctx.newMemory({ ownerId: user.id });
await ctx.newMemoryAsset({ memoryId: memory.id, assetId: asset.id });
await memoryRepo.removeAssetIds(memory.id, [asset.id]);
const response = await ctx.syncStream(auth, [SyncRequestType.MemoryToAssetsV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
assetId: asset.id,
memoryId: memory.id,
},
type: 'MemoryToAssetDeleteV1',
},
]);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.MemoryToAssetsV1])).resolves.toEqual([]);
});
it('should not sync a memory to asset relation or delete for an unrelated user', async () => {
const { auth, ctx } = await setup();
const memoryRepo = ctx.get(MemoryRepository);
const { auth: auth2, user: user2 } = await ctx.newSyncAuthUser();
const { asset } = await ctx.newAsset({ ownerId: user2.id });
const { memory } = await ctx.newMemory({ ownerId: user2.id });
await ctx.newMemoryAsset({ memoryId: memory.id, assetId: asset.id });
expect(await ctx.syncStream(auth, [SyncRequestType.MemoryToAssetsV1])).toHaveLength(0);
expect(await ctx.syncStream(auth2, [SyncRequestType.MemoryToAssetsV1])).toHaveLength(1);
await memoryRepo.removeAssetIds(memory.id, [asset.id]);
expect(await ctx.syncStream(auth, [SyncRequestType.MemoryToAssetsV1])).toHaveLength(0);
expect(await ctx.syncStream(auth2, [SyncRequestType.MemoryToAssetsV1])).toHaveLength(1);
});
});

View file

@ -0,0 +1,115 @@
import { Kysely } from 'kysely';
import { DB } from 'src/db';
import { SyncEntityType, SyncRequestType } from 'src/enum';
import { MemoryRepository } from 'src/repositories/memory.repository';
import { SyncTestContext } from 'test/medium.factory';
import { getKyselyDB } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const setup = async (db?: Kysely<DB>) => {
const ctx = new SyncTestContext(db || defaultDatabase);
const { auth, user, session } = await ctx.newSyncAuthUser();
return { auth, user, session, ctx };
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe(SyncEntityType.MemoryV1, () => {
it('should detect and sync the first memory with the right properties', async () => {
const { auth, user: user1, ctx } = await setup();
const { memory } = await ctx.newMemory({ ownerId: user1.id });
const response = await ctx.syncStream(auth, [SyncRequestType.MemoriesV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
id: memory.id,
createdAt: expect.any(String),
updatedAt: expect.any(String),
deletedAt: memory.deletedAt,
type: memory.type,
data: memory.data,
hideAt: memory.hideAt,
showAt: memory.showAt,
seenAt: memory.seenAt,
memoryAt: expect.any(String),
isSaved: memory.isSaved,
ownerId: memory.ownerId,
},
type: 'MemoryV1',
},
]);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.MemoriesV1])).resolves.toEqual([]);
});
it('should detect and sync a deleted memory', async () => {
const { auth, user, ctx } = await setup();
const memoryRepo = ctx.get(MemoryRepository);
const { memory } = await ctx.newMemory({ ownerId: user.id });
await memoryRepo.delete(memory.id);
const response = await ctx.syncStream(auth, [SyncRequestType.MemoriesV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: {
memoryId: memory.id,
},
type: 'MemoryDeleteV1',
},
]);
await ctx.syncAckAll(auth, response);
await expect(ctx.syncStream(auth, [SyncRequestType.MemoriesV1])).resolves.toEqual([]);
});
it('should sync a memory and then an update to that same memory', async () => {
const { auth, user, ctx } = await setup();
const memoryRepo = ctx.get(MemoryRepository);
const { memory } = await ctx.newMemory({ ownerId: user.id });
const response = await ctx.syncStream(auth, [SyncRequestType.MemoriesV1]);
expect(response).toHaveLength(1);
expect(response).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({ id: memory.id }),
type: 'MemoryV1',
},
]);
await ctx.syncAckAll(auth, response);
await memoryRepo.update(memory.id, { seenAt: new Date() });
const newResponse = await ctx.syncStream(auth, [SyncRequestType.MemoriesV1]);
expect(newResponse).toHaveLength(1);
expect(newResponse).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({ id: memory.id }),
type: 'MemoryV1',
},
]);
await ctx.syncAckAll(auth, newResponse);
await expect(ctx.syncStream(auth, [SyncRequestType.MemoriesV1])).resolves.toEqual([]);
});
it('should not sync a memory or a memory delete for an unrelated user', async () => {
const { auth, ctx } = await setup();
const memoryRepo = ctx.get(MemoryRepository);
const { user: user2 } = await ctx.newUser();
const { memory } = await ctx.newMemory({ ownerId: user2.id });
await expect(ctx.syncStream(auth, [SyncRequestType.MemoriesV1])).resolves.toEqual([]);
await memoryRepo.delete(memory.id);
await expect(ctx.syncStream(auth, [SyncRequestType.MemoriesV1])).resolves.toEqual([]);
});
});