Implement incremental sync and offline recovery
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user