fix: album asset sync new assets in shared album

This commit is contained in:
Zack Pollard 2025-08-04 21:52:05 +01:00
parent 48a1ac83cd
commit 220ee82c18
11 changed files with 219 additions and 80 deletions

View File

@ -44,8 +44,12 @@ class SyncEntityType {
static const albumUserV1 = SyncEntityType._(r'AlbumUserV1'); static const albumUserV1 = SyncEntityType._(r'AlbumUserV1');
static const albumUserBackfillV1 = SyncEntityType._(r'AlbumUserBackfillV1'); static const albumUserBackfillV1 = SyncEntityType._(r'AlbumUserBackfillV1');
static const albumUserDeleteV1 = SyncEntityType._(r'AlbumUserDeleteV1'); 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 albumAssetV1 = SyncEntityType._(r'AlbumAssetV1');
static const albumAssetBackfillV1 = SyncEntityType._(r'AlbumAssetBackfillV1'); 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 albumAssetExifV1 = SyncEntityType._(r'AlbumAssetExifV1');
static const albumAssetExifBackfillV1 = SyncEntityType._(r'AlbumAssetExifBackfillV1'); static const albumAssetExifBackfillV1 = SyncEntityType._(r'AlbumAssetExifBackfillV1');
static const albumToAssetV1 = SyncEntityType._(r'AlbumToAssetV1'); static const albumToAssetV1 = SyncEntityType._(r'AlbumToAssetV1');
@ -89,8 +93,12 @@ class SyncEntityType {
albumUserV1, albumUserV1,
albumUserBackfillV1, albumUserBackfillV1,
albumUserDeleteV1, albumUserDeleteV1,
albumAssetCreateV1,
albumAssetUpdateV1,
albumAssetV1, albumAssetV1,
albumAssetBackfillV1, albumAssetBackfillV1,
albumAssetExifCreateV1,
albumAssetExifUpdateV1,
albumAssetExifV1, albumAssetExifV1,
albumAssetExifBackfillV1, albumAssetExifBackfillV1,
albumToAssetV1, albumToAssetV1,
@ -169,8 +177,12 @@ class SyncEntityTypeTypeTransformer {
case r'AlbumUserV1': return SyncEntityType.albumUserV1; case r'AlbumUserV1': return SyncEntityType.albumUserV1;
case r'AlbumUserBackfillV1': return SyncEntityType.albumUserBackfillV1; case r'AlbumUserBackfillV1': return SyncEntityType.albumUserBackfillV1;
case r'AlbumUserDeleteV1': return SyncEntityType.albumUserDeleteV1; 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'AlbumAssetV1': return SyncEntityType.albumAssetV1;
case r'AlbumAssetBackfillV1': return SyncEntityType.albumAssetBackfillV1; 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'AlbumAssetExifV1': return SyncEntityType.albumAssetExifV1;
case r'AlbumAssetExifBackfillV1': return SyncEntityType.albumAssetExifBackfillV1; case r'AlbumAssetExifBackfillV1': return SyncEntityType.albumAssetExifBackfillV1;
case r'AlbumToAssetV1': return SyncEntityType.albumToAssetV1; case r'AlbumToAssetV1': return SyncEntityType.albumToAssetV1;

View File

@ -14944,8 +14944,12 @@
"AlbumUserV1", "AlbumUserV1",
"AlbumUserBackfillV1", "AlbumUserBackfillV1",
"AlbumUserDeleteV1", "AlbumUserDeleteV1",
"AlbumAssetCreateV1",
"AlbumAssetUpdateV1",
"AlbumAssetV1", "AlbumAssetV1",
"AlbumAssetBackfillV1", "AlbumAssetBackfillV1",
"AlbumAssetExifCreateV1",
"AlbumAssetExifUpdateV1",
"AlbumAssetExifV1", "AlbumAssetExifV1",
"AlbumAssetExifBackfillV1", "AlbumAssetExifBackfillV1",
"AlbumToAssetV1", "AlbumToAssetV1",

View File

@ -4770,8 +4770,12 @@ export enum SyncEntityType {
AlbumUserV1 = "AlbumUserV1", AlbumUserV1 = "AlbumUserV1",
AlbumUserBackfillV1 = "AlbumUserBackfillV1", AlbumUserBackfillV1 = "AlbumUserBackfillV1",
AlbumUserDeleteV1 = "AlbumUserDeleteV1", AlbumUserDeleteV1 = "AlbumUserDeleteV1",
AlbumAssetCreateV1 = "AlbumAssetCreateV1",
AlbumAssetUpdateV1 = "AlbumAssetUpdateV1",
AlbumAssetV1 = "AlbumAssetV1", AlbumAssetV1 = "AlbumAssetV1",
AlbumAssetBackfillV1 = "AlbumAssetBackfillV1", AlbumAssetBackfillV1 = "AlbumAssetBackfillV1",
AlbumAssetExifCreateV1 = "AlbumAssetExifCreateV1",
AlbumAssetExifUpdateV1 = "AlbumAssetExifUpdateV1",
AlbumAssetExifV1 = "AlbumAssetExifV1", AlbumAssetExifV1 = "AlbumAssetExifV1",
AlbumAssetExifBackfillV1 = "AlbumAssetExifBackfillV1", AlbumAssetExifBackfillV1 = "AlbumAssetExifBackfillV1",
AlbumToAssetV1 = "AlbumToAssetV1", AlbumToAssetV1 = "AlbumToAssetV1",

View File

@ -85,6 +85,7 @@
"thumbhash": "^0.1.1", "thumbhash": "^0.1.1",
"typeorm": "^0.3.17", "typeorm": "^0.3.17",
"ua-parser-js": "^2.0.0", "ua-parser-js": "^2.0.0",
"uuid": "^11.1.0",
"validator": "^13.12.0" "validator": "^13.12.0"
}, },
"devDependencies": { "devDependencies": {
@ -8745,6 +8746,19 @@
"uuid": "^9.0.0" "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": { "node_modules/busboy": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
@ -11515,6 +11529,19 @@
"node": ">=14" "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": { "node_modules/gcp-metadata": {
"version": "6.1.1", "version": "6.1.1",
"resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz",
@ -18021,19 +18048,6 @@
"url": "https://github.com/sponsors/isaacs" "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": { "node_modules/typescript": {
"version": "5.8.3", "version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
@ -18338,16 +18352,16 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/uuid": { "node_modules/uuid": {
"version": "9.0.1", "version": "11.1.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", "resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
"integrity": "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==", "integrity": "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==",
"funding": [ "funding": [
"https://github.com/sponsors/broofa", "https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan" "https://github.com/sponsors/ctavan"
], ],
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"uuid": "dist/bin/uuid" "uuid": "dist/esm/bin/uuid"
} }
}, },
"node_modules/validator": { "node_modules/validator": {

View File

@ -110,6 +110,7 @@
"thumbhash": "^0.1.1", "thumbhash": "^0.1.1",
"typeorm": "^0.3.17", "typeorm": "^0.3.17",
"ua-parser-js": "^2.0.0", "ua-parser-js": "^2.0.0",
"uuid": "^11.1.0",
"validator": "^13.12.0" "validator": "^13.12.0"
}, },
"devDependencies": { "devDependencies": {

View File

@ -339,8 +339,12 @@ export type SyncItem = {
[SyncEntityType.AlbumUserV1]: SyncAlbumUserV1; [SyncEntityType.AlbumUserV1]: SyncAlbumUserV1;
[SyncEntityType.AlbumUserBackfillV1]: SyncAlbumUserV1; [SyncEntityType.AlbumUserBackfillV1]: SyncAlbumUserV1;
[SyncEntityType.AlbumUserDeleteV1]: SyncAlbumUserDeleteV1; [SyncEntityType.AlbumUserDeleteV1]: SyncAlbumUserDeleteV1;
[SyncEntityType.AlbumAssetCreateV1]: SyncAssetV1;
[SyncEntityType.AlbumAssetUpdateV1]: SyncAssetV1;
[SyncEntityType.AlbumAssetV1]: SyncAssetV1; [SyncEntityType.AlbumAssetV1]: SyncAssetV1;
[SyncEntityType.AlbumAssetBackfillV1]: SyncAssetV1; [SyncEntityType.AlbumAssetBackfillV1]: SyncAssetV1;
[SyncEntityType.AlbumAssetExifCreateV1]: SyncAssetExifV1;
[SyncEntityType.AlbumAssetExifUpdateV1]: SyncAssetExifV1;
[SyncEntityType.AlbumAssetExifV1]: SyncAssetExifV1; [SyncEntityType.AlbumAssetExifV1]: SyncAssetExifV1;
[SyncEntityType.AlbumAssetExifBackfillV1]: SyncAssetExifV1; [SyncEntityType.AlbumAssetExifBackfillV1]: SyncAssetExifV1;
[SyncEntityType.AlbumToAssetV1]: SyncAlbumToAssetV1; [SyncEntityType.AlbumToAssetV1]: SyncAlbumToAssetV1;

View File

@ -668,8 +668,14 @@ export enum SyncEntityType {
AlbumUserBackfillV1 = 'AlbumUserBackfillV1', AlbumUserBackfillV1 = 'AlbumUserBackfillV1',
AlbumUserDeleteV1 = 'AlbumUserDeleteV1', 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', AlbumAssetV1 = 'AlbumAssetV1',
AlbumAssetBackfillV1 = 'AlbumAssetBackfillV1', 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', AlbumAssetExifV1 = 'AlbumAssetExifV1',
AlbumAssetExifBackfillV1 = 'AlbumAssetExifBackfillV1', AlbumAssetExifBackfillV1 = 'AlbumAssetExifBackfillV1',

View File

@ -155,26 +155,27 @@ class AlbumAssetSync extends BaseSync {
@GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID, DummyValue.UUID], stream: true }) @GenerateSql({ params: [DummyValue.UUID, DummyValue.UUID, DummyValue.UUID], stream: true })
getBackfill(albumId: string, afterUpdateId: string | undefined, beforeUpdateId: string) { getBackfill(albumId: string, afterUpdateId: string | undefined, beforeUpdateId: string) {
return this.db return this.db
.selectFrom('asset') .selectFrom('album_asset')
.innerJoin('album_asset', 'album_asset.assetsId', 'asset.id') .innerJoin('asset', 'asset.id', 'album_asset.assetsId')
.select(columns.syncAsset) .select(columns.syncAsset)
.select('asset.updateId') .select('album_asset.updateId')
.where('album_asset.albumsId', '=', albumId) .where('album_asset.albumsId', '=', albumId)
.where('asset.updatedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'")) .where('album_asset.updatedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'"))
.where('asset.updateId', '<=', beforeUpdateId) .where('album_asset.updateId', '<=', beforeUpdateId)
.$if(!!afterUpdateId, (eb) => eb.where('asset.updateId', '>=', afterUpdateId!)) .$if(!!afterUpdateId, (eb) => eb.where('album_asset.updateId', '>=', afterUpdateId!))
.orderBy('asset.updateId', 'asc') .orderBy('album_asset.updateId', 'asc')
.stream(); .stream();
} }
@GenerateSql({ params: [DummyValue.UUID], stream: true }) @GenerateSql({ params: [DummyValue.UUID], stream: true })
getUpserts(userId: string, ack?: SyncAck) { getUpdates(userId: string, albumToAssetAck: SyncAck, ack?: SyncAck) {
return this.db return this.db
.selectFrom('asset') .selectFrom('asset')
.innerJoin('album_asset', 'album_asset.assetsId', 'asset.id') .innerJoin('album_asset', 'album_asset.assetsId', 'asset.id')
.select(columns.syncAsset) .select(columns.syncAsset)
.select('asset.updateId') .select('asset.updateId')
.where('asset.updatedAt', '<', sql.raw<Date>("now() - interval '1 millisecond'")) .where('asset.updatedAt', '<', sql.raw<Date>("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)) .$if(!!ack, (qb) => qb.where('asset.updateId', '>', ack!.updateId))
.orderBy('asset.updateId', 'asc') .orderBy('asset.updateId', 'asc')
.innerJoin('album', 'album.id', 'album_asset.albumsId') .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)])) .where((eb) => eb.or([eb('album.ownerId', '=', userId), eb('album_user.usersId', '=', userId)]))
.stream(); .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<Date>("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 { class AlbumAssetExifSync extends BaseSync {

View File

@ -23,6 +23,7 @@ import { getMyPartnerIds } from 'src/utils/asset.util';
import { hexOrBufferToBase64 } from 'src/utils/bytes'; import { hexOrBufferToBase64 } from 'src/utils/bytes';
import { setIsEqual } from 'src/utils/set'; import { setIsEqual } from 'src/utils/set';
import { fromAck, mapJsonLine, serialize, SerializeOptions, toAck } from 'src/utils/sync'; import { fromAck, mapJsonLine, serialize, SerializeOptions, toAck } from 'src/utils/sync';
import { v7 } from 'uuid';
type CheckpointMap = Partial<Record<SyncEntityType, SyncAck>>; type CheckpointMap = Partial<Record<SyncEntityType, SyncAck>>;
type AssetLike = Omit<SyncAssetV1, 'checksum' | 'thumbhash'> & { type AssetLike = Omit<SyncAssetV1, 'checksum' | 'thumbhash'> & {
@ -388,10 +389,12 @@ export class SyncService extends BaseService {
const backfillType = SyncEntityType.AlbumAssetBackfillV1; const backfillType = SyncEntityType.AlbumAssetBackfillV1;
const backfillCheckpoint = checkpointMap[backfillType]; const backfillCheckpoint = checkpointMap[backfillType];
const albums = await this.syncRepository.album.getCreatedAfter(auth.user.id, backfillCheckpoint?.updateId); const albums = await this.syncRepository.album.getCreatedAfter(auth.user.id, backfillCheckpoint?.updateId);
const upsertType = SyncEntityType.AlbumAssetV1; const updateType = SyncEntityType.AlbumAssetUpdateV1;
const upsertCheckpoint = checkpointMap[upsertType]; const createType = SyncEntityType.AlbumAssetCreateV1;
if (upsertCheckpoint) { const updateCheckpoint = checkpointMap[updateType];
const endId = upsertCheckpoint.updateId; const createCheckpoint = checkpointMap[createType];
if (createCheckpoint) {
const endId = createCheckpoint.updateId;
for (const album of albums) { for (const album of albums) {
const createId = album.createId; const createId = album.createId;
@ -416,9 +419,26 @@ export class SyncService extends BaseService {
}); });
} }
const upserts = this.syncRepository.albumAsset.getUpserts(auth.user.id, checkpointMap[upsertType]); if (createCheckpoint) {
for await (const { updateId, ...data } of upserts) { const updates = this.syncRepository.albumAsset.getUpdates(auth.user.id, createCheckpoint, updateCheckpoint);
send(response, { type: upsertType, ids: [updateId], data: mapSyncAssetV1(data) }); 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) });
} }
} }

View File

@ -5,7 +5,15 @@ import { createHash, randomBytes } from 'node:crypto';
import { Writable } from 'node:stream'; import { Writable } from 'node:stream';
import { AssetFace } from 'src/database'; import { AssetFace } from 'src/database';
import { AuthDto, LoginResponseDto } from 'src/dtos/auth.dto'; 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 { AccessRepository } from 'src/repositories/access.repository';
import { ActivityRepository } from 'src/repositories/activity.repository'; import { ActivityRepository } from 'src/repositories/activity.repository';
import { AlbumUserRepository } from 'src/repositories/album-user.repository'; import { AlbumUserRepository } from 'src/repositories/album-user.repository';
@ -251,11 +259,16 @@ export class SyncTestContext extends MediumTestContext<SyncService> {
async syncAckAll(auth: AuthDto, response: Array<{ type: string; ack: string }>) { async syncAckAll(auth: AuthDto, response: Array<{ type: string; ack: string }>) {
const acks: Record<string, string> = {}; const acks: Record<string, string> = {};
const syncAcks: string[] = [];
for (const { type, ack } of response) { for (const { type, ack } of response) {
if (type === SyncEntityType.SyncAckV1) {
syncAcks.push(ack);
continue;
}
acks[type] = ack; acks[type] = ack;
} }
await this.sut.setAcks(auth, { acks: Object.values(acks) }); await this.sut.setAcks(auth, { acks: [...Object.values(acks), ...syncAcks] });
} }
} }

View File

@ -1,5 +1,6 @@
import { Kysely } from 'kysely'; import { Kysely } from 'kysely';
import { AlbumUserRole, SyncEntityType, SyncRequestType } from 'src/enum'; import { AlbumUserRole, SyncEntityType, SyncRequestType } from 'src/enum';
import { AssetRepository } from 'src/repositories/asset.repository';
import { DB } from 'src/schema'; import { DB } from 'src/schema';
import { SyncTestContext } from 'test/medium.factory'; import { SyncTestContext } from 'test/medium.factory';
import { factory } from 'test/small.factory'; import { factory } from 'test/small.factory';
@ -13,6 +14,18 @@ const setup = async (db?: Kysely<DB>) => {
return { auth, user, session, ctx }; 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 () => { beforeAll(async () => {
defaultDatabase = await getKyselyDB(); defaultDatabase = await getKyselyDB();
}); });
@ -45,8 +58,9 @@ describe(SyncRequestType.AlbumAssetsV1, () => {
await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.Editor }); await ctx.newAlbumUser({ albumId: album.id, userId: auth.user.id, role: AlbumUserRole.Editor });
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1]); const response = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1]);
expect(response).toHaveLength(1); expect(response).toHaveLength(2);
expect(response).toEqual([ expect(response).toEqual([
updateSyncAck,
{ {
ack: expect.any(String), ack: expect.any(String),
data: { data: {
@ -67,7 +81,7 @@ describe(SyncRequestType.AlbumAssetsV1, () => {
stackId: asset.stackId, stackId: asset.stackId,
libraryId: asset.libraryId, 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 ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
await expect(ctx.syncStream(auth, [SyncRequestType.AssetsV1])).resolves.toHaveLength(1); 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 () => { 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 () => { it('should backfill album assets when a user shares an album with you', async () => {
const { auth, ctx } = await setup(); const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser(); const { user: user2 } = await ctx.newUser();
const { asset: asset1Owner } = await ctx.newAsset({ ownerId: auth.user.id }); const { album: album1 } = await ctx.newAlbum({ ownerId: user2.id });
await wait(2); const { album: album2 } = await ctx.newAlbum({ ownerId: user2.id });
const { asset: asset1User2 } = await ctx.newAsset({ ownerId: user2.id }); const { asset: asset1User2 } = await ctx.newAsset({ ownerId: user2.id });
await ctx.newAlbumAsset({ albumId: album2.id, assetId: asset1User2.id });
await wait(2); await wait(2);
const { asset: asset2User2 } = await ctx.newAsset({ ownerId: user2.id }); 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); await wait(2);
const { asset: asset3User2 } = await ctx.newAsset({ ownerId: user2.id }); const { asset: asset3User2 } = await ctx.newAsset({ ownerId: user2.id });
await ctx.newAlbumAsset({ albumId: album2.id, assetId: asset3User2.id });
await wait(2); 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 }); await ctx.newAlbumUser({ albumId: album1.id, userId: auth.user.id, role: AlbumUserRole.Editor });
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1]); const response = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1]);
expect(response).toHaveLength(1); expect(response).toHaveLength(2);
expect(response).toEqual([ expect(response).toEqual([
updateSyncAck,
{ {
ack: expect.any(String), ack: expect.any(String),
data: expect.objectContaining({ data: expect.objectContaining({
id: asset2User2.id, id: asset2User2.id,
}), }),
type: SyncEntityType.AlbumAssetV1, type: SyncEntityType.AlbumAssetCreateV1,
}, },
]); ]);
@ -131,24 +149,12 @@ describe(SyncRequestType.AlbumAssetsV1, () => {
await ctx.syncAckAll(auth, response); await ctx.syncAckAll(auth, response);
// create a second album // 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 }); await ctx.newAlbumUser({ albumId: album2.id, userId: auth.user.id, role: AlbumUserRole.Editor });
// should backfill the album user // should backfill the album user
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1]); const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1]);
expect(newResponse).toEqual([ expect(newResponse).toEqual([
{
ack: expect.any(String),
data: expect.objectContaining({
id: asset1Owner.id,
}),
type: SyncEntityType.AlbumAssetBackfillV1,
},
{ {
ack: expect.any(String), ack: expect.any(String),
data: expect.objectContaining({ data: expect.objectContaining({
@ -163,17 +169,14 @@ describe(SyncRequestType.AlbumAssetsV1, () => {
}), }),
type: SyncEntityType.AlbumAssetBackfillV1, type: SyncEntityType.AlbumAssetBackfillV1,
}, },
{ backfillSyncAck,
ack: expect.stringContaining(SyncEntityType.AlbumAssetBackfillV1), updateSyncAck,
data: {},
type: SyncEntityType.SyncAckV1,
},
{ {
ack: expect.any(String), ack: expect.any(String),
data: expect.objectContaining({ data: expect.objectContaining({
id: asset3User2.id, 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([]); 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 { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser(); const { user: user2 } = await ctx.newUser();
const { asset: firstAsset } = await ctx.newAsset({ ownerId: user2.id }); const { asset: firstAsset } = await ctx.newAsset({ ownerId: user2.id, originalFileName: 'firstAsset' });
await wait(2); const { asset: secondAsset } = await ctx.newAsset({ ownerId: user2.id, originalFileName: 'secondAsset' });
const { asset: secondAsset } = await ctx.newAsset({ ownerId: user2.id }); const { asset: album1Asset } = await ctx.newAsset({ ownerId: user2.id, originalFileName: 'album1Asset' });
await wait(2);
const { asset: album1Asset } = await ctx.newAsset({ ownerId: user2.id });
await wait(2);
const { album: album1 } = await ctx.newAlbum({ ownerId: user2.id }); const { album: album1 } = await ctx.newAlbum({ ownerId: user2.id });
const { album: album2 } = 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.newAlbumAsset({ albumId: album1.id, assetId: album1Asset.id });
await ctx.newAlbumUser({ albumId: album1.id, userId: auth.user.id, role: AlbumUserRole.Editor }); await ctx.newAlbumUser({ albumId: album1.id, userId: auth.user.id, role: AlbumUserRole.Editor });
const firstAlbumResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1]); const firstAlbumResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1]);
expect(firstAlbumResponse).toHaveLength(1); expect(firstAlbumResponse).toHaveLength(2);
expect(firstAlbumResponse).toEqual([ expect(firstAlbumResponse).toEqual([
updateSyncAck,
{ {
ack: expect.any(String), ack: expect.any(String),
data: expect.objectContaining({ data: expect.objectContaining({
id: album1Asset.id, id: album1Asset.id,
}), }),
type: SyncEntityType.AlbumAssetV1, type: SyncEntityType.AlbumAssetCreateV1,
}, },
]); ]);
await ctx.syncAckAll(auth, firstAlbumResponse); 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 }); await ctx.newAlbumUser({ albumId: album2.id, userId: auth.user.id, role: AlbumUserRole.Editor });
const response = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1]); const response = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1]);
expect(response).toHaveLength(1); // expect(response).toHaveLength(2);
expect(response).toEqual([ expect(response).toEqual([
{ {
ack: expect.any(String), ack: expect.any(String),
@ -222,14 +224,10 @@ describe(SyncRequestType.AlbumAssetsV1, () => {
}), }),
type: SyncEntityType.AlbumAssetBackfillV1, type: SyncEntityType.AlbumAssetBackfillV1,
}, },
{ backfillSyncAck,
ack: expect.stringContaining(SyncEntityType.AlbumAssetBackfillV1),
data: {},
type: SyncEntityType.SyncAckV1,
},
]); ]);
// ack initial album asset sync // ack initial album asset sync
await ctx.syncAckAll(auth, response); await ctx.syncAckAll(auth, response);
await ctx.newAlbumAsset({ albumId: album2.id, assetId: secondAsset.id }); 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 // should backfill the new asset even though it's older than the first asset
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1]); const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1]);
expect(newResponse).toEqual([ expect(newResponse).toEqual([
updateSyncAck,
{ {
ack: expect.any(String), ack: expect.any(String),
data: expect.objectContaining({ data: expect.objectContaining({
id: secondAsset.id, id: secondAsset.id,
}), }),
type: SyncEntityType.AlbumAssetV1, type: SyncEntityType.AlbumAssetCreateV1,
}, },
]); ]);
await ctx.syncAckAll(auth, newResponse); await ctx.syncAckAll(auth, newResponse);
await expect(ctx.syncStream(auth, [SyncRequestType.AlbumAssetsV1])).resolves.toEqual([]); 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,
},
]);
});
}); });