diff --git a/mobile/openapi/lib/model/sync_entity_type.dart b/mobile/openapi/lib/model/sync_entity_type.dart index 5368126923..7bb7ee52e2 100644 --- a/mobile/openapi/lib/model/sync_entity_type.dart +++ b/mobile/openapi/lib/model/sync_entity_type.dart @@ -44,8 +44,12 @@ class SyncEntityType { static const albumUserV1 = SyncEntityType._(r'AlbumUserV1'); static const albumUserBackfillV1 = SyncEntityType._(r'AlbumUserBackfillV1'); static const albumUserDeleteV1 = SyncEntityType._(r'AlbumUserDeleteV1'); + static const albumAssetCreateV1 = SyncEntityType._(r'AlbumAssetCreateV1'); + static const albumAssetUpdateV1 = SyncEntityType._(r'AlbumAssetUpdateV1'); static const albumAssetV1 = SyncEntityType._(r'AlbumAssetV1'); static const albumAssetBackfillV1 = SyncEntityType._(r'AlbumAssetBackfillV1'); + static const albumAssetExifCreateV1 = SyncEntityType._(r'AlbumAssetExifCreateV1'); + static const albumAssetExifUpdateV1 = SyncEntityType._(r'AlbumAssetExifUpdateV1'); static const albumAssetExifV1 = SyncEntityType._(r'AlbumAssetExifV1'); static const albumAssetExifBackfillV1 = SyncEntityType._(r'AlbumAssetExifBackfillV1'); static const albumToAssetV1 = SyncEntityType._(r'AlbumToAssetV1'); @@ -89,8 +93,12 @@ class SyncEntityType { albumUserV1, albumUserBackfillV1, albumUserDeleteV1, + albumAssetCreateV1, + albumAssetUpdateV1, albumAssetV1, albumAssetBackfillV1, + albumAssetExifCreateV1, + albumAssetExifUpdateV1, albumAssetExifV1, albumAssetExifBackfillV1, albumToAssetV1, @@ -169,8 +177,12 @@ class SyncEntityTypeTypeTransformer { case r'AlbumUserV1': return SyncEntityType.albumUserV1; case r'AlbumUserBackfillV1': return SyncEntityType.albumUserBackfillV1; case r'AlbumUserDeleteV1': return SyncEntityType.albumUserDeleteV1; + case r'AlbumAssetCreateV1': return SyncEntityType.albumAssetCreateV1; + case r'AlbumAssetUpdateV1': return SyncEntityType.albumAssetUpdateV1; case r'AlbumAssetV1': return SyncEntityType.albumAssetV1; case r'AlbumAssetBackfillV1': return SyncEntityType.albumAssetBackfillV1; + case r'AlbumAssetExifCreateV1': return SyncEntityType.albumAssetExifCreateV1; + case r'AlbumAssetExifUpdateV1': return SyncEntityType.albumAssetExifUpdateV1; case r'AlbumAssetExifV1': return SyncEntityType.albumAssetExifV1; case r'AlbumAssetExifBackfillV1': return SyncEntityType.albumAssetExifBackfillV1; case r'AlbumToAssetV1': return SyncEntityType.albumToAssetV1; diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 7d3feb24a3..528f0d3ce0 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -14944,8 +14944,12 @@ "AlbumUserV1", "AlbumUserBackfillV1", "AlbumUserDeleteV1", + "AlbumAssetCreateV1", + "AlbumAssetUpdateV1", "AlbumAssetV1", "AlbumAssetBackfillV1", + "AlbumAssetExifCreateV1", + "AlbumAssetExifUpdateV1", "AlbumAssetExifV1", "AlbumAssetExifBackfillV1", "AlbumToAssetV1", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 8b2ed427b4..7203cac90a 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -4770,8 +4770,12 @@ export enum SyncEntityType { AlbumUserV1 = "AlbumUserV1", AlbumUserBackfillV1 = "AlbumUserBackfillV1", AlbumUserDeleteV1 = "AlbumUserDeleteV1", + AlbumAssetCreateV1 = "AlbumAssetCreateV1", + AlbumAssetUpdateV1 = "AlbumAssetUpdateV1", AlbumAssetV1 = "AlbumAssetV1", AlbumAssetBackfillV1 = "AlbumAssetBackfillV1", + AlbumAssetExifCreateV1 = "AlbumAssetExifCreateV1", + AlbumAssetExifUpdateV1 = "AlbumAssetExifUpdateV1", AlbumAssetExifV1 = "AlbumAssetExifV1", AlbumAssetExifBackfillV1 = "AlbumAssetExifBackfillV1", AlbumToAssetV1 = "AlbumToAssetV1", diff --git a/server/package-lock.json b/server/package-lock.json index ed2dc2e14f..321bbcfc5c 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -85,6 +85,7 @@ "thumbhash": "^0.1.1", "typeorm": "^0.3.17", "ua-parser-js": "^2.0.0", + "uuid": "^11.1.0", "validator": "^13.12.0" }, "devDependencies": { @@ -8745,6 +8746,19 @@ "uuid": "^9.0.0" } }, + "node_modules/bullmq/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -11515,6 +11529,19 @@ "node": ">=14" } }, + "node_modules/gaxios/node_modules/uuid": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", + "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/gcp-metadata": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", @@ -18021,19 +18048,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/typeorm/node_modules/uuid": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", - "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/esm/bin/uuid" - } - }, "node_modules/typescript": { "version": "5.8.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", @@ -18338,16 +18352,16 @@ "license": "MIT" }, "node_modules/uuid": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", - "integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz", + "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" ], "license": "MIT", "bin": { - "uuid": "dist/bin/uuid" + "uuid": "dist/esm/bin/uuid" } }, "node_modules/validator": { diff --git a/server/package.json b/server/package.json index 472b746630..bb16dd9c2d 100644 --- a/server/package.json +++ b/server/package.json @@ -110,6 +110,7 @@ "thumbhash": "^0.1.1", "typeorm": "^0.3.17", "ua-parser-js": "^2.0.0", + "uuid": "^11.1.0", "validator": "^13.12.0" }, "devDependencies": { diff --git a/server/src/dtos/sync.dto.ts b/server/src/dtos/sync.dto.ts index 9c304c0d3c..1aca588b92 100644 --- a/server/src/dtos/sync.dto.ts +++ b/server/src/dtos/sync.dto.ts @@ -339,8 +339,12 @@ export type SyncItem = { [SyncEntityType.AlbumUserV1]: SyncAlbumUserV1; [SyncEntityType.AlbumUserBackfillV1]: SyncAlbumUserV1; [SyncEntityType.AlbumUserDeleteV1]: SyncAlbumUserDeleteV1; + [SyncEntityType.AlbumAssetCreateV1]: SyncAssetV1; + [SyncEntityType.AlbumAssetUpdateV1]: SyncAssetV1; [SyncEntityType.AlbumAssetV1]: SyncAssetV1; [SyncEntityType.AlbumAssetBackfillV1]: SyncAssetV1; + [SyncEntityType.AlbumAssetExifCreateV1]: SyncAssetExifV1; + [SyncEntityType.AlbumAssetExifUpdateV1]: SyncAssetExifV1; [SyncEntityType.AlbumAssetExifV1]: SyncAssetExifV1; [SyncEntityType.AlbumAssetExifBackfillV1]: SyncAssetExifV1; [SyncEntityType.AlbumToAssetV1]: SyncAlbumToAssetV1; diff --git a/server/src/enum.ts b/server/src/enum.ts index 8a6d361d35..01fd1e7c40 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -668,8 +668,14 @@ export enum SyncEntityType { AlbumUserBackfillV1 = 'AlbumUserBackfillV1', AlbumUserDeleteV1 = 'AlbumUserDeleteV1', + AlbumAssetCreateV1 = 'AlbumAssetCreateV1', // album-to-asset table joined to asset table, updateId from album-to-asset, filter out assets owned by you + AlbumAssetUpdateV1 = 'AlbumAssetUpdateV1', // asset table, updateId from asset table, don't send assets where createdAt == updatedAt, filter out assets owned by you AlbumAssetV1 = 'AlbumAssetV1', AlbumAssetBackfillV1 = 'AlbumAssetBackfillV1', + AlbumAssetExifCreateV1 = 'AlbumAssetExifCreateV1', // album-to-asset table joined to asset-exif table, updateId from album-to-asset, filter out assets owned by you + // If exif isn't created straight away that might cause a problem with the exif createV1, maybe we just send an empty object if it doesn't exist yet? + // We would ack this on album-to-asset table so that would work ok for future syncs + AlbumAssetExifUpdateV1 = 'AlbumAssetExifUpdateV1', // asset-exif table, updateId from asset-exif table, filter out assets owned by you AlbumAssetExifV1 = 'AlbumAssetExifV1', AlbumAssetExifBackfillV1 = 'AlbumAssetExifBackfillV1', diff --git a/server/src/repositories/sync.repository.ts b/server/src/repositories/sync.repository.ts index d72ddcfc4d..bb9499c9f6 100644 --- a/server/src/repositories/sync.repository.ts +++ b/server/src/repositories/sync.repository.ts @@ -155,26 +155,27 @@ class AlbumAssetSync extends BaseSync { @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID, DummyValue.UUID], stream: true }) getBackfill(albumId: string, afterUpdateId: string | undefined, beforeUpdateId: string) { return this.db - .selectFrom('asset') - .innerJoin('album_asset', 'album_asset.assetsId', 'asset.id') + .selectFrom('album_asset') + .innerJoin('asset', 'asset.id', 'album_asset.assetsId') .select(columns.syncAsset) - .select('asset.updateId') + .select('album_asset.updateId') .where('album_asset.albumsId', '=', albumId) - .where('asset.updatedAt', '<', sql.raw("now() - interval '1 millisecond'")) - .where('asset.updateId', '<=', beforeUpdateId) - .$if(!!afterUpdateId, (eb) => eb.where('asset.updateId', '>=', afterUpdateId!)) - .orderBy('asset.updateId', 'asc') + .where('album_asset.updatedAt', '<', sql.raw("now() - interval '1 millisecond'")) + .where('album_asset.updateId', '<=', beforeUpdateId) + .$if(!!afterUpdateId, (eb) => eb.where('album_asset.updateId', '>=', afterUpdateId!)) + .orderBy('album_asset.updateId', 'asc') .stream(); } @GenerateSql({ params: [DummyValue.UUID], stream: true }) - getUpserts(userId: string, ack?: SyncAck) { + getUpdates(userId: string, albumToAssetAck: SyncAck, ack?: SyncAck) { return this.db .selectFrom('asset') .innerJoin('album_asset', 'album_asset.assetsId', 'asset.id') .select(columns.syncAsset) .select('asset.updateId') .where('asset.updatedAt', '<', sql.raw("now() - interval '1 millisecond'")) + .where('album_asset.updateId', '<=', albumToAssetAck.updateId) // Ensure we only send updates for assets that the client already knows about .$if(!!ack, (qb) => qb.where('asset.updateId', '>', ack!.updateId)) .orderBy('asset.updateId', 'asc') .innerJoin('album', 'album.id', 'album_asset.albumsId') @@ -182,6 +183,22 @@ class AlbumAssetSync extends BaseSync { .where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('album_user.usersId', '=', userId)])) .stream(); } + + @GenerateSql({ params: [DummyValue.UUID], stream: true }) + getCreates(userId: string, ack?: SyncAck) { + return this.db + .selectFrom('album_asset') + .select('album_asset.updateId') + .innerJoin('asset', 'asset.id', 'album_asset.assetsId') + .select(columns.syncAsset) + .innerJoin('album', 'album.id', 'album_asset.albumsId') + .leftJoin('album_user', 'album_user.albumsId', 'album_asset.albumsId') + .where('album_asset.updatedAt', '<', sql.raw("now() - interval '1 millisecond'")) + .where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('album_user.usersId', '=', userId)])) + .$if(!!ack, (qb) => qb.where('album_asset.updateId', '>', ack!.updateId)) + .orderBy('album_asset.updateId', 'asc') + .stream(); + } } class AlbumAssetExifSync extends BaseSync { diff --git a/server/src/services/sync.service.ts b/server/src/services/sync.service.ts index fee77f35ba..5d915ccdcf 100644 --- a/server/src/services/sync.service.ts +++ b/server/src/services/sync.service.ts @@ -23,6 +23,7 @@ import { getMyPartnerIds } from 'src/utils/asset.util'; import { hexOrBufferToBase64 } from 'src/utils/bytes'; import { setIsEqual } from 'src/utils/set'; import { fromAck, mapJsonLine, serialize, SerializeOptions, toAck } from 'src/utils/sync'; +import { v7 } from 'uuid'; type CheckpointMap = Partial>; type AssetLike = Omit & { @@ -388,10 +389,12 @@ export class SyncService extends BaseService { const backfillType = SyncEntityType.AlbumAssetBackfillV1; const backfillCheckpoint = checkpointMap[backfillType]; const albums = await this.syncRepository.album.getCreatedAfter(auth.user.id, backfillCheckpoint?.updateId); - const upsertType = SyncEntityType.AlbumAssetV1; - const upsertCheckpoint = checkpointMap[upsertType]; - if (upsertCheckpoint) { - const endId = upsertCheckpoint.updateId; + const updateType = SyncEntityType.AlbumAssetUpdateV1; + const createType = SyncEntityType.AlbumAssetCreateV1; + const updateCheckpoint = checkpointMap[updateType]; + const createCheckpoint = checkpointMap[createType]; + if (createCheckpoint) { + const endId = createCheckpoint.updateId; for (const album of albums) { const createId = album.createId; @@ -416,9 +419,26 @@ export class SyncService extends BaseService { }); } - const upserts = this.syncRepository.albumAsset.getUpserts(auth.user.id, checkpointMap[upsertType]); - for await (const { updateId, ...data } of upserts) { - send(response, { type: upsertType, ids: [updateId], data: mapSyncAssetV1(data) }); + if (createCheckpoint) { + const updates = this.syncRepository.albumAsset.getUpdates(auth.user.id, createCheckpoint, updateCheckpoint); + for await (const { updateId, ...data } of updates) { + send(response, { type: updateType, ids: [updateId], data: mapSyncAssetV1(data) }); + } + } + + const creates = this.syncRepository.albumAsset.getCreates(auth.user.id, createCheckpoint); + let first = true; + for await (const { updateId, ...data } of creates) { + if (first) { + send(response, { + type: SyncEntityType.SyncAckV1, + data: {}, + ackType: SyncEntityType.AlbumAssetUpdateV1, + ids: [v7()], + }); + first = false; + } + send(response, { type: createType, ids: [updateId], data: mapSyncAssetV1(data) }); } } diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index 1b669e83e4..1056279e46 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -5,7 +5,15 @@ import { createHash, randomBytes } from 'node:crypto'; import { Writable } from 'node:stream'; import { AssetFace } from 'src/database'; import { AuthDto, LoginResponseDto } from 'src/dtos/auth.dto'; -import { AlbumUserRole, AssetType, AssetVisibility, MemoryType, SourceType, SyncRequestType } from 'src/enum'; +import { + AlbumUserRole, + AssetType, + AssetVisibility, + MemoryType, + SourceType, + SyncEntityType, + 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'; @@ -251,11 +259,16 @@ export class SyncTestContext extends MediumTestContext { async syncAckAll(auth: AuthDto, response: Array<{ type: string; ack: string }>) { const acks: Record = {}; + const syncAcks: string[] = []; for (const { type, ack } of response) { + if (type === SyncEntityType.SyncAckV1) { + syncAcks.push(ack); + continue; + } acks[type] = ack; } - await this.sut.setAcks(auth, { acks: Object.values(acks) }); + await this.sut.setAcks(auth, { acks: [...Object.values(acks), ...syncAcks] }); } } diff --git a/server/test/medium/specs/sync/sync-album-asset.spec.ts b/server/test/medium/specs/sync/sync-album-asset.spec.ts index 314378381f..8d8967c724 100644 --- a/server/test/medium/specs/sync/sync-album-asset.spec.ts +++ b/server/test/medium/specs/sync/sync-album-asset.spec.ts @@ -1,5 +1,6 @@ import { Kysely } from 'kysely'; import { AlbumUserRole, SyncEntityType, SyncRequestType } from 'src/enum'; +import { AssetRepository } from 'src/repositories/asset.repository'; import { DB } from 'src/schema'; import { SyncTestContext } from 'test/medium.factory'; import { factory } from 'test/small.factory'; @@ -13,6 +14,18 @@ const setup = async (db?: Kysely) => { return { auth, user, session, ctx }; }; +const updateSyncAck = { + ack: expect.stringContaining(SyncEntityType.AlbumAssetUpdateV1), + data: {}, + type: SyncEntityType.SyncAckV1, +}; + +const backfillSyncAck = { + ack: expect.stringContaining(SyncEntityType.AlbumAssetBackfillV1), + data: {}, + type: SyncEntityType.SyncAckV1, +}; + beforeAll(async () => { defaultDatabase = await getKyselyDB(); }); @@ -45,8 +58,9 @@ describe(SyncRequestType.AlbumAssetsV1, () => { await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.Editor }); const response = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1]); - expect(response).toHaveLength(1); + expect(response).toHaveLength(2); expect(response).toEqual([ + updateSyncAck, { ack: expect.any(String), data: { @@ -67,7 +81,7 @@ describe(SyncRequestType.AlbumAssetsV1, () => { stackId: asset.stackId, libraryId: asset.libraryId, }, - type: SyncEntityType.AlbumAssetV1, + type: SyncEntityType.AlbumAssetCreateV1, }, ]); @@ -82,7 +96,7 @@ describe(SyncRequestType.AlbumAssetsV1, () => { await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id }); await expect(ctx.syncStream(auth, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1); - await expect(ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1])).resolves.toHaveLength(1); + await expect(ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1])).resolves.toHaveLength(2); }); it('should not sync album asset for unrelated user', async () => { @@ -103,27 +117,31 @@ describe(SyncRequestType.AlbumAssetsV1, () => { it('should backfill album assets when a user shares an album with you', async () => { const { auth, ctx } = await setup(); const { user: user2 } = await ctx.newUser(); - const { asset: asset1Owner } = await ctx.newAsset({ ownerId: auth.user.id }); - await wait(2); + const { album: album1 } = await ctx.newAlbum({ ownerId: user2.id }); + const { album: album2 } = await ctx.newAlbum({ ownerId: user2.id }); const { asset: asset1User2 } = await ctx.newAsset({ ownerId: user2.id }); + await ctx.newAlbumAsset({ albumId: album2.id, assetId: asset1User2.id }); await wait(2); const { asset: asset2User2 } = await ctx.newAsset({ ownerId: user2.id }); + await ctx.newAlbumAsset({ albumId: album2.id, assetId: asset2User2.id }); + await wait(2); + await ctx.newAlbumAsset({ albumId: album1.id, assetId: asset2User2.id }); await wait(2); const { asset: asset3User2 } = await ctx.newAsset({ ownerId: user2.id }); + await ctx.newAlbumAsset({ albumId: album2.id, assetId: asset3User2.id }); await wait(2); - const { album: album1 } = await ctx.newAlbum({ ownerId: user2.id }); - await ctx.newAlbumAsset({ albumId: album1.id, assetId: asset2User2.id }); await ctx.newAlbumUser({ albumId: album1.id, userId: auth.user.id, role: AlbumUserRole.Editor }); const response = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1]); - expect(response).toHaveLength(1); + expect(response).toHaveLength(2); expect(response).toEqual([ + updateSyncAck, { ack: expect.any(String), data: expect.objectContaining({ id: asset2User2.id, }), - type: SyncEntityType.AlbumAssetV1, + type: SyncEntityType.AlbumAssetCreateV1, }, ]); @@ -131,24 +149,12 @@ describe(SyncRequestType.AlbumAssetsV1, () => { await ctx.syncAckAll(auth, response); // create a second album - const { album: album2 } = await ctx.newAlbum({ ownerId: user2.id }); - await Promise.all( - [asset1User2.id, asset2User2.id, asset3User2.id, asset1Owner.id].map((assetId) => - ctx.newAlbumAsset({ albumId: album2.id, assetId }), - ), - ); + await ctx.newAlbumUser({ albumId: album2.id, userId: auth.user.id, role: AlbumUserRole.Editor }); // should backfill the album user const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1]); expect(newResponse).toEqual([ - { - ack: expect.any(String), - data: expect.objectContaining({ - id: asset1Owner.id, - }), - type: SyncEntityType.AlbumAssetBackfillV1, - }, { ack: expect.any(String), data: expect.objectContaining({ @@ -163,17 +169,14 @@ describe(SyncRequestType.AlbumAssetsV1, () => { }), type: SyncEntityType.AlbumAssetBackfillV1, }, - { - ack: expect.stringContaining(SyncEntityType.AlbumAssetBackfillV1), - data: {}, - type: SyncEntityType.SyncAckV1, - }, + backfillSyncAck, + updateSyncAck, { ack: expect.any(String), data: expect.objectContaining({ id: asset3User2.id, }), - type: SyncEntityType.AlbumAssetV1, + type: SyncEntityType.AlbumAssetCreateV1, }, ]); @@ -181,39 +184,38 @@ describe(SyncRequestType.AlbumAssetsV1, () => { await expect(ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1])).resolves.toEqual([]); }); - it('should backfill old album assets when a user adds assets to an album they share you', async () => { + it('should sync old assets when a user adds them to an album they share you', async () => { const { auth, ctx } = await setup(); const { user: user2 } = await ctx.newUser(); - const { asset: firstAsset } = await ctx.newAsset({ ownerId: user2.id }); - await wait(2); - const { asset: secondAsset } = await ctx.newAsset({ ownerId: user2.id }); - await wait(2); - const { asset: album1Asset } = await ctx.newAsset({ ownerId: user2.id }); - await wait(2); + const { asset: firstAsset } = await ctx.newAsset({ ownerId: user2.id, originalFileName: 'firstAsset' }); + const { asset: secondAsset } = await ctx.newAsset({ ownerId: user2.id, originalFileName: 'secondAsset' }); + const { asset: album1Asset } = await ctx.newAsset({ ownerId: user2.id, originalFileName: 'album1Asset' }); const { album: album1 } = await ctx.newAlbum({ ownerId: user2.id }); const { album: album2 } = await ctx.newAlbum({ ownerId: user2.id }); + await ctx.newAlbumAsset({ albumId: album2.id, assetId: firstAsset.id }); + await wait(2); await ctx.newAlbumAsset({ albumId: album1.id, assetId: album1Asset.id }); await ctx.newAlbumUser({ albumId: album1.id, userId: auth.user.id, role: AlbumUserRole.Editor }); const firstAlbumResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1]); - expect(firstAlbumResponse).toHaveLength(1); + expect(firstAlbumResponse).toHaveLength(2); expect(firstAlbumResponse).toEqual([ + updateSyncAck, { ack: expect.any(String), data: expect.objectContaining({ id: album1Asset.id, }), - type: SyncEntityType.AlbumAssetV1, + type: SyncEntityType.AlbumAssetCreateV1, }, ]); await ctx.syncAckAll(auth, firstAlbumResponse); - await ctx.newAlbumAsset({ albumId: album2.id, assetId: firstAsset.id }); await ctx.newAlbumUser({ albumId: album2.id, userId: auth.user.id, role: AlbumUserRole.Editor }); const response = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1]); - expect(response).toHaveLength(1); + // expect(response).toHaveLength(2); expect(response).toEqual([ { ack: expect.any(String), @@ -222,14 +224,10 @@ describe(SyncRequestType.AlbumAssetsV1, () => { }), type: SyncEntityType.AlbumAssetBackfillV1, }, - { - ack: expect.stringContaining(SyncEntityType.AlbumAssetBackfillV1), - data: {}, - type: SyncEntityType.SyncAckV1, - }, + backfillSyncAck, ]); - // ack initial album asset sync + // ack initial album asset sync await ctx.syncAckAll(auth, response); await ctx.newAlbumAsset({ albumId: album2.id, assetId: secondAsset.id }); @@ -238,16 +236,62 @@ describe(SyncRequestType.AlbumAssetsV1, () => { // should backfill the new asset even though it's older than the first asset const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1]); expect(newResponse).toEqual([ + updateSyncAck, { ack: expect.any(String), data: expect.objectContaining({ id: secondAsset.id, }), - type: SyncEntityType.AlbumAssetV1, + type: SyncEntityType.AlbumAssetCreateV1, }, ]); await ctx.syncAckAll(auth, newResponse); await expect(ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1])).resolves.toEqual([]); }); + + it('should sync asset updates for an album shared with you', async () => { + const { auth, ctx } = await setup(); + const { user: user2 } = await ctx.newUser(); + const { asset } = await ctx.newAsset({ ownerId: user2.id, isFavorite: false }); + const { album } = await ctx.newAlbum({ ownerId: user2.id }); + await wait(2); + await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id }); + await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.Editor }); + + const response = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1]); + expect(response).toHaveLength(2); + expect(response).toEqual([ + updateSyncAck, + { + ack: expect.any(String), + data: expect.objectContaining({ + id: asset.id, + }), + type: SyncEntityType.AlbumAssetCreateV1, + }, + ]); + + await ctx.syncAckAll(auth, response); + + // update the asset + const assetRepository = ctx.get(AssetRepository); + await assetRepository.update({ + id: asset.id, + isFavorite: true, + }); + + const updateResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1]); + expect(updateResponse).toHaveLength(1); + expect(updateResponse).toEqual([ + { + ack: expect.any(String), + data: expect.objectContaining({ + id: asset.id, + isFavorite: true, + }), + type: SyncEntityType.AlbumAssetUpdateV1, + }, + ]); + }); });