From dd3d97589b2c2053fa04e3d5d96edb9695285ada Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 19 Sep 2026 12:04:43 +0200 Subject: [PATCH] fix(docsync): act on a deletion once, and know what to do when it is undone Both planner branches planned local_deleted for as long as a file sat in TREK's trash. Under unlink nothing showed, until the binding was switched to trash: the next run then binned every document ever deleted in TREK, months back included. A binding on trash from the start asked the provider to bin the same copy on every run. A local_deleted row is now left alone, so the policy is applied once, under the policy in force at that moment. That guard also meant a file taken back out of TREK's trash stayed on local_deleted for good, which it already did before: no transfer, no upload, no flag, and a second deletion of the same file would never have propagated. The row now leaves that state by what became of the provider copy: - still there: the pairing counts again and the row is judged like any synced one, so an upstream edit made meanwhile comes down; - binned by TREK: the pairing is dropped and the file goes up again as a new document through the ordinary push, keeping its trek_doc_uid; - deleted by somebody else: flagged remote_missing, never uploaded over their deletion. Telling the last two apart needs a record of what TREK did, so the new remote_trashed_at column (migration 239) is written when TREK bins a copy and cleared when the pairing is back, or when the binned copy is listed again because somebody restored it at the provider. A file purged from the trash came straight back as well: file_id is ON DELETE SET NULL, and the row read as a download that never landed. A tombstone now stays a tombstone, and a purge no run had seen yet is treated as the deletion it is. Also drops snapshotHash, which nothing read. --- server/src/db/migrations.ts | 21 ++ server/src/nest/doc-sync/doc-sync.helpers.ts | 105 +++++-- server/src/nest/doc-sync/doc-sync.service.ts | 52 +++- .../nest/doc-sync/doc-sync.helpers.test.ts | 163 ++++++++++- .../nest/doc-sync/doc-sync.service.test.ts | 277 ++++++++++++++++++ 5 files changed, 581 insertions(+), 37 deletions(-) diff --git a/server/src/db/migrations.ts b/server/src/db/migrations.ts index 2b59cdb52..e54fcc8c9 100644 --- a/server/src/db/migrations.ts +++ b/server/src/db/migrations.ts @@ -5125,6 +5125,27 @@ function runMigrations(db: Database.Database): void { CREATE INDEX IF NOT EXISTS idx_document_sync_items_due ON document_sync_items(link_id, next_attempt_at); `); }, + + /* + * Document providers: when TREK itself put a provider copy in the bin. + * + * A file deleted in TREK under the `trash` policy takes its provider copy + * with it. Taken back out of TREK's trash, it has to go up again; a copy + * somebody else deleted in the meantime must not. Both leave the same gap + * in a listing, so the difference is written down when TREK acts rather + * than guessed at later. + * + * No backfill from the binding's current policy: that policy may not be + * the one the deletion ran under, and reading it back is the retroactive + * mistake this column exists to avoid. A row left NULL is treated like a + * copy somebody else deleted, which flags it instead of uploading it. + */ + () => { + const hasColumn = db + .prepare("SELECT 1 FROM pragma_table_info('document_sync_items') WHERE name = 'remote_trashed_at'") + .get(); + if (!hasColumn) db.exec('ALTER TABLE document_sync_items ADD COLUMN remote_trashed_at TEXT'); + }, ]; if (currentVersion < migrations.length) { diff --git a/server/src/nest/doc-sync/doc-sync.helpers.ts b/server/src/nest/doc-sync/doc-sync.helpers.ts index 3b7c4017b..c59997ce8 100644 --- a/server/src/nest/doc-sync/doc-sync.helpers.ts +++ b/server/src/nest/doc-sync/doc-sync.helpers.ts @@ -14,20 +14,7 @@ import type { RemoteDocument } from './document-provider'; * service does nothing but execute the plan. */ -// ── Identity and fingerprints ──────────────────────────────────────────────── - -/** - * A stable fingerprint over an explicit field order. - * - * The order is written out rather than derived from the object for the reason - * dawarich.helpers.ts gives: a hash over `Object.keys` changes when someone - * adds a field or a provider reorders its JSON, and a changed hash means - * "changed upstream" to every later run. Explicit means a reviewer can see what - * participates. - */ -export function snapshotHash(parts: readonly (string | number | null | undefined)[]): string { - return crypto.createHash('sha256').update(parts.map((p) => String(p ?? '')).join('\u0000')).digest('hex'); -} +// ── Identity and incoming names ────────────────────────────────────────────── /** Per-document anchor, written into provider metadata where there is room. */ export function newTrekDocUid(): string { @@ -108,6 +95,13 @@ export interface SyncItemState { /** When the backoff lets this row be tried again; null once it is shelved. */ nextAttemptAt: string | null; remoteMissingAt: string | null; + /** + * When TREK itself put the provider copy in the recycle bin, under the + * `trash` policy. A copy binned by TREK and one deleted by somebody else + * leave the same gap in a listing; only this tells them apart once the file + * comes back out of TREK's trash. + */ + remoteTrashedAt: string | null; } /** What TREK currently holds for this trip. */ @@ -138,6 +132,12 @@ export type PlanAction = | { kind: 'mark_remote_missing'; itemId: number } /** Deleted in TREK; what happens upstream is the link's delete policy. */ | { kind: 'local_deleted'; itemId: number; remoteId: string } + /** Back out of TREK's trash while the provider copy stayed: the pairing counts again. */ + | { kind: 'local_restored'; itemId: number } + /** Back out of TREK's trash after TREK binned the copy: unpaired, so it goes up as a new document. */ + | { kind: 'detach'; itemId: number } + /** The copy TREK binned is listed again: somebody restored it at the provider. */ + | { kind: 'remote_restored'; itemId: number } /** Nothing to do but the row should stop looking stale. */ | { kind: 'touch'; itemId: number; remote: RemoteDocument | null }; @@ -195,7 +195,7 @@ export interface ReconcileInput { * or ping-pong forever. */ export function planReconcile(input: ReconcileInput): ReconcilePlan { - const { items, remote, local, direction, remoteTruncated, stableRemoteIds, maxAttempts, conflictPolicy } = input; + const { remote, local, direction, remoteTruncated, stableRemoteIds, maxAttempts, conflictPolicy } = input; const now = input.now ?? new Date().toISOString().replace('T', ' ').slice(0, 19); const actions: PlanAction[] = []; @@ -213,7 +213,7 @@ export function planReconcile(input: ReconcileInput): ReconcilePlan { * which is what "waits for a person" means. */ const blocked = new Set(); - for (const it of items) { + for (const it of input.items) { if (it.state !== 'error') continue; if (it.attempts >= maxAttempts) { blocked.add(it.id); continue; } if (it.nextAttemptAt !== null && it.nextAttemptAt > now) blocked.add(it.id); @@ -260,6 +260,39 @@ export function planReconcile(input: ReconcileInput): ReconcilePlan { const localById = new Map(); for (const l of local) localById.set(l.fileId, l); + /** + * A file taken back out of TREK's trash. + * + * A deletion TREK has acted on is otherwise left alone for good (see the + * pair loop), so this is the one way out of `local_deleted`, and which way + * depends on the provider copy. If it stayed, the pairing counts again and + * the row is judged like any synced one from here on: an edit made upstream + * in the meantime comes down, and a copy somebody else deleted is flagged as + * missing instead of being uploaded over their deletion. If TREK binned it, + * TREK took it away and the person asked for it back, so the pairing is + * dropped and the file goes up as a new document through the ordinary push. + * + * The returned row is what the rest of the plan sees, so it has to match + * what the executor writes for the action added here. + */ + const reopen = (it: SyncItemState): SyncItemState => { + if (it.state !== 'local_deleted' || !it.remoteId || it.fileId === null) return it; + const l = localById.get(it.fileId); + if (!l || l.deletedAt) return it; + const listed = !input.remoteUnchanged && remoteById.has(it.remoteId); + if (it.remoteTrashedAt === null || listed) { + add({ kind: 'local_restored', itemId: it.id }); + return { ...it, state: 'synced', remoteMissingAt: null, remoteTrashedAt: null }; + } + // A pull-only binding cannot upload, and a gap in a truncated listing says + // nothing about the copy. The row waits for a run that can decide. + if (direction === 'pull' || remoteTruncated) return it; + add({ kind: 'detach', itemId: it.id }); + return { ...it, state: 'pending', remoteId: null, remoteVersion: null, remoteTrashedAt: null }; + }; + const items: SyncItemState[] = []; + for (const it of input.items) items.push(reopen(it)); + const itemsByRemote = new Map(); const itemsByFile = new Map(); for (const it of items) { @@ -271,9 +304,14 @@ export function planReconcile(input: ReconcileInput): ReconcilePlan { // remote half of the plan would be reading a list the adapter did not fetch. if (input.remoteUnchanged) { for (const it of items) { - if (it.fileId === null || !it.remoteId) continue; - const l = localById.get(it.fileId); - if (l?.deletedAt) add({ kind: 'local_deleted', itemId: it.id, remoteId: it.remoteId }); + // A deletion already acted on stays acted on, as in the pair loop. A copy + // on record as gone is absent from a full listing too, so a full run + // plans nothing for it until it comes back; binning it here would also + // write the gap down as TREK's doing, and a restore would then upload + // what somebody else deleted. + if (!it.remoteId || it.state === 'local_deleted' || it.state === 'remote_missing') continue; + const l = it.fileId !== null ? localById.get(it.fileId) : undefined; + if (l?.deletedAt || lostItsFile(it)) add({ kind: 'local_deleted', itemId: it.id, remoteId: it.remoteId }); } if (direction !== 'pull') { for (const l of local) { @@ -315,7 +353,21 @@ export function planReconcile(input: ReconcileInput): ReconcilePlan { const localChanged = !!l && l.sha256 !== null && it.contentSha256 !== null && l.sha256 !== it.contentSha256; - if (l?.deletedAt) { + /** + * A deletion is acted on once, under the policy in force at that moment. + * + * This used to be planned again on every run for as long as the file sat + * in TREK's trash. Under `unlink` nothing showed; switching the binding to + * `trash` later then binned every document ever deleted in TREK, months + * back included, and a binding on `trash` from the start asked the + * provider to bin the same copy on every run. A row still here is one + * `reopen` did not take back: its file is still in the trash or purged. + */ + if (it.state === 'local_deleted') { + if (it.remoteTrashedAt !== null) add({ kind: 'remote_restored', itemId: it.id }); + continue; + } + if (l?.deletedAt || lostItsFile(it)) { add({ kind: 'local_deleted', itemId: it.id, remoteId: it.remoteId }); continue; } @@ -444,6 +496,19 @@ export function planReconcile(input: ReconcileInput): ReconcilePlan { return { actions, massDeleteGuardTripped: guardTripped, missingCount: vanished.length }; } +/** + * A file purged from TREK's trash before any run saw it deleted. + * + * `file_id` is ON DELETE SET NULL, so the row outlives the file with nothing + * but a NULL to show for it. Read as a download that never landed, which is + * what happened until now, the document came straight back and the delete + * policy never ran. Only states a row holds together with a file count here: a + * first download that failed has no file either, and is retried. + */ +function lostItsFile(it: SyncItemState): boolean { + return it.fileId === null && (it.state === 'synced' || it.state === 'conflict' || it.state === 'remote_missing'); +} + /** Backoff lookup that saturates instead of running off the end of the curve. */ export function backoffSeconds(curve: readonly number[], failures: number): number { if (failures <= 0) return curve[0]; diff --git a/server/src/nest/doc-sync/doc-sync.service.ts b/server/src/nest/doc-sync/doc-sync.service.ts index 67dcd22d9..020866874 100644 --- a/server/src/nest/doc-sync/doc-sync.service.ts +++ b/server/src/nest/doc-sync/doc-sync.service.ts @@ -277,12 +277,54 @@ export class DocSyncService { return 'ok'; } case 'local_deleted': { - if (ctx.link.delete_policy === 'trash') { + const binned = ctx.link.delete_policy === 'trash'; + if (binned) { const res = await ctx.provider.trash(ctx.ref, ctx.scope, action.remoteId); if (docFailed(res)) return res.error.code; } + // The policy is applied here and never again: the planner leaves a + // `local_deleted` row alone, so a policy changed later cannot reach + // back. Whether TREK binned the copy is written down with it, because a + // restore in TREK later has to know whether the gap upstream is TREK's. this.db.connection - .prepare("UPDATE document_sync_items SET state = 'local_deleted', last_seen_at = CURRENT_TIMESTAMP WHERE id = ?") + .prepare( + `UPDATE document_sync_items + SET state = 'local_deleted', remote_missing_at = NULL, + remote_trashed_at = CASE WHEN ? = 1 THEN CURRENT_TIMESTAMP ELSE NULL END, + last_seen_at = CURRENT_TIMESTAMP + WHERE id = ?`, + ) + .run(binned ? 1 : 0, action.itemId); + return 'ok'; + } + case 'local_restored': { + this.db.connection + .prepare( + `UPDATE document_sync_items + SET state = 'synced', remote_missing_at = NULL, remote_trashed_at = NULL, last_seen_at = CURRENT_TIMESTAMP + WHERE id = ?`, + ) + .run(action.itemId); + return 'ok'; + } + case 'detach': { + // The trek_doc_uid stays: it is the same document to TREK, only the + // provider copy is new. The push the planner queued after this fills + // the pairing in again, and if that push fails the row is an ordinary + // unpaired file that the next run retries. + this.db.connection + .prepare( + `UPDATE document_sync_items + SET state = 'pending', remote_id = NULL, remote_version = NULL, remote_trashed_at = NULL, + error_code = NULL, last_seen_at = CURRENT_TIMESTAMP + WHERE id = ?`, + ) + .run(action.itemId); + return 'ok'; + } + case 'remote_restored': { + this.db.connection + .prepare('UPDATE document_sync_items SET remote_trashed_at = NULL, last_seen_at = CURRENT_TIMESTAMP WHERE id = ?') .run(action.itemId); return 'ok'; } @@ -546,7 +588,7 @@ export class DocSyncService { const rows = this.db.connection .prepare( `SELECT id, file_id, trek_doc_uid, remote_id, remote_version, remote_name, content_sha256, - pushed_sha256, state, attempts, next_attempt_at, remote_missing_at + pushed_sha256, state, attempts, next_attempt_at, remote_missing_at, remote_trashed_at FROM document_sync_items WHERE link_id = ?`, ) .all(linkId) as Array>; @@ -563,6 +605,7 @@ export class DocSyncService { attempts: Number(r.attempts ?? 0), nextAttemptAt: r.next_attempt_at === null || r.next_attempt_at === undefined ? null : String(r.next_attempt_at), remoteMissingAt: r.remote_missing_at === null ? null : String(r.remote_missing_at), + remoteTrashedAt: r.remote_trashed_at === null ? null : String(r.remote_trashed_at), })); } @@ -913,7 +956,8 @@ export class DocSyncService { .prepare( `SELECT link_id, SUM(CASE WHEN file_id IS NOT NULL AND remote_id IS NOT NULL AND state = 'synced' THEN 1 ELSE 0 END) AS paired, - SUM(CASE WHEN remote_id IS NOT NULL AND state != 'remote_missing' THEN 1 ELSE 0 END) AS atProvider, + SUM(CASE WHEN remote_id IS NOT NULL AND state != 'remote_missing' AND remote_trashed_at IS NULL + THEN 1 ELSE 0 END) AS atProvider, SUM(CASE WHEN state = 'remote_missing' THEN 1 ELSE 0 END) AS missing FROM document_sync_items WHERE trip_id = ? diff --git a/server/tests/unit/nest/doc-sync/doc-sync.helpers.test.ts b/server/tests/unit/nest/doc-sync/doc-sync.helpers.test.ts index 314127032..4bf2e28ab 100644 --- a/server/tests/unit/nest/doc-sync/doc-sync.helpers.test.ts +++ b/server/tests/unit/nest/doc-sync/doc-sync.helpers.test.ts @@ -5,7 +5,6 @@ import { isBlockedName, planReconcile, sanitizeIncomingName, - snapshotHash, type LocalDocument, type SyncItemState, } from '../../../../src/nest/doc-sync/doc-sync.helpers'; @@ -54,6 +53,7 @@ const item = (over: Partial = {}): SyncItemState => ({ attempts: 0, nextAttemptAt: null, remoteMissingAt: null, + remoteTrashedAt: null, ...over, }); @@ -329,18 +329,6 @@ describe('isAllowedByOperator', () => { }); }); -describe('snapshotHash', () => { - it('is stable for the same field order and differs when a field changes', () => { - expect(snapshotHash(['a', 1, null])).toBe(snapshotHash(['a', 1, null])); - expect(snapshotHash(['a', 1, null])).not.toBe(snapshotHash(['a', 2, null])); - }); - - it('does not collide when field boundaries shift', () => { - // Joining without a separator would make ['ab','c'] and ['a','bc'] equal. - expect(snapshotHash(['ab', 'c'])).not.toBe(snapshotHash(['a', 'bc'])); - }); -}); - describe('backoffSeconds', () => { it('saturates at the end of the curve instead of running off it', () => { expect(backoffSeconds([60, 300], 1)).toBe(60); @@ -636,3 +624,152 @@ describe('planReconcile > a provider name TREK had to clean up', () => { expect(p.actions.map(a => a.kind)).toEqual(['rename_local']); }); }); + +/** + * A deletion in TREK is acted on once. + * + * Both branches used to plan `local_deleted` for as long as the file sat in + * TREK's trash, so the policy was applied again on every run. Under `unlink` + * that was invisible until the binding was switched to `trash`: the next run + * then binned every document ever deleted in TREK. + */ +describe('planReconcile > a deletion TREK already acted on', () => { + const settled = (over: Partial = {}) => + item({ id: 50, fileId: 5, remoteId: 'r5', state: 'local_deleted', ...over }); + const inTrash = local({ fileId: 5, deletedAt: '2026-09-18 08:00:00' }); + + it('plans nothing more while the file sits in the trash', () => { + const p = plan({ items: [settled()], remote: [remote({ remoteId: 'r5' })], local: [inTrash] }); + expect(p.actions).toEqual([]); + }); + + it('plans nothing more under an unchanged upstream either', () => { + const p = plan({ items: [settled()], remote: [], local: [inTrash], remoteUnchanged: true }); + expect(p.actions).toEqual([]); + }); + + it('does not download the document again once the file is purged', () => { + // file_id is ON DELETE SET NULL, and a row without a file used to read as + // a download that never landed. + const p = plan({ items: [settled({ fileId: null })], remote: [remote({ remoteId: 'r5' })], local: [] }); + expect(p.actions).toEqual([]); + }); + + it('reads a file purged before any run saw it deleted as a deletion', () => { + const p = plan({ items: [item({ fileId: null, state: 'synced' })], remote: [remote()], local: [] }); + expect(p.actions).toEqual([{ kind: 'local_deleted', itemId: 10, remoteId: 'r1' }]); + }); + + it('does the same under an unchanged upstream', () => { + const p = plan({ items: [item({ fileId: null, state: 'synced' })], remote: [], local: [], remoteUnchanged: true }); + expect(p.actions).toEqual([{ kind: 'local_deleted', itemId: 10, remoteId: 'r1' }]); + }); + + it('leaves a copy already on record as gone alone under an unchanged upstream', () => { + // Binning it would record the gap as TREK's doing, and a restore would + // later upload a document somebody else deleted. + const p = plan({ + items: [item({ state: 'remote_missing', remoteMissingAt: '2026-09-17 08:00:00' })], + remote: [], + local: [local({ deletedAt: '2026-09-18 08:00:00' })], + remoteUnchanged: true, + }); + expect(p.actions).toEqual([]); + }); + + it('notices when the copy TREK binned is listed again', () => { + const p = plan({ + items: [settled({ remoteTrashedAt: '2026-09-18 08:05:00' })], + remote: [remote({ remoteId: 'r5' })], + local: [inTrash], + }); + expect(p.actions).toEqual([{ kind: 'remote_restored', itemId: 50 }]); + }); +}); + +/** + * A file taken back out of TREK's trash. + * + * Which way the row goes depends on what became of the provider copy while + * TREK's sat in the bin: still there, binned by TREK itself, or deleted by + * somebody else. + */ +describe('planReconcile > a file taken back out of TREK trash', () => { + const settled = (over: Partial = {}) => + item({ id: 50, fileId: 5, remoteId: 'r5', state: 'local_deleted', ...over }); + const binnedByTrek = (over: Partial = {}) => + settled({ remoteTrashedAt: '2026-09-18 08:05:00', ...over }); + const back = local({ fileId: 5 }); + + it('resumes the pairing when the provider copy stayed', () => { + const p = plan({ items: [settled()], remote: [remote({ remoteId: 'r5' })], local: [back] }); + expect(p.actions.map(a => a.kind)).toEqual(['local_restored', 'touch']); + }); + + it('brings down an edit made upstream in the meantime, as for any synced row', () => { + const p = plan({ items: [settled()], remote: [remote({ remoteId: 'r5', remoteVersion: 'v2' })], local: [back] }); + expect(p.actions.map(a => a.kind)).toEqual(['local_restored', 'pull_update']); + }); + + it('flags a copy somebody else deleted meanwhile instead of uploading it', () => { + const p = plan({ items: [settled()], remote: [], local: [back] }); + expect(p.actions).toEqual([ + { kind: 'local_restored', itemId: 50 }, + { kind: 'mark_remote_missing', itemId: 50 }, + ]); + }); + + it('resumes under an unchanged upstream and leaves the listing to a later run', () => { + const p = plan({ items: [settled()], remote: [], local: [back], remoteUnchanged: true }); + expect(p.actions).toEqual([{ kind: 'local_restored', itemId: 50 }]); + }); + + it('uploads it again when TREK was the one that binned the copy', () => { + const p = plan({ items: [binnedByTrek()], remote: [], local: [back] }); + expect(p.actions).toEqual([ + { kind: 'detach', itemId: 50 }, + { kind: 'push', local: back, itemId: 50, remoteId: null }, + ]); + }); + + it('does so under an unchanged upstream too', () => { + const p = plan({ items: [binnedByTrek()], remote: [], local: [back], remoteUnchanged: true }); + expect(p.actions.map(a => a.kind)).toEqual(['detach', 'push']); + }); + + it('is not held back by the echo guard: nothing about it is a remote change', () => { + const p = plan({ items: [binnedByTrek({ pushedSha256: 'aaa' })], remote: [], local: [local({ fileId: 5, sha256: 'aaa' })] }); + expect(p.actions.map(a => a.kind)).toEqual(['detach', 'push']); + }); + + it('waits on a pull-only binding, which cannot upload', () => { + const p = plan({ items: [binnedByTrek()], remote: [], local: [back], direction: 'pull' }); + expect(p.actions).toEqual([]); + }); + + it('waits on a truncated listing, where a gap proves nothing', () => { + const p = plan({ items: [binnedByTrek()], remote: [], local: [back], remoteTruncated: true }); + expect(p.actions).toEqual([]); + }); + + it('resumes instead when the binned copy was restored at the provider as well', () => { + const p = plan({ items: [binnedByTrek()], remote: [remote({ remoteId: 'r5' })], local: [back] }); + expect(p.actions.map(a => a.kind)).toEqual(['local_restored', 'touch']); + }); + + it('reads many restores after TREK binned them as uploads, not as a mass deletion', () => { + const rows = Array.from({ length: 10 }, (_, i) => + binnedByTrek({ id: 100 + i, fileId: 100 + i, remoteId: `r${100 + i}` })); + const p = plan({ items: rows, remote: [], local: rows.map((r) => local({ fileId: r.fileId as number })) }); + expect(p.massDeleteGuardTripped).toBe(false); + expect(p.actions.filter(a => a.kind === 'detach')).toHaveLength(10); + expect(p.actions.filter(a => a.kind === 'push')).toHaveLength(10); + }); + + it('counts many restored rows whose copies vanished like any synced rows, guard included', () => { + const rows = Array.from({ length: 10 }, (_, i) => + settled({ id: 100 + i, fileId: 100 + i, remoteId: `r${100 + i}` })); + const p = plan({ items: rows, remote: [], local: rows.map((r) => local({ fileId: r.fileId as number })) }); + expect(p.massDeleteGuardTripped).toBe(true); + }); +}); 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 59b51f8df..2d608a128 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 @@ -1245,6 +1245,283 @@ describe('DocSyncService', () => { }); }); + /** + * A deletion in TREK is acted on once, under the policy of that moment. + * + * Both planner branches used to emit `local_deleted` for as long as the file + * sat in TREK's trash. Under `unlink` that was invisible, until somebody + * switched the binding to `trash`: the next run then binned every document + * 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, { + 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 = makeLink({ deletePolicy: 'unlink' }); + const { itemId } = 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(); + + testDb.prepare("UPDATE trip_document_links SET delete_policy = 'trash' WHERE id = ?").run(link.id); + await service.syncLink(config.getLink(link.id)); + + expect(provider.trash).not.toHaveBeenCalled(); + expect(itemRow(itemId).state).toBe('local_deleted'); + }); + + it('bins the provider copy exactly once, even while the provider keeps listing it', async () => { + const link = makeLink({ deletePolicy: 'trash' }); + const { itemId } = deletedPairing(link); + provider.list.mockResolvedValue(ok(listing([remoteDoc({ remoteId: 'r1' })]))); + + await service.syncLink(link); + expect(itemRow(itemId).remote_trashed_at).not.toBeNull(); + await service.syncLink(config.getLink(link.id)); + await service.syncLink(config.getLink(link.id)); + + expect(provider.trash).toHaveBeenCalledTimes(1); + }); + + it('does not ask again under an unchanged upstream either', async () => { + const link = makeLink({ deletePolicy: 'trash' }); + deletedPairing(link); + provider.list.mockResolvedValueOnce(ok(listing([remoteDoc({ remoteId: 'r1' })]))); + await service.syncLink(link); + + provider.list.mockResolvedValue(ok(listing([], { cursorUnchanged: true }))); + await service.syncLink(config.getLink(link.id)); + + expect(provider.trash).toHaveBeenCalledTimes(1); + }); + }); + + /** + * Taking a file back out of TREK's trash. + * + * `FilesService.restoreFile` only clears `deleted_at`; nothing in the files + * domain knows about sync, so the next run is what finds out. Three cases, + * told apart by what happened to the provider copy while TREK's sat in the + * bin: still there, binned by TREK itself, or gone by somebody else's hand. + */ + 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); + + /** 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, { + 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'); + return { itemId, fileId }; + } + + it('resumes the pairing without a transfer when the provider copy is still there', async () => { + const link = makeLink({ deletePolicy: 'unlink' }); + const { itemId, fileId } = await deletedAndSeen(link); + + restore(fileId); + provider.list.mockResolvedValueOnce(ok(listing([remoteDoc({ remoteId: 'r1' })]))); + const run = await service.syncLink(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]]); + }); + + it('lets the ordinary rules bring down an edit made upstream in the meantime', async () => { + const link = makeLink({ deletePolicy: 'unlink' }); + const { itemId, fileId } = await deletedAndSeen(link); + + restore(fileId); + provider.list.mockResolvedValueOnce(ok(listing([remoteDoc({ remoteId: 'r1', remoteVersion: 'v2' })]))); + const run = await service.syncLink(config.getLink(link.id)); + + expect(run.pulled).toBe(1); + const row = itemRow(itemId); + expect(row.state).toBe('synced'); + expect(row.file_id).not.toBe(fileId); + expect(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 = makeLink({ deletePolicy: 'trash' }); + const { itemId, fileId } = await deletedAndSeen(link); + expect(provider.trash).toHaveBeenCalledTimes(1); + const uid = itemRow(itemId).trek_doc_uid; + + restore(fileId); + provider.list.mockResolvedValueOnce(ok(listing([]))); + const run = await service.syncLink(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); + 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); + }); + + it('does not mistake the re-upload for a foreign change on the run after', async () => { + // The echo guard must still recognise TREK's own write once the provider + // reports it back under a new version marker. + const link = makeLink({ deletePolicy: 'trash' }); + const { itemId, fileId } = await deletedAndSeen(link); + restore(fileId); + provider.list.mockResolvedValueOnce(ok(listing([]))); + await service.syncLink(config.getLink(link.id)); + + provider.list.mockResolvedValueOnce(ok(listing([ + remoteDoc({ remoteId: 'r-pushed', name: 'boarding.pdf', remoteVersion: 'v2', contentHash: FILE_SHA }), + ]))); + const run = await service.syncLink(config.getLink(link.id)); + + expect(run.pulled).toBe(0); + expect(provider.fetch).not.toHaveBeenCalled(); + expect(provider.push).toHaveBeenCalledTimes(1); + expect(itemRow(itemId).state).toBe('synced'); + }); + + it('retries a re-upload that failed like any other unpaired file', async () => { + const link = makeLink({ deletePolicy: 'trash' }); + const { itemId, fileId } = await deletedAndSeen(link); + restore(fileId); + provider.list.mockResolvedValue(ok(listing([]))); + provider.push.mockResolvedValueOnce(fail('unreachable')); + + await service.syncLink(config.getLink(link.id)); + const failed = 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 service.syncLink(config.getLink(link.id)); + + expect(provider.push).toHaveBeenCalledTimes(2); + expect(itemRow(itemId).state).toBe('synced'); + expect(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 = 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' })); + 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); + let seq = 0; + provider.push.mockImplementation(async () => { + seq += 1; + return ok({ remoteId: `r-new-${seq}`, remoteVersion: 'v1', remoteModifiedAt: null, deduplicated: false }); + }); + provider.list.mockResolvedValueOnce(ok(listing([]))); + const run = await service.syncLink(config.getLink(link.id)); + + expect(run.errorCode).toBeUndefined(); + expect(run.state).toBe('ok'); + expect(run.pushed).toBe(10); + expect(itemRows().every((r) => r.state === 'synced')).toBe(true); + }); + + it('does not upload a copy TREK binned that was restored at the provider meanwhile', async () => { + // Seen while TREK's file was still in the trash, so the note that TREK + // binned it is dropped. Without that, a later restore under an unchanged + // upstream (no listing to look at) would upload a second copy. + const link = makeLink({ deletePolicy: 'trash' }); + const { itemId, fileId } = await deletedAndSeen(link); + + provider.list.mockResolvedValueOnce(ok(listing([remoteDoc({ remoteId: 'r1' })]))); + await service.syncLink(config.getLink(link.id)); + expect(itemRow(itemId).remote_trashed_at).toBeNull(); + expect(provider.fetch).not.toHaveBeenCalled(); + + restore(fileId); + provider.list.mockResolvedValueOnce(ok(listing([], { cursorUnchanged: true }))); + await service.syncLink(config.getLink(link.id)); + + expect(provider.push).not.toHaveBeenCalled(); + expect(itemRow(itemId).state).toBe('synced'); + expect(itemRow(itemId).remote_id).toBe('r1'); + }); + + it('flags the copy as missing when it vanished upstream by somebody else', async () => { + const link = makeLink({ deletePolicy: 'unlink' }); + const { itemId, fileId } = await deletedAndSeen(link); + + restore(fileId); + provider.list.mockResolvedValueOnce(ok(listing([]))); + const run = await service.syncLink(config.getLink(link.id)); + + expect(provider.push).not.toHaveBeenCalled(); + expect(run.missing).toBe(1); + expect(itemRow(itemId).state).toBe('remote_missing'); + expect(service.issues(tripId).map((r) => r.id)).toContain(itemId); + }); + }); + + /** + * Purging leaves the row behind with `file_id` NULL (ON DELETE SET NULL), and + * that row read as a download that never landed: under `unlink` the document + * came back on the next run, and a purge no run had seen yet skipped the + * delete policy entirely. + */ + describe('a file purged from TREK trash', () => { + const purge = (fileId: number) => testDb.prepare('DELETE FROM trip_files WHERE id = ?').run(fileId); + + it('does not download a document again once its deletion was seen', async () => { + const link = 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' }); + provider.list.mockResolvedValue(ok(listing([remoteDoc({ remoteId: 'r1' })]))); + await service.syncLink(link); + + purge(fileId); + await service.syncLink(config.getLink(link.id)); + + expect(provider.fetch).not.toHaveBeenCalled(); + expect(fileRows()).toHaveLength(0); + expect(itemRow(itemId).state).toBe('local_deleted'); + }); + + it('applies the delete policy when the purge came before any run saw the deletion', async () => { + const link = 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); + provider.list.mockResolvedValue(ok(listing([remoteDoc({ remoteId: 'r1' })]))); + + await service.syncLink(link); + await service.syncLink(config.getLink(link.id)); + + expect(provider.fetch).not.toHaveBeenCalled(); + expect(provider.trash).toHaveBeenCalledTimes(1); + expect(fileRows()).toHaveLength(0); + expect(itemRow(itemId).state).toBe('local_deleted'); + }); + }); + describe('resolveConflict across trips', () => { function conflictedItem(linkId: number, tripOfItem: number): number { return Number(testDb.prepare(