Implement incremental sync and offline recovery

This commit is contained in:
diyaa
2026-06-15 22:31:23 +02:00
parent fa7727d572
commit 295c6c1d9b
25 changed files with 2293 additions and 494 deletions
+140 -10
View File
@@ -84,6 +84,7 @@ async function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {
function createPrismaMock() {
const users = new Map<string, any>();
const devices = new Map<string, any>();
const deviceSyncCursors = new Map<string, any>();
const tracks = new Map<string, any>();
const audioAssets = new Map<string, any>();
const artworkAssets = new Map<string, any>();
@@ -91,21 +92,81 @@ function createPrismaMock() {
const libraryEvents = new Map<bigint, any>();
let nextLibraryEventId = 1n;
const defaultUser = {
const createUserRecord = (data: Record<string, any>) => {
const now = new Date();
return {
id: data.id ?? randomUUID(),
createdAt: data.createdAt ?? now,
updatedAt: data.updatedAt ?? now,
...data,
libraryCursor:
data.libraryCursor == null ? 0n : BigInt(data.libraryCursor),
};
};
const defaultUser = createUserRecord({
id: randomUUID(),
slug: 'default-owner',
displayName: 'Default Owner',
isDefault: true,
createdAt: new Date(),
updatedAt: new Date(),
};
libraryCursor: 0n,
});
users.set(defaultUser.id, defaultUser);
const prismaMock: any = {
$queryRawUnsafe: jest.fn().mockResolvedValue([{ '?column?': 1 }]),
$transaction: jest.fn().mockImplementation(async (callback: any) => callback(prismaMock)),
user: {
upsert: jest.fn().mockResolvedValue(defaultUser),
upsert: jest.fn().mockImplementation(async ({ where, update, create }) => {
const current =
[...users.values()].find((user) => user.slug === where.slug) ?? null;
if (current) {
const updated = createUserRecord({
...current,
...update,
id: current.id,
createdAt: current.createdAt,
updatedAt: new Date(),
libraryCursor: current.libraryCursor,
});
users.set(updated.id, updated);
return updated;
}
const created = createUserRecord(create);
users.set(created.id, created);
return created;
}),
create: jest.fn().mockImplementation(async ({ data }) => {
const created = createUserRecord(data);
users.set(created.id, created);
return created;
}),
update: jest.fn().mockImplementation(async ({ where, data, select }) => {
const current = users.get(where.id);
if (!current) {
throw new Error(
`Test Prisma mock invariant failed: user ${where.id} not found for update`,
);
}
const incrementBy = BigInt(data.libraryCursor?.increment ?? 0);
const updated = createUserRecord({
...current,
updatedAt: new Date(),
libraryCursor: current.libraryCursor + incrementBy,
});
users.set(where.id, updated);
if (select?.libraryCursor) {
return {
libraryCursor: updated.libraryCursor,
};
}
return updated;
}),
},
device: {
create: jest.fn().mockImplementation(async ({ data }) => {
@@ -314,11 +375,44 @@ function createPrismaMock() {
nextLibraryEventId += 1n;
return record;
}),
findFirst: jest.fn().mockImplementation(async ({ where }) => {
findFirst: jest.fn().mockImplementation(async ({ where, orderBy }) => {
const filteredEvents = [...libraryEvents.values()].filter((event) =>
where?.userId ? event.userId === where.userId : true,
);
return filteredEvents.sort((lhs, rhs) => Number(rhs.id - lhs.id))[0] ?? null;
const direction = orderBy?.cursor ?? 'desc';
return filteredEvents
.sort((lhs, rhs) =>
direction === 'asc'
? Number(lhs.cursor - rhs.cursor)
: Number(rhs.cursor - lhs.cursor),
)[0] ?? null;
}),
findMany: jest.fn().mockImplementation(async ({ where, take }) => {
return [...libraryEvents.values()]
.filter(
(event) =>
(where?.userId ? event.userId === where.userId : true) &&
(where?.cursor?.gt != null ? event.cursor > where.cursor.gt : true),
)
.sort((lhs, rhs) => Number(lhs.cursor - rhs.cursor))
.slice(0, take);
}),
},
deviceSyncCursor: {
upsert: jest.fn().mockImplementation(async ({ where, update, create }) => {
const current = deviceSyncCursors.get(where.deviceId);
const nextRecord = current
? {
...current,
...update,
updatedAt: new Date(),
}
: {
...create,
updatedAt: new Date(),
};
deviceSyncCursors.set(where.deviceId, nextRecord);
return nextRecord;
}),
},
};
@@ -327,7 +421,9 @@ function createPrismaMock() {
prismaMock,
state: {
defaultUser,
users,
devices,
deviceSyncCursors,
tracks,
audioAssets,
artworkAssets,
@@ -696,12 +792,20 @@ describe('Velody API wiring (e2e)', () => {
syncController.bootstrap(),
);
const changesResponse = await runAsDevice(device.deviceAccessToken, () =>
syncController.changes({ after: '0' }),
syncController.changes({ cursor: '0' }),
);
expect(bootstrapResponse.tracks).toEqual([]);
expect(bootstrapResponse.nextCursor).toBe('0');
expect(changesResponse.events).toEqual([]);
expect(changesResponse.nextCursor).toBe('0');
expect(changesResponse.hasMore).toBe(false);
expect(changesResponse.requiresBootstrap).toBe(false);
expect(prismaState.deviceSyncCursors.get(device.deviceId)).toMatchObject({
deviceId: device.deviceId,
userId: prismaState.defaultUser.id,
cursor: 0n,
});
});
it('sync bootstrap and changes do not expose foreign-owner data', async () => {
@@ -734,10 +838,24 @@ describe('Velody API wiring (e2e)', () => {
});
prismaState.libraryEvents.set(1n, {
id: 1n,
cursor: 1n,
userId: foreignUserId,
entityType: 'TRACK',
entityId: foreignTrackId,
action: 'CREATED',
payload: {
track: {
trackId: foreignTrackId,
title: 'Foreign Bootstrap Track',
artist: 'Elsewhere',
durationSeconds: 180,
sha256: 'f'.repeat(64),
assetId: randomUUID(),
createdAt: '2026-05-29T08:00:00.000Z',
updatedAt: '2026-05-29T08:01:00.000Z',
artwork: null,
},
},
payloadVersion: 1,
createdAt: new Date('2026-05-29T08:02:00.000Z'),
});
@@ -746,7 +864,7 @@ describe('Velody API wiring (e2e)', () => {
syncController.bootstrap(),
);
const changesResponse = await runAsDevice(device.deviceAccessToken, () =>
syncController.changes({ after: '0' }),
syncController.changes({ cursor: '0' }),
);
expect(bootstrapResponse.tracks).toEqual([]);
@@ -1590,6 +1708,18 @@ describe('Velody API wiring (e2e)', () => {
it('makes an upload from one device visible to another linked device under the same owner', async () => {
const identityUserId = randomUUID();
prismaState.users.set(
identityUserId,
{
id: identityUserId,
slug: `identity-${identityUserId}`,
displayName: 'Identity Owner',
isDefault: false,
libraryCursor: 0n,
createdAt: new Date(),
updatedAt: new Date(),
},
);
const primaryDevice = seedDevice({
userId: identityUserId,
deviceAccessToken: 'linked-upload-primary-token',
@@ -1713,7 +1843,7 @@ describe('Velody API wiring (e2e)', () => {
expect(duplicatePrepare.status).toBe('exists');
expect(duplicatePrepare.uploadId).toBeDefined();
expect(prismaState.audioAssets.size).toBe(1);
expect(prismaState.libraryEvents.size).toBe(1);
expect(prismaState.libraryEvents.size).toBe(2);
});
it('supports upload finalize with embedded artwork and exposes remote artwork metadata', async () => {