diff --git a/server/tests/unit/nest/doc-sync/doc-sync.service.test.ts b/server/tests/unit/nest/doc-sync/doc-sync.service.test.ts index 3068b7ab4..fd3079137 100644 --- a/server/tests/unit/nest/doc-sync/doc-sync.service.test.ts +++ b/server/tests/unit/nest/doc-sync/doc-sync.service.test.ts @@ -62,7 +62,19 @@ import type { AddonsService } from '../../../../src/nest/addons/addons.service'; import type { FilesService } from '../../../../src/nest/files/files.service'; import type { StorageService } from '../../../../src/nest/storage/storage.service'; import type { RealtimeService } from '../../../../src/nest/realtime/realtime.service'; -import { createTestUnitOfWork, createTestAppSettingsRepo, createTestTripsRepo } from '../../../helpers/test-uow'; +import { createTestUnitOfWork, createTestAppSettingsRepo, createTestTripsRepo, sharedTestOrm } from '../../../helpers/test-uow'; +import type { TestOrm } from '../../../helpers/test-orm'; +import { countRows, deleteRows, findRow, findRows, insertRow, updateRows } from '../../../helpers/factories/rows'; +import { dbNow } from '../../../../src/db/types/db-timestamp.type'; +import { CollabMessages } from '../../../../src/db/entities/CollabMessages.entity'; +import { CollabNotes } from '../../../../src/db/entities/CollabNotes.entity'; +import { DocumentConnections } from '../../../../src/db/entities/DocumentConnections.entity'; +import { DocumentProviders } from '../../../../src/db/entities/DocumentProviders.entity'; +import { DocumentSyncItems } from '../../../../src/db/entities/DocumentSyncItems.entity'; +import { FileLinks } from '../../../../src/db/entities/FileLinks.entity'; +import { Reservations } from '../../../../src/db/entities/Reservations.entity'; +import { TripDocumentLinks } from '../../../../src/db/entities/TripDocumentLinks.entity'; +import { TripFiles } from '../../../../src/db/entities/TripFiles.entity'; import { createTestDocumentConnectionsRepo, createTestDocumentProviderFieldsRepo, @@ -159,29 +171,26 @@ const storage = { } as unknown as StorageService; const files = { - getFileById: vi.fn((id: string | number, tripId: string | number) => - testDb.prepare('SELECT * FROM trip_files WHERE id = ? AND trip_id = ?').get(id, tripId)), - createFile: vi.fn(( + getFileById: vi.fn(async (id: string | number, tripId: string | number) => + (await findRow(orm, TripFiles, { id: Number(id), trip: Number(tripId) })) ?? undefined), + createFile: vi.fn(async ( tripId: string | number, file: { filename: string; originalname: string; size: number; mimetype: string }, uploadedBy: number, opts: { place_id?: string | number | null; reservation_id?: string | number | null; description?: string | null } = {}, ) => { - const info = testDb - .prepare( - `INSERT INTO trip_files (trip_id, place_id, reservation_id, filename, original_name, file_size, mime_type, description, uploaded_by) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ) - .run( - tripId, opts.place_id || null, opts.reservation_id || null, - file.filename, file.originalname, file.size, file.mimetype, opts.description || null, uploadedBy || null, - ); - return testDb.prepare('SELECT * FROM trip_files WHERE id = ?').get(info.lastInsertRowid); + const id = await insertRow(orm, TripFiles, { + trip: Number(tripId), place: opts.place_id ? Number(opts.place_id) : null, + reservation: opts.reservation_id ? Number(opts.reservation_id) : null, + filename: file.filename, original_name: file.originalname, file_size: file.size, mime_type: file.mimetype, + description: opts.description || null, uploadedByRef: uploadedBy || null, + }); + return findRow(orm, TripFiles, { id }); }), // Writes for real, like the two above: a double that answers but changes // nothing lets a missing call pass as a passing test. - softDeleteFile: vi.fn((id: string | number) => { - testDb.prepare('UPDATE trip_files SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?').run(id); + softDeleteFile: vi.fn(async (id: string | number) => { + await updateRows(orm, TripFiles, { id: Number(id) }, { deleted_at: dbNow() }); }), } as unknown as FilesService; @@ -193,6 +202,7 @@ let tripId: number; let connectionId: number; let service: DocSyncService; let config: DocSyncConfigService; +let orm: TestOrm; // ── Row builders ───────────────────────────────────────────────────────────── @@ -208,27 +218,21 @@ async function makeLink(over: { providerId?: string; } = {}): Promise { scopeSeq += 1; - const info = testDb - .prepare( - `INSERT INTO trip_document_links - (trip_id, connection_id, provider_id, remote_scope_key, remote_root_id, remote_root_path, remote_label, - direction, delete_policy, conflict_policy, sync_enabled, failure_count, next_attempt_at, created_by) - VALUES (?, ?, ?, ?, '1', '/TREK/japan', 'Japan 2026', ?, ?, ?, ?, ?, ?, ?)`, - ) - .run( - tripId, connectionId, over.providerId ?? 'paperless', `tag:${scopeSeq}`, - over.direction ?? 'both', over.deletePolicy ?? 'unlink', over.conflictPolicy ?? 'manual', - over.syncEnabled ?? 1, over.failureCount ?? 0, over.nextAttemptAt ?? null, ownerId, - ); + const id = await insertRow(orm, TripDocumentLinks, { + trip: tripId, connection: connectionId, provider_id: over.providerId ?? 'paperless', remote_scope_key: `tag:${scopeSeq}`, + remote_root_id: '1', remote_root_path: '/TREK/japan', remote_label: 'Japan 2026', + direction: over.direction ?? 'both', delete_policy: over.deletePolicy ?? 'unlink', conflict_policy: over.conflictPolicy ?? 'manual', + sync_enabled: over.syncEnabled ?? 1, failure_count: over.failureCount ?? 0, next_attempt_at: over.nextAttemptAt ?? null, + createdByRef: ownerId, + }); if (over.lastSyncAt !== undefined) { - testDb.prepare('UPDATE trip_document_links SET last_sync_at = ? WHERE id = ?') - .run(over.lastSyncAt, Number(info.lastInsertRowid)); + await updateRows(orm, TripDocumentLinks, { id }, { last_sync_at: over.lastSyncAt }); } - return (await config.getLink(Number(info.lastInsertRowid)))!; + return (await config.getLink(id))!; } let uidSeq = 0; -function seedItem(link: LinkRow, over: { +async function seedItem(link: LinkRow, over: { remoteId?: string; remoteName?: string; remoteVersion?: string; @@ -239,57 +243,44 @@ function seedItem(link: LinkRow, over: { remoteSize?: number; remoteModifiedAt?: string; remoteMissingAt?: string; -} = {}): number { +} = {}): Promise { uidSeq += 1; - const info = testDb - .prepare( - `INSERT INTO document_sync_items - (link_id, trip_id, file_id, trek_doc_uid, remote_id, remote_name, remote_version, - remote_size, remote_modified_at, content_sha256, pushed_sha256, state, remote_missing_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ) - .run( - link.id, link.trip_id, over.fileId ?? null, `uid-${uidSeq}`, - over.remoteId ?? null, over.remoteName ?? null, over.remoteVersion ?? null, - over.remoteSize ?? null, over.remoteModifiedAt ?? null, - over.contentSha256 ?? null, over.pushedSha256 ?? null, over.state ?? 'synced', - over.remoteMissingAt ?? null, - ); - return Number(info.lastInsertRowid); + return insertRow(orm, DocumentSyncItems, { + link: link.id, trip: link.trip_id, file: over.fileId ?? null, trek_doc_uid: `uid-${uidSeq}`, + remote_id: over.remoteId ?? null, remote_name: over.remoteName ?? null, remote_version: over.remoteVersion ?? null, + remote_size: over.remoteSize ?? null, remote_modified_at: over.remoteModifiedAt ?? null, + content_sha256: over.contentSha256 ?? null, pushed_sha256: over.pushedSha256 ?? null, state: over.state ?? 'synced', + remote_missing_at: over.remoteMissingAt ?? null, + }); } -function makeFile(over: { +async function makeFile(over: { name?: string; storageKey?: string; mime?: string; messageId?: number; noteId?: number; deletedAt?: string; -} = {}): number { - const info = testDb - .prepare( - `INSERT INTO trip_files (trip_id, filename, original_name, file_size, mime_type, uploaded_by, message_id, note_id, deleted_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, - ) - .run( - tripId, over.storageKey ?? `key-${over.name ?? 'boarding.pdf'}`, over.name ?? 'boarding.pdf', - FILE_BYTES.length, over.mime ?? 'application/pdf', ownerId, - over.messageId ?? null, over.noteId ?? null, over.deletedAt ?? null, - ); - return Number(info.lastInsertRowid); +} = {}): Promise { + return insertRow(orm, TripFiles, { + trip: tripId, filename: over.storageKey ?? `key-${over.name ?? 'boarding.pdf'}`, original_name: over.name ?? 'boarding.pdf', + file_size: FILE_BYTES.length, mime_type: over.mime ?? 'application/pdf', uploadedByRef: ownerId, + message: over.messageId ?? null, note: over.noteId ?? null, deleted_at: over.deletedAt ?? null, + }); } -const itemRow = (id: number) => - testDb.prepare('SELECT * FROM document_sync_items WHERE id = ?').get(id) as Record; -const itemRows = () => - testDb.prepare('SELECT * FROM document_sync_items ORDER BY id').all() as Array>; -const linkRow = (id: number) => - testDb.prepare('SELECT * FROM trip_document_links WHERE id = ?').get(id) as Record; -const fileRows = () => - testDb.prepare('SELECT * FROM trip_files ORDER BY id').all() as Array>; +const itemRow = async (id: number) => + (await findRow(orm, DocumentSyncItems, { id })) as Record; +const itemRows = async () => + (await findRows(orm, DocumentSyncItems, {}, { id: 'asc' })) as Array>; +const linkRow = async (id: number) => + (await findRow(orm, TripDocumentLinks, { id })) as Record; +const fileRows = async () => + (await findRows(orm, TripFiles, {}, { id: 'asc' })) as Array>; /** SQLite's own clock and format, so a comparison against CURRENT_TIMESTAMP means something. */ const sqlTime = (modifier: string): string => + // test-sql-allow: the timestamp has to come from SQLite's own clock, the one CURRENT_TIMESTAMP compares against. (testDb.prepare("SELECT datetime('now', ?) AS t").get(modifier) as { t: string }).t; /** @@ -300,7 +291,7 @@ const addons = { isAddonEnabled: vi.fn(() => true) }; /** The admin's per-provider switch, which is a real row rather than a double. */ const switchProvider = (id: string, on: boolean) => - testDb.prepare('UPDATE document_providers SET enabled = ? WHERE id = ?').run(on ? 1 : 0, id); + updateRows(orm, DocumentProviders, { id }, { enabled: on ? 1 : 0 }); // ── Suite ──────────────────────────────────────────────────────────────────── @@ -308,15 +299,12 @@ describe('DocSyncService', () => { beforeAll(async () => { spoolDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-docsync-')); + orm = await sharedTestOrm(testDb); ownerId = createUser(testDb, { username: 'owner', email: 'owner@docsync.test' }).user.id; tripId = createTrip(testDb, ownerId, { title: 'Japan' }).id; - const info = testDb - .prepare( - `INSERT INTO document_connections (trip_id, provider_id, owner_user_id, base_url, secrets, settings) - VALUES (?, 'paperless', ?, 'https://paperless.example.com', NULL, '{}')`, - ) - .run(tripId, ownerId); - connectionId = Number(info.lastInsertRowid); + connectionId = await insertRow(orm, DocumentConnections, { + trip: tripId, provider: 'paperless', ownerUser: ownerId, base_url: 'https://paperless.example.com', secrets: null, settings: '{}', + }); const registry = new DocumentProviderRegistry([provider as unknown as DocumentProvider]); config = new DocSyncConfigService( @@ -351,17 +339,17 @@ describe('DocSyncService', () => { fs.rmSync(spoolDir, { recursive: true, force: true }); }); - beforeEach(() => { + beforeEach(async () => { vi.resetAllMocks(); capabilities = { ...BASE_CAPABILITIES }; - testDb.prepare('DELETE FROM document_sync_items').run(); - testDb.prepare('DELETE FROM trip_document_links').run(); - testDb.prepare('DELETE FROM trip_files').run(); - testDb.prepare('DELETE FROM collab_messages').run(); - testDb.prepare('DELETE FROM collab_notes').run(); + await deleteRows(orm, DocumentSyncItems); + await deleteRows(orm, TripDocumentLinks); + await deleteRows(orm, TripFiles); + await deleteRows(orm, CollabMessages); + await deleteRows(orm, CollabNotes); // Providers ship switched off; the fake one stands in for Paperless. - testDb.prepare('UPDATE document_providers SET enabled = 0').run(); - switchProvider('paperless', true); + await updateRows(orm, DocumentProviders, {}, { enabled: 0 }); + await switchProvider('paperless', true); for (const entry of fs.readdirSync(spoolDir)) fs.rmSync(path.join(spoolDir, entry), { force: true }); }); @@ -447,7 +435,7 @@ describe('DocSyncService', () => { // It would never run, so it would never get a last_sync_at and would take // one of the slots on every tick. const orphaned = await makeLink({ lastSyncAt: null }); - testDb.prepare("UPDATE trip_document_links SET last_sync_state = 'orphaned' WHERE id = ?").run(orphaned.id); + await updateRows(orm, TripDocumentLinks, { id: orphaned.id }, { last_sync_state: 'orphaned' }); const live = await makeLink({ lastSyncAt: sqlTime('-1 minute') }); expect((await service.dueLinks()).map((l) => l.id)).toEqual([live.id]); @@ -486,7 +474,7 @@ describe('DocSyncService', () => { expect(res.state).toBe('scope_lost'); expect(res.errorCode).toBe('scope_missing'); - expect(linkRow(link.id).last_sync_state).toBe('scope_lost'); + expect((await linkRow(link.id)).last_sync_state).toBe('scope_lost'); // Nothing is enumerated once the container is known to be gone, so a // deleted folder cannot be re-created and refilled. expect(provider.list).not.toHaveBeenCalled(); @@ -509,7 +497,7 @@ describe('DocSyncService', () => { const res = await service.syncLink(link); expect(res.state).toBe('needs_reauth'); - const row = linkRow(link.id); + const row = await linkRow(link.id); expect(row.last_sync_state).toBe('needs_reauth'); expect(row.last_sync_error).toBe('unauthorized'); }); @@ -521,7 +509,7 @@ describe('DocSyncService', () => { const res = await service.syncLink(link); expect(res.state).toBe('failed'); - expect(linkRow(link.id).last_sync_state).toBe('failed'); + expect((await linkRow(link.id)).last_sync_state).toBe('failed'); }); /** @@ -533,9 +521,11 @@ describe('DocSyncService', () => { */ it('abandons the entire run when most known documents vanish from one listing', async () => { const link = await makeLink(); - const itemIds = Array.from({ length: 10 }, (_, i) => - seedItem(link, { remoteId: `r${i}`, remoteName: `doc-${i}.pdf`, state: 'synced' })); - makeFile({ name: 'local-only.pdf' }); + const itemIds: number[] = []; + for (let i = 0; i < 10; i += 1) { + itemIds.push(await seedItem(link, { remoteId: `r${i}`, remoteName: `doc-${i}.pdf`, state: 'synced' })); + } + await makeFile({ name: 'local-only.pdf' }); provider.list.mockResolvedValueOnce(ok(listing([remoteDoc({ remoteId: 'r0', name: 'doc-0.pdf' })]))); const res = await service.syncLink(link); @@ -551,12 +541,12 @@ describe('DocSyncService', () => { expect(files.createFile).not.toHaveBeenCalled(); for (const id of itemIds) { - const row = itemRow(id); + const row = await itemRow(id); expect(row.state).toBe('synced'); expect(row.remote_missing_at).toBeNull(); } - expect(fileRows()).toHaveLength(1); - expect(linkRow(link.id).last_sync_error).toBe('mass_delete_guard'); + expect(await fileRows()).toHaveLength(1); + expect((await linkRow(link.id)).last_sync_error).toBe('mass_delete_guard'); }); /** @@ -575,8 +565,8 @@ describe('DocSyncService', () => { const res = await service.syncLink(link); expect(provider.fetch).not.toHaveBeenCalled(); - expect(fileRows()).toHaveLength(0); - expect(itemRows().map((r) => [r.remote_id, r.state, r.error_code])).toEqual([ + expect(await fileRows()).toHaveLength(0); + expect((await itemRows()).map((r) => [r.remote_id, r.state, r.error_code])).toEqual([ ['r-svg', 'rejected_type', 'unsupported_type'], ['r-html', 'rejected_type', 'unsupported_type'], ]); @@ -592,7 +582,7 @@ describe('DocSyncService', () => { await service.syncLink(link); expect(provider.fetch).not.toHaveBeenCalled(); - const row = itemRows()[0]; + const row = (await itemRows())[0]; expect(row.state).toBe('too_large'); expect(row.error_code).toBe('too_large'); }); @@ -605,12 +595,12 @@ describe('DocSyncService', () => { it('never uploads a document whose type the provider has said it will not take', async () => { const link = await makeLink(); capabilities = { ...BASE_CAPABILITIES, acceptedMimeTypes: ['application/pdf'] }; - const fileId = makeFile({ name: 'sunset.png', mime: 'image/png' }); + const fileId = await makeFile({ name: 'sunset.png', mime: 'image/png' }); await service.syncLink(link); expect(provider.push).not.toHaveBeenCalled(); - const row = itemRows()[0]; + const row = (await itemRows())[0]; expect(row.file_id).toBe(fileId); expect(row.state).toBe('rejected_type'); expect(row.error_code).toBe('unsupported_type'); @@ -624,12 +614,12 @@ describe('DocSyncService', () => { */ it('does not pull back the bytes it uploaded itself', async () => { const link = await makeLink(); - makeFile({ name: 'boarding.pdf' }); + await makeFile({ name: 'boarding.pdf' }); provider.list.mockResolvedValueOnce(ok(listing([]))); const firstRun = await service.syncLink(link); expect(firstRun.pushed).toBe(1); - const afterPush = itemRows()[0]; + const afterPush = (await itemRows())[0]; expect(afterPush.pushed_sha256).toBe(FILE_SHA); expect(afterPush.content_sha256).toBe(FILE_SHA); expect(afterPush.remote_id).toBe('r-pushed'); @@ -645,8 +635,8 @@ describe('DocSyncService', () => { expect(provider.fetch).not.toHaveBeenCalled(); expect(provider.push).toHaveBeenCalledTimes(1); expect(secondRun.pulled).toBe(0); - expect(fileRows()).toHaveLength(1); - expect(itemRows()[0].state).toBe('synced'); + expect(await fileRows()).toHaveLength(1); + expect((await itemRows())[0].state).toBe('synced'); }); it('refuses to move a pairing onto a document another file already owns', async () => { @@ -655,8 +645,8 @@ describe('DocSyncService', () => { // document, and the unique index on (link_id, remote_id) turned that // into a constraint error that aborted this run and every run after it. const link = await makeLink(); - makeFile({ name: 'boarding.pdf' }); - makeFile({ name: 'boarding-copy.pdf', storageKey: 'key-copy' }); + await makeFile({ name: 'boarding.pdf' }); + await makeFile({ name: 'boarding-copy.pdf', storageKey: 'key-copy' }); provider.list.mockResolvedValue(ok(listing([]))); const run = await service.syncLink(link); @@ -667,7 +657,7 @@ describe('DocSyncService', () => { expect(run.pushed).toBe(1); expect(run.conflicts).toBe(1); - const rows = itemRows(); + const rows = await itemRows(); expect(rows).toHaveLength(2); // The first file keeps the document. The second is on record as the one // that could not have it, with its bytes still in TREK. @@ -684,6 +674,7 @@ describe('DocSyncService', () => { await service.syncLink(link); const after = testDb + // test-sql-allow: compares against SQLite's own clock, which is what CURRENT_TIMESTAMP and the stored text are measured by. .prepare('SELECT failure_count, next_attempt_at > CURRENT_TIMESTAMP AS in_future FROM trip_document_links WHERE id = ?') .get(link.id) as { failure_count: number; in_future: number }; expect(after.failure_count).toBe(1); @@ -691,7 +682,7 @@ describe('DocSyncService', () => { provider.list.mockResolvedValueOnce(fail('unreachable')); await service.syncLink(await config.getLink(link.id)); - expect(linkRow(link.id).failure_count).toBe(2); + expect((await linkRow(link.id)).failure_count).toBe(2); }); it('clears the failure count and the wait as soon as a run succeeds', async () => { @@ -700,7 +691,7 @@ describe('DocSyncService', () => { const res = await service.syncLink(link); expect(res.state).toBe('ok'); - const row = linkRow(link.id); + const row = await linkRow(link.id); expect(row.failure_count).toBe(0); expect(row.next_attempt_at).toBeNull(); expect(row.remote_cursor).toBe('cursor-1'); @@ -714,22 +705,18 @@ describe('DocSyncService', () => { */ it('leaves chat and note attachments out of the upload entirely', async () => { const link = await makeLink(); - const messageId = Number(testDb - .prepare("INSERT INTO collab_messages (trip_id, user_id, text) VALUES (?, ?, 'hi')") - .run(tripId, ownerId).lastInsertRowid); - const noteId = Number(testDb - .prepare("INSERT INTO collab_notes (trip_id, user_id, title) VALUES (?, ?, 'note')") - .run(tripId, ownerId).lastInsertRowid); + const messageId = await insertRow(orm, CollabMessages, { trip: tripId, user: ownerId, text: 'hi' }); + const noteId = await insertRow(orm, CollabNotes, { trip: tripId, user: ownerId, title: 'note' }); - makeFile({ name: 'boarding.pdf' }); - makeFile({ name: 'chat-screenshot.pdf', storageKey: 'key-chat', messageId }); - makeFile({ name: 'note-attachment.pdf', storageKey: 'key-note', noteId }); + await makeFile({ name: 'boarding.pdf' }); + await makeFile({ name: 'chat-screenshot.pdf', storageKey: 'key-chat', messageId }); + await makeFile({ name: 'note-attachment.pdf', storageKey: 'key-note', noteId }); await service.syncLink(link); expect(provider.push).toHaveBeenCalledTimes(1); expect(provider.push.mock.calls[0][2].fileName).toBe('boarding.pdf'); - expect(itemRows()).toHaveLength(1); + expect(await itemRows()).toHaveLength(1); }); /** @@ -739,32 +726,32 @@ describe('DocSyncService', () => { */ it('records a document that vanished upstream without removing anything locally', async () => { const link = await makeLink(); - const fileId = makeFile({ name: 'boarding.pdf' }); - const itemId = seedItem(link, { remoteId: 'r1', remoteName: 'boarding.pdf', fileId, state: 'synced' }); + const fileId = await makeFile({ name: 'boarding.pdf' }); + const itemId = await seedItem(link, { remoteId: 'r1', remoteName: 'boarding.pdf', fileId, state: 'synced' }); const res = await service.syncLink(link); expect(res.missing).toBe(1); - const row = itemRow(itemId); + const row = await itemRow(itemId); expect(row.state).toBe('remote_missing'); expect(row.remote_missing_at).not.toBeNull(); - expect(fileRows().map((f) => f.id)).toEqual([fileId]); + expect((await fileRows()).map((f) => f.id)).toEqual([fileId]); expect(provider.trash).not.toHaveBeenCalled(); }); it('moves a deleted document to the provider recycle bin only when the binding says trash', async () => { const trashing = await makeLink({ deletePolicy: 'trash' }); - const fileId = makeFile({ name: 'boarding.pdf', deletedAt: '2026-09-18 08:00:00' }); - const itemId = seedItem(trashing, { remoteId: 'r1', remoteName: 'boarding.pdf', fileId, state: 'synced' }); + const fileId = await makeFile({ name: 'boarding.pdf', deletedAt: '2026-09-18 08:00:00' }); + const itemId = await seedItem(trashing, { remoteId: 'r1', remoteName: 'boarding.pdf', fileId, state: 'synced' }); provider.list.mockResolvedValue(ok(listing([remoteDoc({ remoteId: 'r1' })]))); await service.syncLink(trashing); expect(provider.trash).toHaveBeenCalledWith(expect.anything(), expect.anything(), 'r1'); - expect(itemRow(itemId).state).toBe('local_deleted'); + expect((await itemRow(itemId)).state).toBe('local_deleted'); const unlinking = await makeLink({ deletePolicy: 'unlink' }); - const otherFileId = makeFile({ name: 'other.pdf', storageKey: 'key-other', deletedAt: '2026-09-18 08:00:00' }); - seedItem(unlinking, { remoteId: 'r1', remoteName: 'boarding.pdf', fileId: otherFileId, state: 'synced' }); + const otherFileId = await makeFile({ name: 'other.pdf', storageKey: 'key-other', deletedAt: '2026-09-18 08:00:00' }); + await seedItem(unlinking, { remoteId: 'r1', remoteName: 'boarding.pdf', fileId: otherFileId, state: 'synced' }); provider.trash.mockClear(); await service.syncLink(unlinking); @@ -773,58 +760,58 @@ describe('DocSyncService', () => { it('carries a rename made in TREK over to the provider', async () => { const link = await makeLink(); - const fileId = makeFile({ name: 'new.pdf' }); - const itemId = seedItem(link, { + const fileId = await makeFile({ name: 'new.pdf' }); + const itemId = (await seedItem(link, { remoteId: 'r1', remoteName: 'old.pdf', remoteVersion: 'v1', fileId, state: 'synced', - }); + })); provider.list.mockResolvedValueOnce(ok(listing([remoteDoc({ remoteId: 'r1', name: 'old.pdf' })]))); await service.syncLink(link); expect(provider.rename).toHaveBeenCalledWith(expect.anything(), expect.anything(), 'r1', 'new.pdf'); - const row = itemRow(itemId); + const row = await itemRow(itemId); expect(row.remote_name).toBe('new.pdf'); expect(row.remote_version).toBe('v2'); }); it('carries a rename made at the provider over to the trip file', async () => { const link = await makeLink(); - const fileId = makeFile({ name: 'old.pdf' }); - const itemId = seedItem(link, { + const fileId = await makeFile({ name: 'old.pdf' }); + const itemId = (await seedItem(link, { remoteId: 'r1', remoteName: 'old.pdf', remoteVersion: 'v1', fileId, state: 'synced', - }); + })); provider.list.mockResolvedValueOnce(ok(listing([remoteDoc({ remoteId: 'r1', name: 'new-upstream.pdf' })]))); await service.syncLink(link); expect(provider.rename).not.toHaveBeenCalled(); - expect(fileRows()[0].original_name).toBe('new-upstream.pdf'); - expect(itemRow(itemId).remote_name).toBe('new-upstream.pdf'); + expect((await fileRows())[0].original_name).toBe('new-upstream.pdf'); + expect((await itemRow(itemId)).remote_name).toBe('new-upstream.pdf'); expect(realtime.broadcast).toHaveBeenCalledWith(tripId, 'file:updated', expect.anything()); }); it('hands a document both sides renamed to a person instead of picking one', async () => { const link = await makeLink(); - const fileId = makeFile({ name: 'mine.pdf' }); - const itemId = seedItem(link, { + const fileId = await makeFile({ name: 'mine.pdf' }); + const itemId = (await seedItem(link, { remoteId: 'r1', remoteName: 'old.pdf', remoteVersion: 'v1', fileId, state: 'synced', - }); + })); provider.list.mockResolvedValueOnce(ok(listing([remoteDoc({ remoteId: 'r1', name: 'theirs.pdf' })]))); const res = await service.syncLink(link); expect(res.conflicts).toBe(1); expect(provider.rename).not.toHaveBeenCalled(); - expect(itemRow(itemId).state).toBe('conflict'); + expect((await itemRow(itemId)).state).toBe('conflict'); expect((await service.issues(tripId)).map((r) => r.id)).toContain(itemId); }); it('settles a double rename TREK wins with a rename, not a second upload', async () => { const link = await makeLink({ conflictPolicy: 'trek_wins' }); - const fileId = makeFile({ name: 'mine.pdf' }); - const itemId = seedItem(link, { + const fileId = await makeFile({ name: 'mine.pdf' }); + const itemId = (await seedItem(link, { remoteId: 'r1', remoteName: 'old.pdf', remoteVersion: 'v1', fileId, state: 'synced', - }); + })); provider.list.mockResolvedValueOnce(ok(listing([remoteDoc({ remoteId: 'r1', name: 'theirs.pdf' })]))); const res = await service.syncLink(link); @@ -832,13 +819,13 @@ describe('DocSyncService', () => { expect(res.conflicts).toBe(0); expect(provider.push).not.toHaveBeenCalled(); expect(provider.rename).toHaveBeenCalledWith(expect.anything(), expect.anything(), 'r1', 'mine.pdf'); - expect(itemRow(itemId)).toMatchObject({ state: 'synced', remote_name: 'mine.pdf' }); + expect(await itemRow(itemId)).toMatchObject({ state: 'synced', remote_name: 'mine.pdf' }); provider.list.mockResolvedValueOnce(ok(listing([remoteDoc({ remoteId: 'r1', name: 'mine.pdf', remoteVersion: 'v2' })]))); await service.syncLink(await config.getLink(link.id)); expect(provider.rename).toHaveBeenCalledTimes(1); - expect(fileRows().map((f) => f.original_name)).toEqual(['mine.pdf']); + expect((await fileRows()).map((f) => f.original_name)).toEqual(['mine.pdf']); }); /** @@ -848,33 +835,33 @@ describe('DocSyncService', () => { */ it('takes a copy on record as missing off the list when it is back under a new name', async () => { const link = await makeLink(); - const fileId = makeFile({ name: 'old.pdf' }); - const itemId = seedItem(link, { + const fileId = await makeFile({ name: 'old.pdf' }); + const itemId = (await seedItem(link, { remoteId: 'r1', remoteName: 'old.pdf', remoteVersion: 'v1', fileId, state: 'remote_missing', remoteMissingAt: '2026-09-18 09:00:00', - }); + })); provider.list.mockResolvedValueOnce(ok(listing([remoteDoc({ remoteId: 'r1', name: 'new-upstream.pdf' })]))); await service.syncLink(link); - expect(fileRows()[0].original_name).toBe('new-upstream.pdf'); - expect(itemRow(itemId)).toMatchObject({ state: 'synced', remote_missing_at: null, remote_name: 'new-upstream.pdf' }); + expect((await fileRows())[0].original_name).toBe('new-upstream.pdf'); + expect(await itemRow(itemId)).toMatchObject({ state: 'synced', remote_missing_at: null, remote_name: 'new-upstream.pdf' }); expect(await service.issues(tripId)).toEqual([]); }); it('does the same when the name TREK gave it meanwhile is carried upstream', async () => { const link = await makeLink(); - const fileId = makeFile({ name: 'new.pdf' }); - const itemId = seedItem(link, { + const fileId = await makeFile({ name: 'new.pdf' }); + const itemId = (await seedItem(link, { remoteId: 'r1', remoteName: 'old.pdf', remoteVersion: 'v1', fileId, state: 'remote_missing', remoteMissingAt: '2026-09-18 09:00:00', - }); + })); provider.list.mockResolvedValueOnce(ok(listing([remoteDoc({ remoteId: 'r1', name: 'old.pdf' })]))); await service.syncLink(link); expect(provider.rename).toHaveBeenCalledWith(expect.anything(), expect.anything(), 'r1', 'new.pdf'); - expect(itemRow(itemId)).toMatchObject({ state: 'synced', remote_missing_at: null, remote_name: 'new.pdf' }); + expect(await itemRow(itemId)).toMatchObject({ state: 'synced', remote_missing_at: null, remote_name: 'new.pdf' }); }); /** @@ -895,8 +882,8 @@ describe('DocSyncService', () => { await service.syncLink(link); expect(storage.put).not.toHaveBeenCalled(); - expect(fileRows()).toHaveLength(0); - expect(itemRows()[0].state).toBe('too_large'); + expect(await fileRows()).toHaveLength(0); + expect((await itemRows())[0].state).toBe('too_large'); expect(fs.readdirSync(spoolDir)).toHaveLength(0); }); @@ -908,12 +895,12 @@ describe('DocSyncService', () => { const res = await service.syncLink(link); expect(res.state).toBe('partial'); - const row = itemRows()[0]; + const row = (await itemRows())[0]; expect(row.state).toBe('error'); expect(row.error_code).toBe('timeout'); expect(row.attempts).toBe(1); expect(row.file_id).toBeNull(); - expect(fileRows()).toHaveLength(0); + expect(await fileRows()).toHaveLength(0); expect(await service.issues(tripId)).toHaveLength(1); }); @@ -929,7 +916,7 @@ describe('DocSyncService', () => { await service.syncLink(link); await service.syncLink(link); - const row = itemRows()[0]; + const row = (await itemRows())[0]; expect(row.state).toBe('error'); expect(row.file_id).toBeNull(); expect(await service.issues(tripId)).toHaveLength(1); @@ -943,19 +930,19 @@ describe('DocSyncService', () => { await service.syncLink(link); expect(files.createFile).not.toHaveBeenCalled(); - expect(fileRows()).toHaveLength(0); - expect(itemRows()[0].state).toBe('error'); + expect(await fileRows()).toHaveLength(0); + expect((await itemRows())[0].state).toBe('error'); }); it('keeps the document anchor when an upload fails, so the retry is not a second document', async () => { const link = await makeLink(); - makeFile({ name: 'boarding.pdf' }); + await makeFile({ name: 'boarding.pdf' }); provider.push.mockResolvedValueOnce(fail('quota_exceeded')); const res = await service.syncLink(link); expect(res.errorCode).toBe('quota_exceeded'); - const row = itemRows()[0]; + const row = (await itemRows())[0]; expect(row.state).toBe('error'); expect(row.error_code).toBe('quota_exceeded'); expect(row.trek_doc_uid).toBeTruthy(); @@ -964,24 +951,24 @@ describe('DocSyncService', () => { it('refuses an upload the provider has told TREK is over its size limit', async () => { const link = await makeLink(); capabilities = { ...BASE_CAPABILITIES, maxUploadBytes: 10 }; - makeFile({ name: 'boarding.pdf' }); + await makeFile({ name: 'boarding.pdf' }); await service.syncLink(link); expect(provider.push).not.toHaveBeenCalled(); - expect(itemRows()[0].state).toBe('too_large'); + expect((await itemRows())[0].state).toBe('too_large'); }); it('records an upload whose bytes are missing from storage instead of throwing', async () => { const link = await makeLink(); - makeFile({ name: 'boarding.pdf' }); + await makeFile({ name: 'boarding.pdf' }); (storage.getStream as unknown as ReturnType).mockRejectedValueOnce(new Error('gone')); const res = await service.syncLink(link); expect(res.errorCode).toBe('provider_error'); expect(provider.push).not.toHaveBeenCalled(); - expect(itemRows()[0].state).toBe('error'); + expect((await itemRows())[0].state).toBe('error'); }); /** @@ -998,7 +985,7 @@ describe('DocSyncService', () => { expect(res.pulled).toBe(MAX_TRANSFERS_PER_RUN); expect(provider.fetch).toHaveBeenCalledTimes(MAX_TRANSFERS_PER_RUN); - expect(fileRows()).toHaveLength(MAX_TRANSFERS_PER_RUN); + expect(await fileRows()).toHaveLength(MAX_TRANSFERS_PER_RUN); }); it('records an unexpected crash as a failed run instead of letting it escape', async () => { @@ -1009,13 +996,13 @@ describe('DocSyncService', () => { expect(res.state).toBe('failed'); expect(res.errorCode).toBe('unknown'); - expect(linkRow(link.id).failure_count).toBe(1); + expect((await linkRow(link.id)).failure_count).toBe(1); }); it('fails a binding for a provider this build does not have', async () => { const link = await makeLink(); - testDb.prepare("UPDATE trip_document_links SET provider_id = 'papra' WHERE id = ?").run(link.id); - switchProvider('papra', true); + await updateRows(orm, TripDocumentLinks, { id: link.id }, { provider_id: 'papra' }); + await switchProvider('papra', true); const res = await service.syncLink(await config.getLink(link.id)); @@ -1048,8 +1035,8 @@ describe('DocSyncService', () => { it('leaves a binding whose provider is off out of the due list', async () => { await makeLink(); const on = await makeLink({ providerId: 'nextcloud' }); - switchProvider('paperless', false); - switchProvider('nextcloud', true); + await switchProvider('paperless', false); + await switchProvider('nextcloud', true); expect((await service.dueLinks()).map((l) => l.id)).toEqual([on.id]); }); @@ -1059,89 +1046,88 @@ describe('DocSyncService', () => { // on every tick for good. for (let i = 0; i < 25; i += 1) await makeLink({ lastSyncAt: null }); const on = await makeLink({ providerId: 'nextcloud', lastSyncAt: sqlTime('-1 minute') }); - switchProvider('paperless', false); - switchProvider('nextcloud', true); + await switchProvider('paperless', false); + await switchProvider('nextcloud', true); expect((await service.dueLinks()).map((l) => l.id)).toEqual([on.id]); }); it('stands down without touching the binding or its documents while the provider is off', async () => { const link = await makeLink({ failureCount: 2 }); - testDb.prepare("UPDATE trip_document_links SET last_sync_state = 'partial', last_sync_error = 'timeout' WHERE id = ?") - .run(link.id); - const itemId = seedItem(link, { remoteId: 'r1', remoteVersion: 'v1', state: 'error' }); - testDb.prepare('UPDATE document_sync_items SET attempts = 6 WHERE id = ?').run(itemId); - const linkBefore = linkRow(link.id); - const itemBefore = itemRow(itemId); - switchProvider('paperless', false); + await updateRows(orm, TripDocumentLinks, { id: link.id }, { last_sync_state: 'partial', last_sync_error: 'timeout' }); + const itemId = await seedItem(link, { remoteId: 'r1', remoteVersion: 'v1', state: 'error' }); + await updateRows(orm, DocumentSyncItems, { id: itemId }, { attempts: 6 }); + const linkBefore = await linkRow(link.id); + const itemBefore = await itemRow(itemId); + await switchProvider('paperless', false); const res = await service.syncLink(await config.getLink(link.id)); expect(res.state).toBe('disabled'); expect(provider.resolveScope).not.toHaveBeenCalled(); expect(provider.list).not.toHaveBeenCalled(); - expect(linkRow(link.id)).toEqual(linkBefore); - expect(itemRow(itemId)).toEqual(itemBefore); + expect(await linkRow(link.id)).toEqual(linkBefore); + expect(await itemRow(itemId)).toEqual(itemBefore); }); it('stands down the same way while the Documents addon is off', async () => { const link = await makeLink(); - const before = linkRow(link.id); + const before = await linkRow(link.id); addons.isAddonEnabled.mockReturnValue(false); const res = await service.syncLink(link); expect(res.state).toBe('disabled'); expect(provider.list).not.toHaveBeenCalled(); - expect(linkRow(link.id)).toEqual(before); + expect(await linkRow(link.id)).toEqual(before); }); it('stands down before it looks for the connection, so a gone one is not a failure either', async () => { const link = { ...(await makeLink()), connection_id: 999999 }; - switchProvider('paperless', false); + await switchProvider('paperless', false); expect((await service.syncLink(link)).state).toBe('disabled'); - expect(linkRow(link.id).failure_count).toBe(0); + expect((await linkRow(link.id)).failure_count).toBe(0); }); it('picks up where it stopped once the provider is back on', async () => { const link = await makeLink(); provider.list.mockResolvedValue(ok(listing([remoteDoc()]))); - switchProvider('paperless', false); + await switchProvider('paperless', false); await service.syncLink(link); - expect(fileRows()).toHaveLength(0); + expect(await fileRows()).toHaveLength(0); - switchProvider('paperless', true); + await switchProvider('paperless', true); expect((await service.dueLinks()).map((l) => l.id)).toEqual([link.id]); const res = await service.syncLink(await config.getLink(link.id)); expect(res).toMatchObject({ state: 'ok', pulled: 1 }); - expect(fileRows()).toHaveLength(1); + expect(await fileRows()).toHaveLength(1); }); it('never runs an orphaned binding, whoever asks', async () => { // Sync automatically switched back on puts sync_enabled to 1 again, and a // webhook or a resolved conflict calls syncLink without asking first. const link = await makeLink(); - testDb.prepare("UPDATE trip_document_links SET last_sync_state = 'orphaned' WHERE id = ?").run(link.id); - const before = linkRow(link.id); + await updateRows(orm, TripDocumentLinks, { id: link.id }, { last_sync_state: 'orphaned' }); + const before = await linkRow(link.id); const res = await service.syncLink(await config.getLink(link.id)); expect(res.state).toBe('orphaned'); expect(provider.resolveScope).not.toHaveBeenCalled(); expect(provider.list).not.toHaveBeenCalled(); - expect(linkRow(link.id)).toEqual(before); + expect(await linkRow(link.id)).toEqual(before); }); it('answers the same question for any caller that has to refuse up front', async () => { const link = await makeLink(); expect(await service.isSwitchedOff(link)).toBe(false); - switchProvider('paperless', false); + await switchProvider('paperless', false); expect(await service.isSwitchedOff(link)).toBe(true); - switchProvider('paperless', true); + await switchProvider('paperless', true); addons.isAddonEnabled.mockReturnValue(false); expect(await service.isSwitchedOff(link)).toBe(true); }); @@ -1150,9 +1136,9 @@ describe('DocSyncService', () => { describe('status', () => { it('reports the trip bindings together with a count per item state', async () => { const link = await makeLink(); - seedItem(link, { remoteId: 'r1', state: 'synced' }); - seedItem(link, { remoteId: 'r2', state: 'synced' }); - seedItem(link, { remoteId: 'r3', state: 'conflict' }); + await seedItem(link, { remoteId: 'r1', state: 'synced' }); + await seedItem(link, { remoteId: 'r2', state: 'synced' }); + await seedItem(link, { remoteId: 'r3', state: 'conflict' }); const status = await service.status(tripId); @@ -1163,8 +1149,8 @@ describe('DocSyncService', () => { it('says which bindings are paused because their provider is switched off', async () => { const off = await makeLink(); const on = await makeLink({ providerId: 'nextcloud' }); - switchProvider('paperless', false); - switchProvider('nextcloud', true); + await switchProvider('paperless', false); + await switchProvider('nextcloud', true); const links = (await service.status(tripId)).links as Array<{ id: number; providerOff: boolean }>; @@ -1176,9 +1162,9 @@ describe('DocSyncService', () => { describe('itemsForTrip', () => { it('M2 (Plan 3h Task 7 review): an empty `?state=` returns every item, not none — legacy truthiness, not an `undefined` check', async () => { const link = await makeLink(); - const fileId = makeFile(); - const withFile = seedItem(link, { remoteId: 'r1', state: 'synced', fileId }); - const withoutFile = seedItem(link, { remoteId: 'r2', state: 'conflict' }); + const fileId = await makeFile(); + const withFile = await seedItem(link, { remoteId: 'r1', state: 'synced', fileId }); + const withoutFile = await seedItem(link, { remoteId: 'r2', state: 'conflict' }); // `state === undefined ? listForTrip : listForTripByState` (the R2 // regression this ratchet pins) would treat `''` as a real filter @@ -1199,8 +1185,8 @@ describe('DocSyncService', () => { it('a real state value still narrows to that one state', async () => { const link = await makeLink(); - seedItem(link, { remoteId: 'r1', state: 'synced' }); - const conflicted = seedItem(link, { remoteId: 'r2', state: 'conflict' }); + await seedItem(link, { remoteId: 'r1', state: 'synced' }); + const conflicted = await seedItem(link, { remoteId: 'r2', state: 'conflict' }); const rows = await service.itemsForTrip(tripId, 'conflict'); @@ -1210,22 +1196,22 @@ describe('DocSyncService', () => { describe('resolveConflict', () => { /** A conflicted pairing: the provider moved on, and TREK holds its own copy. */ - function seedConflict(link: LinkRow): { itemId: number; fileId: number } { - const fileId = makeFile({ name: 'boarding.pdf' }); - const itemId = seedItem(link, { + async function seedConflict(link: LinkRow): Promise<{ itemId: number; fileId: number }> { + const fileId = await makeFile({ name: 'boarding.pdf' }); + const itemId = (await seedItem(link, { remoteId: 'r1', remoteName: 'boarding.pdf', remoteVersion: 'v1', fileId, state: 'conflict', contentSha256: 'agreed-hash', - }); + })); return { itemId, fileId }; } it('keeps both copies and gives each its own pairing when the user asks for both', async () => { const link = await makeLink(); - const { itemId, fileId } = seedConflict(link); + const { itemId, fileId } = await seedConflict(link); provider.list.mockResolvedValueOnce(ok(listing([ remoteDoc({ remoteId: 'r1', name: 'boarding.pdf', remoteVersion: 'v2' }), ]))); @@ -1236,10 +1222,10 @@ describe('DocSyncService', () => { // goes up as a second provider document. Neither side loses anything. expect(provider.fetch).toHaveBeenCalledTimes(1); expect(provider.push).toHaveBeenCalledTimes(1); - const filesAfter = fileRows(); + const filesAfter = await fileRows(); expect(filesAfter).toHaveLength(2); expect(filesAfter.map((f) => f.id)).toContain(fileId); - expect(itemRows()).toHaveLength(2); + expect(await itemRows()).toHaveLength(2); }); /** @@ -1257,7 +1243,7 @@ describe('DocSyncService', () => { // agreed to this", and the provider's copy comes down over the one the // user just chose to keep. const link = await makeLink(); - const { itemId } = seedConflict(link); + const { itemId } = await seedConflict(link); provider.list.mockResolvedValueOnce(ok(listing([ remoteDoc({ remoteId: 'r1', name: 'boarding.pdf', remoteVersion: 'v2' }), ]))); @@ -1265,12 +1251,12 @@ describe('DocSyncService', () => { await service.resolveConflict(itemId, 'trek'); expect(provider.fetch).not.toHaveBeenCalled(); - expect(fileRows()).toHaveLength(1); + expect(await fileRows()).toHaveLength(1); }); it('pulls the provider copy when the conflict was resolved in its favour', async () => { const link = await makeLink(); - const { itemId } = seedConflict(link); + const { itemId } = await seedConflict(link); provider.list.mockResolvedValueOnce(ok(listing([ remoteDoc({ remoteId: 'r1', name: 'boarding.pdf', remoteVersion: 'v2' }), ]))); @@ -1278,7 +1264,7 @@ describe('DocSyncService', () => { await service.resolveConflict(itemId, 'provider'); expect(provider.fetch).toHaveBeenCalledTimes(1); - expect(itemRow(itemId).state).toBe('synced'); + expect((await itemRow(itemId)).state).toBe('synced'); }); /** @@ -1288,22 +1274,22 @@ describe('DocSyncService', () => { * whatever the owner chose, the issue came back within minutes, forever. */ describe('a double rename', () => { - function seedRenameConflict(link: LinkRow) { - const fileId = makeFile({ name: 'Invoice.pdf' }); // renamed in TREK - const itemId = seedItem(link, { + async function seedRenameConflict(link: LinkRow) { + const fileId = await makeFile({ name: 'Invoice.pdf' }); // renamed in TREK + const itemId = (await seedItem(link, { remoteId: 'r1', remoteName: 'Bill.pdf', // what both agreed on remoteVersion: 'v1', fileId, state: 'conflict', contentSha256: 'agreed-hash', - }); + })); return { itemId, fileId }; } it('renames the provider copy to TREK name when TREK wins', async () => { const link = await makeLink(); - const { itemId } = seedRenameConflict(link); + const { itemId } = await seedRenameConflict(link); provider.list.mockResolvedValue(ok(listing([ remoteDoc({ remoteId: 'r1', name: 'Invoice.pdf', remoteVersion: 'v2' }), ]))); @@ -1313,20 +1299,20 @@ describe('DocSyncService', () => { expect(provider.rename).toHaveBeenCalledWith( expect.anything(), expect.anything(), 'r1', 'Invoice.pdf', ); - expect(itemRow(itemId).remote_name).toBe('Invoice.pdf'); + expect((await itemRow(itemId)).remote_name).toBe('Invoice.pdf'); }); it('takes the local rename back when the provider wins, so TREK follows next run', async () => { const link = await makeLink(); - const { itemId, fileId } = seedRenameConflict(link); + const { itemId, fileId } = await seedRenameConflict(link); provider.list.mockResolvedValue(ok(listing([ remoteDoc({ remoteId: 'r1', name: 'Rechnung.pdf', remoteVersion: 'v1' }), ]))); await service.resolveConflict(itemId, 'provider'); - const file = testDb.prepare('SELECT original_name FROM trip_files WHERE id = ?').get(fileId) as { original_name: string }; - expect(file.original_name).toBe('Rechnung.pdf'); + const file = await findRow(orm, TripFiles, { id: fileId }); + expect(file?.original_name).toBe('Rechnung.pdf'); }); it('takes the provider name back the way a download would have written it', async () => { @@ -1334,40 +1320,40 @@ describe('DocSyncService', () => { // rename of its own, and the conflict the owner had just settled came // straight back. const link = await makeLink(); - const fileId = makeFile({ name: 'Invoice.pdf' }); - const itemId = seedItem(link, { + const fileId = await makeFile({ name: 'Invoice.pdf' }); + const itemId = (await seedItem(link, { remoteId: 'r1', remoteName: 'Hotel: Kyoto.pdf', remoteVersion: 'v1', fileId, state: 'conflict', contentSha256: 'agreed-hash', - }); + })); provider.list.mockResolvedValue(ok(listing([ remoteDoc({ remoteId: 'r1', name: 'Hotel: Kyoto 2026.pdf', remoteVersion: 'v1' }), ]))); await service.resolveConflict(itemId, 'provider'); - expect(itemRow(itemId).state).not.toBe('conflict'); + expect((await itemRow(itemId)).state).not.toBe('conflict'); expect(provider.rename).not.toHaveBeenCalled(); - const file = testDb.prepare('SELECT original_name FROM trip_files WHERE id = ?').get(fileId) as { original_name: string }; - expect(file.original_name).toBe('Hotel_ Kyoto 2026.pdf'); + const file = await findRow(orm, TripFiles, { id: fileId }); + expect(file?.original_name).toBe('Hotel_ Kyoto 2026.pdf'); }); it('does not leave the row in conflict either way', async () => { for (const keep of ['trek', 'provider'] as const) { const link = await makeLink(); - const { itemId } = seedRenameConflict(link); + const { itemId } = await seedRenameConflict(link); provider.list.mockResolvedValue(ok(listing([ remoteDoc({ remoteId: 'r1', name: 'Rechnung.pdf', remoteVersion: 'v1' }), ]))); await service.resolveConflict(itemId, keep); - expect(itemRow(itemId).state, `keep=${keep} left the row in conflict`).not.toBe('conflict'); + expect((await itemRow(itemId)).state, `keep=${keep} left the row in conflict`).not.toBe('conflict'); } }); }); it('refuses to resolve a pairing that is not in conflict', async () => { const link = await makeLink(); - const itemId = seedItem(link, { remoteId: 'r1', state: 'synced' }); + const itemId = await seedItem(link, { remoteId: 'r1', state: 'synced' }); expect(await service.resolveConflict(itemId, 'both')).toBe(false); expect(provider.list).not.toHaveBeenCalled(); @@ -1380,7 +1366,7 @@ describe('DocSyncService', () => { const decidable = ['conflict', 'rejected_type', 'too_large', 'remote_missing', 'error']; const waiting = ['pending', 'synced', 'local_deleted']; for (const state of [...decidable, ...waiting]) { - seedItem(link, { remoteId: `r-${state}`, state }); + await seedItem(link, { remoteId: `r-${state}`, state }); } const states = (await service.issues(tripId)).map((r) => r.state as string).sort(); @@ -1409,29 +1395,29 @@ describe('DocSyncService', () => { * with every further edit. */ describe('pull_update replaces rather than accumulates', () => { - function seedSynced(link: LinkRow): { itemId: number; fileId: number } { - const fileId = makeFile({ name: 'invoice.pdf' }); - const itemId = seedItem(link, { + async function seedSynced(link: LinkRow): Promise<{ itemId: number; fileId: number }> { + const fileId = await makeFile({ name: 'invoice.pdf' }); + const itemId = (await seedItem(link, { remoteId: 'r1', remoteName: 'invoice.pdf', remoteVersion: 'v1', fileId, state: 'synced', contentSha256: 'agreed-hash', - }); + })); return { itemId, fileId }; } it('puts the superseded copy in the trash instead of leaving it beside the new one', async () => { const link = await makeLink(); - const { fileId } = seedSynced(link); + const { fileId } = await seedSynced(link); provider.list.mockResolvedValueOnce(ok(listing([ remoteDoc({ remoteId: 'r1', name: 'invoice.pdf', remoteVersion: 'v2' }), ]))); await service.syncLink(link); - const rows = fileRows(); + const rows = await fileRows(); const old = rows.find(r => Number(r.id) === fileId) as Record; const fresh = rows.find(r => Number(r.id) !== fileId) as Record; expect(old.deleted_at).not.toBeNull(); @@ -1441,19 +1427,19 @@ describe('DocSyncService', () => { it('leaves exactly one live document, so the file manager does not show two', async () => { const link = await makeLink(); - seedSynced(link); + await seedSynced(link); provider.list.mockResolvedValueOnce(ok(listing([ remoteDoc({ remoteId: 'r1', name: 'invoice.pdf', remoteVersion: 'v2' }), ]))); await service.syncLink(link); - expect(fileRows().filter(r => r.deleted_at === null)).toHaveLength(1); + expect((await fileRows()).filter(r => r.deleted_at === null)).toHaveLength(1); }); it('does not push the superseded copy back on the next run', async () => { const link = await makeLink(); - seedSynced(link); + await seedSynced(link); provider.list.mockResolvedValueOnce(ok(listing([ remoteDoc({ remoteId: 'r1', name: 'invoice.pdf', remoteVersion: 'v2' }), ]))); @@ -1476,7 +1462,7 @@ describe('DocSyncService', () => { await service.syncLink(link); - expect(fileRows().filter(r => r.deleted_at === null)).toHaveLength(1); + expect((await fileRows()).filter(r => r.deleted_at === null)).toHaveLength(1); }); /** @@ -1489,29 +1475,24 @@ describe('DocSyncService', () => { */ it('carries the attachments of the superseded copy over to the new revision', async () => { const link = await makeLink(); - const { itemId, fileId } = seedSynced(link); - const reservationId = Number( - testDb.prepare("INSERT INTO reservations (trip_id, title) VALUES (?, 'Hotel Kyoto')").run(tripId).lastInsertRowid, - ); - const otherReservationId = Number( - testDb.prepare("INSERT INTO reservations (trip_id, title) VALUES (?, 'Ryokan Hakone')").run(tripId).lastInsertRowid, - ); - testDb.prepare('UPDATE trip_files SET reservation_id = ?, description = ?, starred = 1 WHERE id = ?') - .run(reservationId, 'Booking confirmation', fileId); - testDb.prepare('INSERT INTO file_links (file_id, reservation_id) VALUES (?, ?)').run(fileId, otherReservationId); + const { itemId, fileId } = await seedSynced(link); + const reservationId = await insertRow(orm, Reservations, { trip: tripId, title: 'Hotel Kyoto' }); + const otherReservationId = await insertRow(orm, Reservations, { trip: tripId, title: 'Ryokan Hakone' }); + await updateRows(orm, TripFiles, { id: fileId }, { reservation: reservationId, description: 'Booking confirmation', starred: 1 }); + await insertRow(orm, FileLinks, { file: fileId, reservation: otherReservationId }); provider.list.mockResolvedValueOnce(ok(listing([ remoteDoc({ remoteId: 'r1', name: 'invoice.pdf', remoteVersion: 'v2' }), ]))); await service.syncLink(link); - const fresh = fileRows().find(r => Number(r.id) !== fileId) as Record; + const fresh = (await fileRows()).find(r => Number(r.id) !== fileId) as Record; expect(fresh).toMatchObject({ reservation_id: reservationId, description: 'Booking confirmation', starred: 1, deleted_at: null }); - expect(itemRow(itemId).file_id).toBe(Number(fresh.id)); - const links = testDb.prepare('SELECT reservation_id FROM file_links WHERE file_id = ? ORDER BY id').all(fresh.id) as Array<{ reservation_id: number }>; + expect((await itemRow(itemId)).file_id).toBe(Number(fresh.id)); + const links = await findRows(orm, FileLinks, { file: Number(fresh.id) }, { id: 'asc' }); expect(links.map(l => l.reservation_id)).toEqual([otherReservationId]); // The old copy keeps what it had: the trash shows it as it was. - expect(fileRows().find(r => Number(r.id) === fileId)).toMatchObject({ reservation_id: reservationId, starred: 1 }); + expect((await fileRows()).find(r => Number(r.id) === fileId)).toMatchObject({ reservation_id: reservationId, starred: 1 }); expect(realtime.broadcast).toHaveBeenCalledWith(tripId, 'file:created', { file: expect.objectContaining({ id: fresh.id, starred: 1 }) }); }); @@ -1527,19 +1508,19 @@ describe('DocSyncService', () => { */ it('keeps the file when the download turns out to be the bytes TREK already holds', async () => { const link = await makeLink(); - const fileId = makeFile({ name: 'invoice.pdf' }); - const itemId = seedItem(link, { + const fileId = await makeFile({ name: 'invoice.pdf' }); + const itemId = (await seedItem(link, { remoteId: 'r1', remoteName: 'invoice.pdf', remoteVersion: 'v1', fileId, state: 'synced', contentSha256: FILE_SHA, - }); + })); provider.list.mockResolvedValueOnce(ok(listing([ remoteDoc({ remoteId: 'r1', name: 'invoice.pdf', remoteVersion: 'v2', contentHash: null }), ]))); const res = await service.syncLink(link); - expect(fileRows()).toHaveLength(1); - expect(fileRows()[0]).toMatchObject({ id: fileId, deleted_at: null }); - expect(itemRow(itemId)).toMatchObject({ file_id: fileId, remote_version: 'v2', state: 'synced', content_sha256: FILE_SHA }); + expect(await fileRows()).toHaveLength(1); + expect((await fileRows())[0]).toMatchObject({ id: fileId, deleted_at: null }); + expect(await itemRow(itemId)).toMatchObject({ file_id: fileId, remote_version: 'v2', state: 'synced', content_sha256: FILE_SHA }); expect(storage.put).not.toHaveBeenCalled(); expect(fs.readdirSync(spoolDir)).toHaveLength(0); expect(res.pulled).toBe(0); @@ -1550,18 +1531,18 @@ describe('DocSyncService', () => { // Moving the agreed name here would read the store's rename as TREK's // own on the next run and send the old name back up. const link = await makeLink(); - const fileId = makeFile({ name: 'invoice.pdf' }); - const itemId = seedItem(link, { + const fileId = await makeFile({ name: 'invoice.pdf' }); + const itemId = (await seedItem(link, { remoteId: 'r1', remoteName: 'invoice.pdf', remoteVersion: 'v1', fileId, state: 'synced', contentSha256: FILE_SHA, - }); + })); provider.list.mockResolvedValueOnce(ok(listing([ remoteDoc({ remoteId: 'r1', name: 'hotel-invoice.pdf', remoteVersion: 'v2', contentHash: null }), ]))); await service.syncLink(link); - expect(itemRow(itemId)).toMatchObject({ remote_name: 'invoice.pdf', remote_version: 'v2' }); - expect(fileRows()[0].original_name).toBe('invoice.pdf'); + expect(await itemRow(itemId)).toMatchObject({ remote_name: 'invoice.pdf', remote_version: 'v2' }); + expect((await fileRows())[0].original_name).toBe('invoice.pdf'); provider.list.mockResolvedValueOnce(ok(listing([ remoteDoc({ remoteId: 'r1', name: 'hotel-invoice.pdf', remoteVersion: 'v2', contentHash: null }), @@ -1569,8 +1550,8 @@ describe('DocSyncService', () => { await service.syncLink(link); expect(provider.rename).not.toHaveBeenCalled(); - expect(fileRows()[0].original_name).toBe('hotel-invoice.pdf'); - expect(itemRow(itemId).remote_name).toBe('hotel-invoice.pdf'); + expect((await fileRows())[0].original_name).toBe('hotel-invoice.pdf'); + expect((await itemRow(itemId)).remote_name).toBe('hotel-invoice.pdf'); }); }); @@ -1588,8 +1569,8 @@ describe('DocSyncService', () => { const res = await service.syncLink(link); expect(provider.fetch).toHaveBeenCalledTimes(1); - expect(fileRows().map(r => r.original_name)).toEqual(['flug.mp4']); - expect(itemRows()[0].state).toBe('synced'); + expect((await fileRows()).map(r => r.original_name)).toEqual(['flug.mp4']); + expect((await itemRows())[0].state).toBe('synced'); expect(res.state).toBe('ok'); }); }); @@ -1604,14 +1585,14 @@ describe('DocSyncService', () => { * the same. */ describe('a download that fails', () => { - const settleBackoff = () => testDb.prepare('UPDATE document_sync_items SET next_attempt_at = NULL').run(); + const settleBackoff = () => updateRows(orm, DocumentSyncItems, {}, { next_attempt_at: null }); - function seedHeld(link: LinkRow): { itemId: number; fileId: number } { - const fileId = makeFile({ name: 'invoice.pdf' }); - const itemId = seedItem(link, { + async function seedHeld(link: LinkRow): Promise<{ itemId: number; fileId: number }> { + const fileId = await makeFile({ name: 'invoice.pdf' }); + const itemId = (await seedItem(link, { remoteId: 'r1', remoteName: 'invoice.pdf', remoteVersion: 'v1', fileId, state: 'synced', contentSha256: 'held-hash', remoteSize: 512, remoteModifiedAt: '2026-09-01T08:00:00Z', - }); + })); return { itemId, fileId }; } @@ -1621,13 +1602,13 @@ describe('DocSyncService', () => { it('keeps the version TREK holds on record, not the one it failed to get', async () => { const link = await makeLink(); - const { itemId, fileId } = seedHeld(link); + const { itemId, fileId } = await seedHeld(link); provider.list.mockResolvedValue(ok(listing([edited()]))); provider.fetch.mockResolvedValueOnce(fail('timeout')); await service.syncLink(link); - expect(itemRow(itemId)).toMatchObject({ + expect(await itemRow(itemId)).toMatchObject({ state: 'error', error_code: 'timeout', file_id: fileId, content_sha256: 'held-hash', remote_version: 'v1', remote_size: 512, remote_modified_at: '2026-09-01T08:00:00Z', }); @@ -1635,22 +1616,22 @@ describe('DocSyncService', () => { it('fetches the update again once the backoff is over and ends synced with the new bytes', async () => { const link = await makeLink(); - const { itemId, fileId } = seedHeld(link); + const { itemId, fileId } = await seedHeld(link); provider.list.mockResolvedValue(ok(listing([edited()]))); provider.fetch.mockResolvedValueOnce(fail('timeout')); await service.syncLink(link); - settleBackoff(); + await settleBackoff(); const res = await service.syncLink(await config.getLink(link.id)); expect(res).toMatchObject({ state: 'ok', pulled: 1 }); expect(provider.fetch).toHaveBeenCalledTimes(2); - const row = itemRow(itemId); + const row = await itemRow(itemId); expect(row).toMatchObject({ state: 'synced', error_code: null, attempts: 0, remote_version: 'v2', content_sha256: FILE_SHA, remote_size: 2048, remote_modified_at: '2026-09-18T10:00:00Z', }); - const live = fileRows().filter(r => r.deleted_at === null); + const live = (await fileRows()).filter(r => r.deleted_at === null); expect(live).toHaveLength(1); expect(Number(live[0].id)).toBe(Number(row.file_id)); expect(Number(row.file_id)).not.toBe(fileId); @@ -1659,7 +1640,7 @@ describe('DocSyncService', () => { it('is fetched again by "Sync now" without waiting out the backoff', async () => { const link = await makeLink(); - const { itemId } = seedHeld(link); + const { itemId } = await seedHeld(link); provider.list.mockResolvedValue(ok(listing([edited()]))); provider.fetch.mockResolvedValueOnce(fail('provider_error')); await service.syncLink(link); @@ -1668,7 +1649,7 @@ describe('DocSyncService', () => { await service.syncLink(await config.getLink(link.id)); expect(provider.fetch).toHaveBeenCalledTimes(2); - expect(itemRow(itemId)).toMatchObject({ state: 'synced', remote_version: 'v2', content_sha256: FILE_SHA }); + expect(await itemRow(itemId)).toMatchObject({ state: 'synced', remote_version: 'v2', content_sha256: FILE_SHA }); }); it('still retries a first download, which has no version to keep', async () => { @@ -1676,15 +1657,15 @@ describe('DocSyncService', () => { provider.list.mockResolvedValue(ok(listing([remoteDoc({ remoteId: 'r-new', name: 'fresh.pdf', remoteVersion: 'v1' })]))); provider.fetch.mockResolvedValueOnce(fail('timeout')); await service.syncLink(link); - expect(itemRows()[0]).toMatchObject({ state: 'error', file_id: null, remote_id: 'r-new', remote_version: null }); + expect((await itemRows())[0]).toMatchObject({ state: 'error', file_id: null, remote_id: 'r-new', remote_version: null }); - settleBackoff(); + await settleBackoff(); await service.syncLink(await config.getLink(link.id)); expect(provider.fetch).toHaveBeenCalledTimes(2); - expect(itemRows()).toHaveLength(1); - expect(itemRows()[0]).toMatchObject({ state: 'synced', remote_version: 'v1', content_sha256: FILE_SHA }); - expect(fileRows()).toHaveLength(1); + expect(await itemRows()).toHaveLength(1); + expect((await itemRows())[0]).toMatchObject({ state: 'synced', remote_version: 'v1', content_sha256: FILE_SHA }); + expect(await fileRows()).toHaveLength(1); }); }); @@ -1707,7 +1688,7 @@ describe('DocSyncService', () => { * backoff rather than the counter. */ async function failingRun(link: LinkRow) { - testDb.prepare("UPDATE document_sync_items SET next_attempt_at = NULL WHERE remote_id = 'r-flaky'").run(); + await updateRows(orm, DocumentSyncItems, { remote_id: 'r-flaky' }, { next_attempt_at: null }); provider.list.mockResolvedValueOnce(ok(listing([ remoteDoc({ remoteId: 'r-flaky', name: 'flaky.pdf', remoteVersion: 'v1' }), ]))); @@ -1717,34 +1698,33 @@ describe('DocSyncService', () => { /** One run in which it works. */ async function goodRun(link: LinkRow) { - testDb.prepare("UPDATE document_sync_items SET next_attempt_at = NULL WHERE remote_id = 'r-flaky'").run(); + await updateRows(orm, DocumentSyncItems, { remote_id: 'r-flaky' }, { next_attempt_at: null }); provider.list.mockResolvedValueOnce(ok(listing([ remoteDoc({ remoteId: 'r-flaky', name: 'flaky.pdf', remoteVersion: 'v1' }), ]))); await service.syncLink(link); } - const flakyRow = () => testDb - .prepare("SELECT * FROM document_sync_items WHERE remote_id = 'r-flaky'") - .get() as Record; + const flakyRow = async () => + (await findRow(orm, DocumentSyncItems, { remote_id: 'r-flaky' })) as Record; it('counts consecutive failures', async () => { const link = await makeLink(); await failingRun(link); - expect(flakyRow().attempts).toBe(1); + expect((await flakyRow()).attempts).toBe(1); await failingRun(link); - expect(flakyRow().attempts).toBe(2); + expect((await flakyRow()).attempts).toBe(2); }); it('clears the counter once a pass succeeds, so the limit means six in a row', async () => { const link = await makeLink(); await failingRun(link); await failingRun(link); - expect(flakyRow().attempts).toBe(2); + expect((await flakyRow()).attempts).toBe(2); await goodRun(link); - expect(flakyRow().attempts).toBe(0); + expect((await flakyRow()).attempts).toBe(0); }); it('schedules each failure further out than the last', async () => { @@ -1752,8 +1732,9 @@ describe('DocSyncService', () => { const waits: number[] = []; for (let i = 0; i < 3; i += 1) { await failingRun(link); - const row = flakyRow(); + const row = await flakyRow(); const secs = (testDb + // test-sql-allow: compares against SQLite's own clock, which is what CURRENT_TIMESTAMP and the stored text are measured by. .prepare("SELECT CAST((julianday(?) - julianday('now')) * 86400 AS INTEGER) AS s") .get(row.next_attempt_at) as { s: number }).s; waits.push(secs); @@ -1773,38 +1754,38 @@ describe('DocSyncService', () => { * ever deleted in TREK, months back included. */ describe('a deletion in TREK', () => { - function deletedPairing(link: LinkRow): { itemId: number; fileId: number } { - const fileId = makeFile({ name: 'boarding.pdf', deletedAt: '2026-09-18 08:00:00' }); - const itemId = seedItem(link, { + async function deletedPairing(link: LinkRow): Promise<{ itemId: number; fileId: number }> { + const fileId = await makeFile({ name: 'boarding.pdf', deletedAt: '2026-09-18 08:00:00' }); + const itemId = (await seedItem(link, { remoteId: 'r1', remoteName: 'boarding.pdf', remoteVersion: 'v1', fileId, state: 'synced', contentSha256: FILE_SHA, pushedSha256: FILE_SHA, - }); + })); return { itemId, fileId }; } it('does not reach back when the binding is switched from unlink to trash later', async () => { const link = await makeLink({ deletePolicy: 'unlink' }); - const { itemId } = deletedPairing(link); + const { itemId } = await deletedPairing(link); provider.list.mockResolvedValue(ok(listing([remoteDoc({ remoteId: 'r1' })]))); await service.syncLink(link); - expect(itemRow(itemId).state).toBe('local_deleted'); - expect(itemRow(itemId).remote_trashed_at).toBeNull(); + expect((await itemRow(itemId)).state).toBe('local_deleted'); + expect((await itemRow(itemId)).remote_trashed_at).toBeNull(); - testDb.prepare("UPDATE trip_document_links SET delete_policy = 'trash' WHERE id = ?").run(link.id); + await updateRows(orm, TripDocumentLinks, { id: link.id }, { delete_policy: 'trash' }); await service.syncLink(await config.getLink(link.id)); expect(provider.trash).not.toHaveBeenCalled(); - expect(itemRow(itemId).state).toBe('local_deleted'); + expect((await itemRow(itemId)).state).toBe('local_deleted'); }); it('bins the provider copy exactly once, even while the provider keeps listing it', async () => { const link = await makeLink({ deletePolicy: 'trash' }); - const { itemId } = deletedPairing(link); + const { itemId } = await deletedPairing(link); provider.list.mockResolvedValue(ok(listing([remoteDoc({ remoteId: 'r1' })]))); await service.syncLink(link); - expect(itemRow(itemId).remote_trashed_at).not.toBeNull(); + expect((await itemRow(itemId)).remote_trashed_at).not.toBeNull(); await service.syncLink(await config.getLink(link.id)); await service.syncLink(await config.getLink(link.id)); @@ -1813,7 +1794,7 @@ describe('DocSyncService', () => { it('does not ask again under an unchanged upstream either', async () => { const link = await makeLink({ deletePolicy: 'trash' }); - deletedPairing(link); + await deletedPairing(link); provider.list.mockResolvedValueOnce(ok(listing([remoteDoc({ remoteId: 'r1' })]))); await service.syncLink(link); @@ -1834,7 +1815,7 @@ describe('DocSyncService', () => { */ describe('a file taken back out of TREK trash', () => { const restore = (fileId: number) => - testDb.prepare('UPDATE trip_files SET deleted_at = NULL WHERE id = ?').run(fileId); + updateRows(orm, TripFiles, { id: fileId }, { deleted_at: null }); /** * A provider answering the way the WebDAV adapters do: asked with the @@ -1847,14 +1828,14 @@ describe('DocSyncService', () => { /** A synced pairing whose file was deleted in TREK and whose deletion one run has seen. */ async function deletedAndSeen(link: LinkRow): Promise<{ itemId: number; fileId: number }> { - const fileId = makeFile({ name: 'boarding.pdf', deletedAt: '2026-09-18 08:00:00' }); - const itemId = seedItem(link, { + const fileId = await makeFile({ name: 'boarding.pdf', deletedAt: '2026-09-18 08:00:00' }); + const itemId = (await seedItem(link, { remoteId: 'r1', remoteName: 'boarding.pdf', remoteVersion: 'v1', fileId, state: 'synced', contentSha256: FILE_SHA, pushedSha256: FILE_SHA, - }); + })); provider.list.mockResolvedValueOnce(ok(listing([remoteDoc({ remoteId: 'r1' })]))); await service.syncLink(link); - expect(itemRow(itemId).state).toBe('local_deleted'); + expect((await itemRow(itemId)).state).toBe('local_deleted'); return { itemId, fileId }; } @@ -1862,22 +1843,22 @@ describe('DocSyncService', () => { const link = await makeLink({ deletePolicy: 'unlink' }); const { itemId, fileId } = await deletedAndSeen(link); - restore(fileId); + await restore(fileId); provider.list.mockResolvedValueOnce(ok(listing([remoteDoc({ remoteId: 'r1' })]))); const run = await service.syncLink(await config.getLink(link.id)); expect(run.state).toBe('ok'); expect(provider.fetch).not.toHaveBeenCalled(); expect(provider.push).not.toHaveBeenCalled(); - expect(itemRow(itemId).state).toBe('synced'); - expect(fileRows().map((f) => [f.id, f.deleted_at])).toEqual([[fileId, null]]); + expect((await itemRow(itemId)).state).toBe('synced'); + expect((await fileRows()).map((f) => [f.id, f.deleted_at])).toEqual([[fileId, null]]); }); it('lets the ordinary rules bring down an edit made upstream in the meantime', async () => { const link = await makeLink({ deletePolicy: 'unlink' }); const { itemId, fileId } = await deletedAndSeen(link); - restore(fileId); + await restore(fileId); // An edit is new bytes, not only a new version marker: the same bytes under // a moved marker are kept as they are. const edited = Buffer.from('trek-document-bytes, revised upstream'); @@ -1886,30 +1867,30 @@ describe('DocSyncService', () => { const run = await service.syncLink(await config.getLink(link.id)); expect(run.pulled).toBe(1); - const row = itemRow(itemId); + const row = await itemRow(itemId); expect(row.state).toBe('synced'); expect(row.file_id).not.toBe(fileId); - expect(fileRows().filter((f) => f.deleted_at === null)).toHaveLength(1); + expect((await fileRows()).filter((f) => f.deleted_at === null)).toHaveLength(1); }); it('puts the file back at the provider when TREK was the one that binned it', async () => { const link = await makeLink({ deletePolicy: 'trash' }); const { itemId, fileId } = await deletedAndSeen(link); expect(provider.trash).toHaveBeenCalledTimes(1); - const uid = itemRow(itemId).trek_doc_uid; + const uid = (await itemRow(itemId)).trek_doc_uid; - restore(fileId); + await restore(fileId); provider.list.mockResolvedValueOnce(ok(listing([]))); const run = await service.syncLink(await config.getLink(link.id)); expect(run.pushed).toBe(1); expect(provider.push.mock.calls[0][2]).toMatchObject({ fileName: 'boarding.pdf', remoteId: undefined, trekDocUid: uid }); - const row = itemRow(itemId); + const row = await itemRow(itemId); expect(row.state).toBe('synced'); expect(row.remote_id).toBe('r-pushed'); expect(row.file_id).toBe(fileId); expect(row.remote_trashed_at).toBeNull(); - expect(itemRows()).toHaveLength(1); + expect(await itemRows()).toHaveLength(1); }); it('does not mistake the re-upload for a foreign change on the run after', async () => { @@ -1917,7 +1898,7 @@ describe('DocSyncService', () => { // reports it back under a new version marker. const link = await makeLink({ deletePolicy: 'trash' }); const { itemId, fileId } = await deletedAndSeen(link); - restore(fileId); + await restore(fileId); provider.list.mockResolvedValueOnce(ok(listing([]))); await service.syncLink(await config.getLink(link.id)); @@ -1929,40 +1910,43 @@ describe('DocSyncService', () => { expect(run.pulled).toBe(0); expect(provider.fetch).not.toHaveBeenCalled(); expect(provider.push).toHaveBeenCalledTimes(1); - expect(itemRow(itemId).state).toBe('synced'); + expect((await itemRow(itemId)).state).toBe('synced'); }); it('retries a re-upload that failed like any other unpaired file', async () => { const link = await makeLink({ deletePolicy: 'trash' }); const { itemId, fileId } = await deletedAndSeen(link); - restore(fileId); + await restore(fileId); provider.list.mockResolvedValue(ok(listing([]))); provider.push.mockResolvedValueOnce(fail('unreachable')); await service.syncLink(await config.getLink(link.id)); - const failed = itemRow(itemId); + const failed = await itemRow(itemId); expect(failed.state).toBe('error'); expect(failed.remote_id).toBeNull(); - testDb.prepare('UPDATE document_sync_items SET next_attempt_at = NULL WHERE id = ?').run(itemId); + await updateRows(orm, DocumentSyncItems, { id: itemId }, { next_attempt_at: null }); await service.syncLink(await config.getLink(link.id)); expect(provider.push).toHaveBeenCalledTimes(2); - expect(itemRow(itemId).state).toBe('synced'); - expect(itemRow(itemId).remote_id).toBe('r-pushed'); + expect((await itemRow(itemId)).state).toBe('synced'); + expect((await itemRow(itemId)).remote_id).toBe('r-pushed'); }); it('uploads many files TREK had binned instead of reading them as a mass deletion', async () => { const link = await makeLink({ deletePolicy: 'trash' }); - const fileIds = Array.from({ length: 10 }, (_, i) => - makeFile({ name: `doc-${i}.pdf`, storageKey: `key-${i}`, deletedAt: '2026-09-18 08:00:00' })); - fileIds.forEach((fileId, i) => - seedItem(link, { remoteId: `r${i}`, remoteName: `doc-${i}.pdf`, remoteVersion: 'v1', fileId, state: 'synced' })); + const fileIds: number[] = []; + for (let i = 0; i < 10; i += 1) { + fileIds.push(await makeFile({ name: `doc-${i}.pdf`, storageKey: `key-${i}`, deletedAt: '2026-09-18 08:00:00' })); + } + for (const [i, fileId] of fileIds.entries()) { + await seedItem(link, { remoteId: `r${i}`, remoteName: `doc-${i}.pdf`, remoteVersion: 'v1', fileId, state: 'synced' }); + } provider.list.mockResolvedValueOnce(ok(listing(fileIds.map((_, i) => remoteDoc({ remoteId: `r${i}`, name: `doc-${i}.pdf` }))))); await service.syncLink(link); expect(provider.trash).toHaveBeenCalledTimes(10); - for (const fileId of fileIds) restore(fileId); + for (const fileId of fileIds) await restore(fileId); let seq = 0; provider.push.mockImplementation(async () => { seq += 1; @@ -1974,7 +1958,7 @@ describe('DocSyncService', () => { expect(run.errorCode).toBeUndefined(); expect(run.state).toBe('ok'); expect(run.pushed).toBe(10); - expect(itemRows().every((r) => r.state === 'synced')).toBe(true); + expect((await itemRows()).every((r) => r.state === 'synced')).toBe(true); }); it('does not upload a copy TREK binned that was restored at the provider meanwhile', async () => { @@ -1986,16 +1970,16 @@ describe('DocSyncService', () => { provider.list.mockResolvedValueOnce(ok(listing([remoteDoc({ remoteId: 'r1' })]))); await service.syncLink(await config.getLink(link.id)); - expect(itemRow(itemId).remote_trashed_at).toBeNull(); + expect((await itemRow(itemId)).remote_trashed_at).toBeNull(); expect(provider.fetch).not.toHaveBeenCalled(); - restore(fileId); + await restore(fileId); provider.list.mockImplementationOnce(quietUnlessAsked([remoteDoc({ remoteId: 'r1' })])); await service.syncLink(await config.getLink(link.id)); expect(provider.push).not.toHaveBeenCalled(); - expect(itemRow(itemId).state).toBe('synced'); - expect(itemRow(itemId).remote_id).toBe('r1'); + expect((await itemRow(itemId)).state).toBe('synced'); + expect((await itemRow(itemId)).remote_id).toBe('r1'); }); it('asks a quiet binding for the whole listing and flags a copy that is gone', async () => { @@ -2006,14 +1990,14 @@ describe('DocSyncService', () => { const { itemId, fileId } = await deletedAndSeen(link); const visa = remoteDoc({ remoteId: 'r-visa', name: 'visa.pdf' }); - restore(fileId); + await restore(fileId); provider.list.mockImplementation(quietUnlessAsked([visa])); const run = await service.syncLink(await config.getLink(link.id)); expect(provider.list.mock.calls[1][1].cursor).toBeNull(); expect(run).toMatchObject({ state: 'ok', pulled: 1, pushed: 0, missing: 1 }); - expect(itemRow(itemId)).toMatchObject({ state: 'remote_missing', remote_trashed_at: null }); - expect(itemRow(itemId).remote_missing_at).not.toBeNull(); + expect(await itemRow(itemId)).toMatchObject({ state: 'remote_missing', remote_trashed_at: null }); + expect((await itemRow(itemId)).remote_missing_at).not.toBeNull(); // Once the restore is settled the binding goes back to its cursor. await service.syncLink(await config.getLink(link.id)); @@ -2026,16 +2010,20 @@ describe('DocSyncService', () => { // written, the next run planned the same, and the binding synced nothing // at all, new documents included, until its circuit opened. const link = await makeLink({ deletePolicy: 'unlink' }); - const inStep = Array.from({ length: 6 }, (_, i) => { - const fileId = makeFile({ name: `doc-${i}.pdf`, storageKey: `key-doc-${i}` }); - seedItem(link, { remoteId: `s${i}`, remoteName: `doc-${i}.pdf`, remoteVersion: 'v1', fileId, contentSha256: FILE_SHA }); - return remoteDoc({ remoteId: `s${i}`, name: `doc-${i}.pdf` }); - }); - const restored = Array.from({ length: 5 }, (_, i) => seedItem(link, { - remoteId: `g${i}`, remoteName: `gone-${i}.pdf`, remoteVersion: 'v1', state: 'local_deleted', contentSha256: FILE_SHA, - fileId: makeFile({ name: `gone-${i}.pdf`, storageKey: `key-gone-${i}` }), - })); - makeFile({ name: 'new-here.pdf', storageKey: 'key-new-here' }); + const inStep: RemoteDocument[] = []; + for (let i = 0; i < 6; i += 1) { + const fileId = await makeFile({ name: `doc-${i}.pdf`, storageKey: `key-doc-${i}` }); + await seedItem(link, { remoteId: `s${i}`, remoteName: `doc-${i}.pdf`, remoteVersion: 'v1', fileId, contentSha256: FILE_SHA }); + inStep.push(remoteDoc({ remoteId: `s${i}`, name: `doc-${i}.pdf` })); + } + const restored: number[] = []; + for (let i = 0; i < 5; i += 1) { + restored.push(await seedItem(link, { + remoteId: `g${i}`, remoteName: `gone-${i}.pdf`, remoteVersion: 'v1', state: 'local_deleted', contentSha256: FILE_SHA, + fileId: await makeFile({ name: `gone-${i}.pdf`, storageKey: `key-gone-${i}` }), + })); + } + await makeFile({ name: 'new-here.pdf', storageKey: 'key-new-here' }); const newThere = remoteDoc({ remoteId: 'r-new', name: 'new-there.pdf' }); provider.list.mockResolvedValueOnce(ok(listing([...inStep, newThere]))); @@ -2048,21 +2036,21 @@ describe('DocSyncService', () => { expect(first).toMatchObject({ state: 'ok', pulled: 1, pushed: 1, missing: 5 }); expect(first.errorCode).toBeUndefined(); expect(second).toMatchObject({ state: 'ok', pulled: 0, pushed: 0, missing: 0 }); - expect(linkRow(link.id)).toMatchObject({ failure_count: 0, last_sync_state: 'ok' }); - for (const id of restored) expect(itemRow(id).state).toBe('remote_missing'); + expect(await linkRow(link.id)).toMatchObject({ failure_count: 0, last_sync_state: 'ok' }); + for (const id of restored) expect((await itemRow(id)).state).toBe('remote_missing'); }); it('flags the copy as missing when it vanished upstream by somebody else', async () => { const link = await makeLink({ deletePolicy: 'unlink' }); const { itemId, fileId } = await deletedAndSeen(link); - restore(fileId); + await restore(fileId); provider.list.mockResolvedValueOnce(ok(listing([]))); const run = await service.syncLink(await config.getLink(link.id)); expect(provider.push).not.toHaveBeenCalled(); expect(run.missing).toBe(1); - expect(itemRow(itemId).state).toBe('remote_missing'); + expect((await itemRow(itemId)).state).toBe('remote_missing'); expect((await service.issues(tripId)).map((r) => r.id)).toContain(itemId); }); }); @@ -2074,28 +2062,28 @@ describe('DocSyncService', () => { * delete policy entirely. */ describe('a file purged from TREK trash', () => { - const purge = (fileId: number) => testDb.prepare('DELETE FROM trip_files WHERE id = ?').run(fileId); + const purge = (fileId: number) => deleteRows(orm, TripFiles, { id: fileId }); it('does not download a document again once its deletion was seen', async () => { const link = await makeLink({ deletePolicy: 'unlink' }); - const fileId = makeFile({ name: 'boarding.pdf', deletedAt: '2026-09-18 08:00:00' }); - const itemId = seedItem(link, { remoteId: 'r1', remoteName: 'boarding.pdf', remoteVersion: 'v1', fileId, state: 'synced' }); + const fileId = await makeFile({ name: 'boarding.pdf', deletedAt: '2026-09-18 08:00:00' }); + const itemId = await seedItem(link, { remoteId: 'r1', remoteName: 'boarding.pdf', remoteVersion: 'v1', fileId, state: 'synced' }); provider.list.mockResolvedValue(ok(listing([remoteDoc({ remoteId: 'r1' })]))); await service.syncLink(link); - purge(fileId); + await purge(fileId); await service.syncLink(await config.getLink(link.id)); expect(provider.fetch).not.toHaveBeenCalled(); - expect(fileRows()).toHaveLength(0); - expect(itemRow(itemId).state).toBe('local_deleted'); + expect(await fileRows()).toHaveLength(0); + expect((await itemRow(itemId)).state).toBe('local_deleted'); }); it('applies the delete policy when the purge came before any run saw the deletion', async () => { const link = await makeLink({ deletePolicy: 'trash' }); - const fileId = makeFile({ name: 'boarding.pdf', deletedAt: '2026-09-18 08:00:00' }); - const itemId = seedItem(link, { remoteId: 'r1', remoteName: 'boarding.pdf', remoteVersion: 'v1', fileId, state: 'synced' }); - purge(fileId); + const fileId = await makeFile({ name: 'boarding.pdf', deletedAt: '2026-09-18 08:00:00' }); + const itemId = await seedItem(link, { remoteId: 'r1', remoteName: 'boarding.pdf', remoteVersion: 'v1', fileId, state: 'synced' }); + await purge(fileId); provider.list.mockResolvedValue(ok(listing([remoteDoc({ remoteId: 'r1' })]))); await service.syncLink(link); @@ -2103,8 +2091,8 @@ describe('DocSyncService', () => { expect(provider.fetch).not.toHaveBeenCalled(); expect(provider.trash).toHaveBeenCalledTimes(1); - expect(fileRows()).toHaveLength(0); - expect(itemRow(itemId).state).toBe('local_deleted'); + expect(await fileRows()).toHaveLength(0); + expect((await itemRow(itemId)).state).toBe('local_deleted'); }); it('does not bring back a document purged while its failed update was held back', async () => { @@ -2112,23 +2100,23 @@ describe('DocSyncService', () => { // backoff drops the deletion. Purged in that window, the row read as a // first download that never landed, and "Sync now" fetched it again. const link = await makeLink({ deletePolicy: 'trash' }); - const fileId = makeFile({ name: 'boarding.pdf' }); - const itemId = seedItem(link, { + const fileId = await makeFile({ name: 'boarding.pdf' }); + const itemId = (await seedItem(link, { remoteId: 'r1', remoteName: 'boarding.pdf', remoteVersion: 'v1', fileId, state: 'synced', contentSha256: FILE_SHA, - }); + })); provider.list.mockResolvedValue(ok(listing([remoteDoc({ remoteId: 'r1', remoteVersion: 'v2' })]))); provider.fetch.mockResolvedValueOnce(fail('timeout')); await service.syncLink(link); - expect(itemRow(itemId)).toMatchObject({ state: 'error', file_id: fileId }); + expect(await itemRow(itemId)).toMatchObject({ state: 'error', file_id: fileId }); - purge(fileId); + await purge(fileId); await service.retryShelvedItems(link.id); await service.syncLink(await config.getLink(link.id)); expect(provider.fetch).toHaveBeenCalledTimes(1); - expect(fileRows()).toHaveLength(0); + expect(await fileRows()).toHaveLength(0); expect(provider.trash).toHaveBeenCalledTimes(1); - expect(itemRow(itemId).state).toBe('local_deleted'); + expect((await itemRow(itemId)).state).toBe('local_deleted'); }); /** @@ -2142,19 +2130,19 @@ describe('DocSyncService', () => { */ it('closes a row on record as gone from the store once the TREK copy is purged too', async () => { const link = await makeLink({ deletePolicy: 'trash' }); - const fileId = makeFile({ name: 'boarding.pdf', deletedAt: '2026-09-18 08:00:00' }); - const itemId = seedItem(link, { + const fileId = await makeFile({ name: 'boarding.pdf', deletedAt: '2026-09-18 08:00:00' }); + const itemId = (await seedItem(link, { remoteId: 'r1', remoteName: 'boarding.pdf', remoteVersion: 'v1', fileId, state: 'remote_missing', remoteMissingAt: '2026-09-17 08:00:00', - }); - purge(fileId); + })); + await purge(fileId); provider.list.mockResolvedValue(ok(listing([]))); const res = await service.syncLink(link); expect(provider.trash).not.toHaveBeenCalled(); - expect(itemRow(itemId)).toMatchObject({ state: 'local_deleted', remote_trashed_at: null, file_id: null }); - expect(itemRow(itemId).remote_missing_at).not.toBeNull(); + expect(await itemRow(itemId)).toMatchObject({ state: 'local_deleted', remote_trashed_at: null, file_id: null }); + expect((await itemRow(itemId)).remote_missing_at).not.toBeNull(); expect((await service.issues(tripId)).map((r) => r.id)).not.toContain(itemId); // Closed is not "back at the store": the holdings must not count it there. const [status] = ((await service.status(tripId)) as { links: Array<{ holdings: { atProvider: number; missing: number } }> }).links; @@ -2180,11 +2168,11 @@ describe('DocSyncService', () => { it('takes a rename made at the provider into TREK and does not rename it back', async () => { const link = await makeLink(); - const fileId = makeFile({ name: 'a.pdf' }); - const itemId = seedItem(link, { + const fileId = await makeFile({ name: 'a.pdf' }); + const itemId = (await seedItem(link, { remoteId: '/trek/a.pdf', remoteName: 'a.pdf', remoteVersion: 'va', fileId, state: 'synced', contentSha256: FILE_SHA, remoteSize: 1024, remoteModifiedAt: AT, - }); + })); provider.list.mockResolvedValue(ok(listing([ remoteDoc({ remoteId: '/trek/b.pdf', name: 'b.pdf', remoteVersion: 'vb', contentHash: FILE_SHA }), ]))); @@ -2192,8 +2180,8 @@ describe('DocSyncService', () => { const first = await service.syncLink(link); expect(first).toMatchObject({ state: 'ok', missing: 0, pulled: 0 }); - expect(fileRows().map((f) => [f.id, f.original_name, f.deleted_at])).toEqual([[fileId, 'b.pdf', null]]); - expect(itemRow(itemId)).toMatchObject({ + expect((await fileRows()).map((f) => [f.id, f.original_name, f.deleted_at])).toEqual([[fileId, 'b.pdf', null]]); + expect(await itemRow(itemId)).toMatchObject({ remote_id: '/trek/b.pdf', remote_name: 'b.pdf', remote_version: 'vb', state: 'synced', remote_missing_at: null, }); @@ -2203,30 +2191,30 @@ describe('DocSyncService', () => { expect(provider.rename).not.toHaveBeenCalled(); expect(provider.fetch).not.toHaveBeenCalled(); expect(provider.push).not.toHaveBeenCalled(); - expect(fileRows().map((f) => f.original_name)).toEqual(['b.pdf']); - expect(itemRows()).toHaveLength(1); + expect((await fileRows()).map((f) => f.original_name)).toEqual(['b.pdf']); + expect(await itemRows()).toHaveLength(1); }); it('takes a copy on record as missing off the list in the run that finds it renamed', async () => { const link = await makeLink(); - const fileId = makeFile({ name: 'a.pdf' }); - const itemId = seedItem(link, { + const fileId = await makeFile({ name: 'a.pdf' }); + const itemId = (await seedItem(link, { remoteId: '/trek/a.pdf', remoteName: 'a.pdf', remoteVersion: 'va', fileId, state: 'remote_missing', contentSha256: FILE_SHA, remoteSize: 1024, remoteModifiedAt: AT, remoteMissingAt: '2026-09-18 09:00:00', - }); + })); provider.list.mockResolvedValue(ok(listing([ remoteDoc({ remoteId: '/trek/b.pdf', name: 'b.pdf', remoteVersion: 'vb' }), ]))); await service.syncLink(link); - expect(itemRow(itemId)).toMatchObject({ remote_id: '/trek/b.pdf', state: 'synced', remote_missing_at: null }); - expect(fileRows().map((f) => f.original_name)).toEqual(['b.pdf']); + expect(await itemRow(itemId)).toMatchObject({ remote_id: '/trek/b.pdf', state: 'synced', remote_missing_at: null }); + expect((await fileRows()).map((f) => f.original_name)).toEqual(['b.pdf']); expect(await service.issues(tripId)).toEqual([]); await service.syncLink(await config.getLink(link.id)); - expect(itemRow(itemId).state).toBe('synced'); + expect((await itemRow(itemId)).state).toBe('synced'); expect(provider.rename).not.toHaveBeenCalled(); }); @@ -2239,7 +2227,7 @@ describe('DocSyncService', () => { ]))); await service.syncLink(link); expect(provider.fetch).toHaveBeenCalledTimes(1); - const fileId = Number(fileRows()[0].id); + const fileId = Number((await fileRows())[0].id); provider.list.mockResolvedValue(ok(listing([ remoteDoc({ remoteId: '/trek/2026/b.pdf', name: 'b.pdf', remoteVersion: 'vb' }), @@ -2250,13 +2238,13 @@ describe('DocSyncService', () => { expect(renamed.missing).toBe(0); expect(provider.fetch).toHaveBeenCalledTimes(1); expect(provider.rename).not.toHaveBeenCalled(); - expect(fileRows().map((f) => [f.id, f.original_name, f.deleted_at])).toEqual([[fileId, 'b.pdf', null]]); - expect(itemRows().map((r) => [r.remote_id, r.remote_name, r.state])).toEqual([['/trek/2026/b.pdf', 'b.pdf', 'synced']]); + expect((await fileRows()).map((f) => [f.id, f.original_name, f.deleted_at])).toEqual([[fileId, 'b.pdf', null]]); + expect((await itemRows()).map((r) => [r.remote_id, r.remote_name, r.state])).toEqual([['/trek/2026/b.pdf', 'b.pdf', 'synced']]); }); it('recognises a copy it uploaded itself when it is renamed before the next run', async () => { const link = await makeLink(); - makeFile({ name: 'boarding.pdf' }); + await makeFile({ name: 'boarding.pdf' }); provider.list.mockResolvedValueOnce(ok(listing([]))); provider.push.mockResolvedValueOnce(ok({ remoteId: '/trek/boarding.pdf', remoteVersion: 'v1', remoteModifiedAt: AT, deduplicated: false, @@ -2272,19 +2260,19 @@ describe('DocSyncService', () => { expect(provider.push).toHaveBeenCalledTimes(1); expect(provider.fetch).not.toHaveBeenCalled(); expect(provider.rename).not.toHaveBeenCalled(); - expect(fileRows().map((f) => f.original_name)).toEqual(['flight.pdf']); - expect(itemRows().map((r) => [r.remote_id, r.state])).toEqual([['/trek/flight.pdf', 'synced']]); + expect((await fileRows()).map((f) => f.original_name)).toEqual(['flight.pdf']); + expect((await itemRows()).map((r) => [r.remote_id, r.state])).toEqual([['/trek/flight.pdf', 'synced']]); }); it('finds the copy again after sending a rename made in TREK upstream', async () => { // The adapter cannot report the path its rename produced, so the row // keeps the old one until the next listing shows where the copy went. const link = await makeLink(); - const fileId = makeFile({ name: 'b.pdf' }); - const itemId = seedItem(link, { + const fileId = await makeFile({ name: 'b.pdf' }); + const itemId = (await seedItem(link, { remoteId: '/trek/a.pdf', remoteName: 'a.pdf', remoteVersion: 'va', fileId, state: 'synced', contentSha256: FILE_SHA, remoteSize: 1024, remoteModifiedAt: AT, - }); + })); provider.list.mockResolvedValueOnce(ok(listing([ remoteDoc({ remoteId: '/trek/a.pdf', name: 'a.pdf', remoteVersion: 'va' }), ]))); @@ -2302,8 +2290,8 @@ describe('DocSyncService', () => { expect(provider.rename).toHaveBeenCalledTimes(1); expect(provider.fetch).not.toHaveBeenCalled(); expect(provider.push).not.toHaveBeenCalled(); - expect(fileRows()).toHaveLength(1); - expect(itemRow(itemId)).toMatchObject({ remote_id: '/trek/b.pdf', remote_name: 'b.pdf', state: 'synced' }); + expect(await fileRows()).toHaveLength(1); + expect(await itemRow(itemId)).toMatchObject({ remote_id: '/trek/b.pdf', remote_name: 'b.pdf', state: 'synced' }); }); }); @@ -2347,23 +2335,23 @@ describe('DocSyncService', () => { it('flags a copy deleted at the provider right after TREK uploaded it', async () => { const link = await makeLink(); - const fileId = makeFile({ name: 'boarding.pdf' }); + const fileId = await makeFile({ name: 'boarding.pdf' }); const held = digestScope(); expect(await service.syncLink(link)).toMatchObject({ state: 'ok', pushed: 1 }); - const itemId = Number(itemRows()[0].id); + const itemId = Number((await itemRows())[0].id); held.clear(); const run = await service.syncLink(await config.getLink(link.id)); expect(run).toMatchObject({ state: 'ok', missing: 1 }); - expect(itemRow(itemId)).toMatchObject({ state: 'remote_missing', file_id: fileId }); - expect(itemRow(itemId).remote_missing_at).not.toBeNull(); + expect(await itemRow(itemId)).toMatchObject({ state: 'remote_missing', file_id: fileId }); + expect((await itemRow(itemId)).remote_missing_at).not.toBeNull(); expect((await service.issues(tripId)).map((r) => r.id)).toEqual([itemId]); }); it('lets the mass-delete guard see a scope emptied right after an upload', async () => { const link = await makeLink(); - for (let i = 0; i < 6; i += 1) makeFile({ name: `doc-${i}.pdf`, storageKey: `key-doc-${i}` }); + for (let i = 0; i < 6; i += 1) (await makeFile({ name: `doc-${i}.pdf`, storageKey: `key-doc-${i}` })); const held = digestScope(); expect(await service.syncLink(link)).toMatchObject({ state: 'ok', pushed: 6 }); @@ -2371,46 +2359,46 @@ describe('DocSyncService', () => { const run = await service.syncLink(await config.getLink(link.id)); expect(run).toMatchObject({ state: 'partial', errorCode: 'mass_delete_guard', missing: 6 }); - expect(itemRows().every((r) => r.state === 'synced')).toBe(true); + expect((await itemRows()).every((r) => r.state === 'synced')).toBe(true); }); it('notices a copy TREK binned coming back, so a restore in TREK does not upload it twice', async () => { const link = await makeLink({ deletePolicy: 'trash' }); - const fileId = makeFile({ name: 'boarding.pdf', deletedAt: '2026-09-18 08:00:00' }); - const itemId = seedItem(link, { + const fileId = await makeFile({ name: 'boarding.pdf', deletedAt: '2026-09-18 08:00:00' }); + const itemId = (await seedItem(link, { remoteId: 'r1', remoteName: 'boarding.pdf', remoteVersion: 'v1', fileId, state: 'synced', contentSha256: FILE_SHA, - }); + })); const copy = remoteDoc({ remoteId: 'r1' }); const held = digestScope([copy]); await service.syncLink(link); expect(held.size).toBe(0); - expect(itemRow(itemId).remote_trashed_at).not.toBeNull(); + expect((await itemRow(itemId)).remote_trashed_at).not.toBeNull(); // Out of the provider's recycle bin as it went in, which is the scope the // binning run listed. held.set('r1', copy); await service.syncLink(await config.getLink(link.id)); - expect(itemRow(itemId).remote_trashed_at).toBeNull(); + expect((await itemRow(itemId)).remote_trashed_at).toBeNull(); - testDb.prepare('UPDATE trip_files SET deleted_at = NULL WHERE id = ?').run(fileId); + await updateRows(orm, TripFiles, { id: fileId }, { deleted_at: null }); await service.syncLink(await config.getLink(link.id)); expect(provider.push).not.toHaveBeenCalled(); - expect(itemRow(itemId)).toMatchObject({ state: 'synced', remote_id: 'r1' }); + expect(await itemRow(itemId)).toMatchObject({ state: 'synced', remote_id: 'r1' }); }); it('lists in full after a rename it sent, then goes back to its cursor', async () => { const link = await makeLink(); - const fileId = makeFile({ name: 'new.pdf' }); - seedItem(link, { remoteId: 'r1', remoteName: 'old.pdf', remoteVersion: 'v1', fileId, state: 'synced' }); + const fileId = await makeFile({ name: 'new.pdf' }); + await seedItem(link, { remoteId: 'r1', remoteName: 'old.pdf', remoteVersion: 'v1', fileId, state: 'synced' }); digestScope([remoteDoc({ remoteId: 'r1', name: 'old.pdf' })]); await service.syncLink(link); expect(provider.rename).toHaveBeenCalledTimes(1); - expect(linkRow(link.id).remote_cursor).toBeNull(); + expect((await linkRow(link.id)).remote_cursor).toBeNull(); await service.syncLink(await config.getLink(link.id)); - expect(linkRow(link.id).remote_cursor).toBe('digest:r1@v2'); + expect((await linkRow(link.id)).remote_cursor).toBe('digest:r1@v2'); const quiet = await service.syncLink(await config.getLink(link.id)); expect(provider.list.mock.calls[2][1].cursor).toBe('digest:r1@v2'); @@ -2427,7 +2415,7 @@ describe('DocSyncService', () => { const rest = await service.syncLink(await config.getLink(link.id)); expect(rest).toMatchObject({ state: 'ok', pulled: 5 }); - expect(fileRows()).toHaveLength(MAX_TRANSFERS_PER_RUN + 5); + expect(await fileRows()).toHaveLength(MAX_TRANSFERS_PER_RUN + 5); }); it('fetches a failed download again while nothing moves upstream', async () => { @@ -2436,39 +2424,38 @@ describe('DocSyncService', () => { provider.fetch.mockResolvedValueOnce(fail('timeout')); expect(await service.syncLink(link)).toMatchObject({ state: 'partial', errorCode: 'timeout' }); - testDb.prepare('UPDATE document_sync_items SET next_attempt_at = NULL').run(); + await updateRows(orm, DocumentSyncItems, {}, { next_attempt_at: null }); const retry = await service.syncLink(await config.getLink(link.id)); expect(retry).toMatchObject({ state: 'ok', pulled: 1 }); expect(provider.fetch).toHaveBeenCalledTimes(2); - expect(itemRows()[0]).toMatchObject({ state: 'synced', remote_id: 'r-new' }); + expect((await itemRows())[0]).toMatchObject({ state: 'synced', remote_id: 'r-new' }); }); }); describe('resolveConflict across trips', () => { - function conflictedItem(linkId: number, tripOfItem: number): number { - return Number(testDb.prepare( - `INSERT INTO document_sync_items (link_id, trip_id, trek_doc_uid, remote_id, state) - VALUES (?, ?, 'uid-conflict', 'r-conflict', 'conflict')`, - ).run(linkId, tripOfItem).lastInsertRowid); + function conflictedItem(linkId: number, tripOfItem: number): Promise { + return insertRow(orm, DocumentSyncItems, { + link: linkId, trip: tripOfItem, trek_doc_uid: 'uid-conflict', remote_id: 'r-conflict', state: 'conflict', + }); } it('refuses when the row belongs to another trip', async () => { const link = await makeLink(); - const itemId = conflictedItem(link.id, link.trip_id); + const itemId = await conflictedItem(link.id, link.trip_id); expect(await service.resolveConflict(itemId, 'trek', link.trip_id + 999)).toBe(false); - const after = testDb.prepare('SELECT state FROM document_sync_items WHERE id = ?').get(itemId) as { state: string }; - expect(after.state).toBe('conflict'); + const after = await findRow(orm, DocumentSyncItems, { id: itemId }); + expect(after?.state).toBe('conflict'); }); it('resolves when the row belongs to the trip', async () => { const link = await makeLink(); - const itemId = conflictedItem(link.id, link.trip_id); + const itemId = await conflictedItem(link.id, link.trip_id); expect(await service.resolveConflict(itemId, 'trek', link.trip_id)).toBe(true); - const after = testDb.prepare('SELECT state FROM document_sync_items WHERE id = ?').get(itemId) as { state: string }; - expect(after.state).not.toBe('conflict'); + const after = await findRow(orm, DocumentSyncItems, { id: itemId }); + expect(after?.state).not.toBe('conflict'); }); it('still works for a caller that names no trip, so nothing else breaks', async () => { const link = await makeLink(); - const itemId = conflictedItem(link.id, link.trip_id); + const itemId = await conflictedItem(link.id, link.trip_id); expect(await service.resolveConflict(itemId, 'trek')).toBe(true); }); });