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.
This commit is contained in:
Maurice
2026-09-19 12:04:43 +02:00
parent dc1ac0ae85
commit dd3d97589b
5 changed files with 581 additions and 37 deletions
+48 -4
View File
@@ -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<Record<string, unknown>>;
@@ -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 = ?