mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(docsync): follow a rename on Synology, and settle restores and purges in one run
Where the id is the path, a rename or a move upstream lists the same copy under a new id. The upstream-only loop re-paired it with a touch, which went wrong twice. The pairing had already been counted as vanished, so the same run flagged it missing as well and counted it towards the mass-delete guard. And touch wrote the provider's new name into remote_name while the trip file kept the old one, so the next run read that as a rename made in TREK and renamed the provider's copy back. The heuristic also matched on a content hash only, which Synology never sends: on the one provider without stable ids it did not fire at all, and every rename there ended as a missing flag plus a second download of the same bytes. The planner now moves such a pairing first, with a relocate action that updates remote_id and the version and leaves the agreed name alone. The pair loop then treats it like any listed document, so the new name comes into TREK through rename_local exactly as it does where ids are stable, and nothing counts as vanished. A copy is recognised by its content hash where the listing has one, otherwise by size and modification time, which a rename or a move leaves untouched. Only a one-to-one match is followed, and a copy TREK binned itself never ties with a live twin of the same bytes. The rows now load remote_size and remote_modified_at, which were written on pull and never read; touch keeps them current and a push records them. rename_local and the provider branch of resolveConflict take the provider name in its cleaned form, since a raw name with a colon or a slash read as a rename made in TREK on the next run. Restores and purges: - A file taken back out of TREK's trash whose copy somebody else had deleted meanwhile resumed as synced and was then counted as vanished, so a few such restores tripped the mass-delete guard on every run until the circuit opened. It now lands as remote_missing directly, outside the guard, and the service asks for a full listing while such a restore is pending. - A row in error that once held a file now reads as a deletion once the file is purged, told apart from a first download that never landed by the agreed hash. "Sync now" used to download the deleted document again. - Moves are followed before a restore is judged, so a binned copy restored at the provider under a new path resumes its pairing instead of being duplicated on both sides. - relocate and both renames lift a remote_missing flag the way touch does. - A double rename under trek_wins or provider_wins is settled with a rename instead of a transfer.
This commit is contained in:
@@ -9,8 +9,8 @@ import type { RemoteDocument } from './document-provider';
|
||||
*
|
||||
* `planReconcile` takes a snapshot of both sides and returns the actions to
|
||||
* take. It touches no database, no HTTP and no container, which is what lets
|
||||
* the hard cases — conflict, echo, mass deletion, rename, a document that
|
||||
* exists on neither side any more — be tested exhaustively as plain data. The
|
||||
* the hard cases (conflict, echo, mass deletion, rename, a document that
|
||||
* exists on neither side any more) be tested exhaustively as plain data. The
|
||||
* service does nothing but execute the plan.
|
||||
*/
|
||||
|
||||
@@ -26,7 +26,7 @@ export function newTrekDocUid(): string {
|
||||
* separators, control characters, leading dots and NTFS-illegal characters, and
|
||||
* they end up both in a DB column and in a Content-Disposition header.
|
||||
*
|
||||
* The extension is preserved deliberately — it is what TREK's download route
|
||||
* The extension is preserved deliberately: it is what TREK's download route
|
||||
* derives the Content-Type from, and what the blocklist check reads.
|
||||
*/
|
||||
export function sanitizeIncomingName(raw: string): string {
|
||||
@@ -84,11 +84,17 @@ export interface SyncItemState {
|
||||
trekDocUid: string;
|
||||
remoteId: string | null;
|
||||
remoteVersion: string | null;
|
||||
/** The name the provider had at the last agreed state — the rename arbiter. */
|
||||
/** The name the provider had at the last agreed state: the rename arbiter. */
|
||||
remoteName: string | null;
|
||||
/**
|
||||
* Size and modification time of the provider copy as last seen. Only read
|
||||
* where ids are paths, to recognise the same copy after a rename or a move.
|
||||
*/
|
||||
remoteSize: number | null;
|
||||
remoteModifiedAt: string | null;
|
||||
/** The bytes both sides last agreed on. */
|
||||
contentSha256: string | null;
|
||||
/** What TREK itself last uploaded — the echo guard. */
|
||||
/** What TREK itself last uploaded: the echo guard. */
|
||||
pushedSha256: string | null;
|
||||
state: string;
|
||||
attempts: number;
|
||||
@@ -123,6 +129,11 @@ export type PlanAction =
|
||||
| { kind: 'pull_update'; remote: RemoteDocument; itemId: number }
|
||||
/** TREK moved on while upstream did not: push a new revision. */
|
||||
| { kind: 'push_update'; local: LocalDocument; itemId: number; remoteId: string }
|
||||
/**
|
||||
* Where the id is the path: the paired copy is listed under a new one,
|
||||
* because it was renamed or moved. The pairing follows; nothing else changes.
|
||||
*/
|
||||
| { kind: 'relocate'; itemId: number; remote: RemoteDocument }
|
||||
/** A name changed on one side only. */
|
||||
| { kind: 'rename_remote'; itemId: number; remoteId: string; name: string }
|
||||
| { kind: 'rename_local'; itemId: number; fileId: number; name: string }
|
||||
@@ -132,8 +143,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 while the provider copy stayed: the pairing counts
|
||||
* again. With `missing`, the copy is gone and TREK did not bin it, so the row
|
||||
* comes back flagged the way a vanished copy is.
|
||||
*/
|
||||
| { kind: 'local_restored'; itemId: number; missing?: true }
|
||||
/** 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. */
|
||||
@@ -145,7 +160,7 @@ export interface ReconcilePlan {
|
||||
actions: PlanAction[];
|
||||
/** True when the listing looked like a mass deletion and was not trusted. */
|
||||
massDeleteGuardTripped: boolean;
|
||||
/** Vanished upstream but below the guard threshold. */
|
||||
/** Newly gone upstream: vanished since the last run, or found gone on a restore in TREK. */
|
||||
missingCount: number;
|
||||
}
|
||||
|
||||
@@ -159,8 +174,8 @@ export interface ReconcileInput {
|
||||
/**
|
||||
* The provider says its side has not moved since the stored cursor.
|
||||
*
|
||||
* Every adapter derives that cursor from the scope's contents — a digest over
|
||||
* the documents for Paperless, Papra and Synology, the root ETag for WebDAV —
|
||||
* Every adapter derives that cursor from the scope's contents (a digest over
|
||||
* the documents for Paperless, Papra and Synology, the root ETag for WebDAV),
|
||||
* so the flag means "nothing changed upstream" for all of them. The WebDAV
|
||||
* adapter acts on it by returning an EMPTY list instead of walking the folder,
|
||||
* which is only safe if this is read: otherwise a quiet binding looks like a
|
||||
@@ -173,7 +188,7 @@ export interface ReconcileInput {
|
||||
*
|
||||
* `manual` parks the row for a person. The other two are the reason this
|
||||
* field exists: the binding was asked at setup, the answer was stored, and
|
||||
* nothing ever read it — so every conflict was manual whatever the binding
|
||||
* nothing ever read it, so every conflict was manual whatever the binding
|
||||
* said, and a person who had chosen a side still had to pick it again.
|
||||
*/
|
||||
conflictPolicy: 'manual' | 'trek_wins' | 'provider_wins';
|
||||
@@ -202,7 +217,7 @@ export function planReconcile(input: ReconcileInput): ReconcilePlan {
|
||||
/**
|
||||
* Rows that must not be tried again on this run.
|
||||
*
|
||||
* A provider that refuses a document refuses it every time — a file it reads
|
||||
* A provider that refuses a document refuses it every time: a file it reads
|
||||
* as corrupt, a type it does not accept, a quota that is full. Without this
|
||||
* the row is re-planned on every pass, so a single bad document turns into an
|
||||
* upload attempt every cron tick forever; rows were found at 46 attempts
|
||||
@@ -233,7 +248,8 @@ export function planReconcile(input: ReconcileInput): ReconcilePlan {
|
||||
};
|
||||
|
||||
/**
|
||||
* Both sides moved. The binding's answer decides, and `manual` parks it.
|
||||
* Both sides changed the bytes. The binding's answer decides, and `manual`
|
||||
* parks it.
|
||||
*
|
||||
* A side that wins takes the whole document, name and bytes together, because
|
||||
* the two halves of one document coming from different sides is not a state
|
||||
@@ -242,8 +258,8 @@ export function planReconcile(input: ReconcileInput): ReconcilePlan {
|
||||
* the opposite of what was chosen.
|
||||
*/
|
||||
const settle = (it: SyncItemState, r: RemoteDocument, l: LocalDocument | null): void => {
|
||||
// Only the two answers that name a winner act; anything else — `manual`, a
|
||||
// value from a future version, a column somebody edited by hand — parks the
|
||||
// Only the two answers that name a winner act; anything else (`manual`, a
|
||||
// value from a future version, a column somebody edited by hand) parks the
|
||||
// row. Overwriting one side is the destructive move, so it needs a yes.
|
||||
const park = (): void => { add({ kind: 'conflict', itemId: it.id, remote: r, local: l }); };
|
||||
if (conflictPolicy === 'provider_wins' && direction !== 'push') {
|
||||
@@ -255,43 +271,141 @@ export function planReconcile(input: ReconcileInput): ReconcilePlan {
|
||||
return park();
|
||||
};
|
||||
|
||||
/**
|
||||
* Both sides renamed a document whose bytes still agree. Only the name is in
|
||||
* dispute, so the winning side's name is carried over and nothing is sent.
|
||||
*
|
||||
* This went through `settle` once, and `trek_wins` re-uploaded identical
|
||||
* bytes. Where the provider replaces in place (WebDAV, Synology) the upload
|
||||
* also kept the provider's name, so the next run read that as a rename made
|
||||
* upstream and gave TREK's file the name TREK was supposed to win with.
|
||||
*/
|
||||
const settleName = (it: SyncItemState, r: RemoteDocument, l: LocalDocument, incoming: string): void => {
|
||||
if (conflictPolicy === 'provider_wins' && direction !== 'push') {
|
||||
return add({ kind: 'rename_local', itemId: it.id, fileId: l.fileId, name: incoming });
|
||||
}
|
||||
if (conflictPolicy === 'trek_wins' && direction !== 'pull') {
|
||||
return add({ kind: 'rename_remote', itemId: it.id, remoteId: it.remoteId, name: l.name });
|
||||
}
|
||||
return add({ kind: 'conflict', itemId: it.id, remote: r, local: l });
|
||||
};
|
||||
|
||||
const remoteById = new Map<string, RemoteDocument>();
|
||||
for (const r of remote) if (!r.isDeleted) remoteById.set(r.remoteId, r);
|
||||
const localById = new Map<number, LocalDocument>();
|
||||
for (const l of local) localById.set(l.fileId, l);
|
||||
|
||||
/**
|
||||
* Where the id is the path, a rename or a move upstream reads as one copy
|
||||
* gone and another one new.
|
||||
*
|
||||
* This used to be patched up in the upstream-only loop with a `touch`, which
|
||||
* went wrong twice. The pairing had already been counted as vanished by then,
|
||||
* so the same run flagged it missing (and counted it towards the mass-delete
|
||||
* guard); and `touch` wrote the provider's new name into the arbiter, so the
|
||||
* next run read the name TREK still had as a rename made in TREK and renamed
|
||||
* the provider's copy back.
|
||||
*
|
||||
* So the pairing is moved first, before anything else looks at it, `reopen`
|
||||
* included: onto a new entry that is recognisably the same copy (see
|
||||
* `sameCopy`). Only the id and the version move. The agreed name stays, so
|
||||
* the pair loop reads the new name the way it reads any rename, and the rest
|
||||
* of the plan sees a document that is listed. A rename then ends exactly as
|
||||
* it does where ids are stable. A rename TREK sent upstream is found again
|
||||
* the same way: the adapter cannot say what the new path is, so the stored id
|
||||
* is stale until here.
|
||||
*
|
||||
* Only a one-to-one match moves anything. Two copies of the same bytes
|
||||
* renamed at once cannot be told apart, and pairing them crosswise puts each
|
||||
* name on the other file; unless one of them kept its name (a move), neither
|
||||
* is moved, and both are handled like any other gap and new document.
|
||||
*
|
||||
* A copy TREK binned itself only gets an entry nobody else fits. Its gap is
|
||||
* TREK's own doing, and letting it make a tie meant that deleting one of two
|
||||
* identical files in TREK stopped a rename of the other from being followed,
|
||||
* which came back as a missing flag plus a second download. It is still
|
||||
* recognised on its own, as a copy restored at the provider somewhere else.
|
||||
*/
|
||||
const followMoves = (list: readonly SyncItemState[]): SyncItemState[] => {
|
||||
const paired = new Set(list.map((it) => it.remoteId));
|
||||
const fresh = remote.filter((r) => !r.isDeleted && !paired.has(r.remoteId));
|
||||
const gone = list.filter((it) => it.remoteId !== null && !remoteById.has(it.remoteId));
|
||||
|
||||
const owner = (r: RemoteDocument): SyncItemState | null => {
|
||||
const fits = gone.filter((o) => sameCopy(o, r));
|
||||
return soleMatch(fits, (o) => keptName(o, r))
|
||||
?? soleMatch(fits.filter((o) => o.remoteTrashedAt === null), (o) => keptName(o, r));
|
||||
};
|
||||
|
||||
const moved = new Map<number, RemoteDocument>();
|
||||
for (const it of gone) {
|
||||
const r = soleMatch(fresh.filter((d) => sameCopy(it, d)), (d) => keptName(it, d));
|
||||
if (!r || owner(r) !== it) continue;
|
||||
// Counted above so a shelved row still makes a tie a tie, but not moved:
|
||||
// `add` would drop the action while the plan went on with the new id.
|
||||
if (blocked.has(it.id)) continue;
|
||||
moved.set(it.id, r);
|
||||
}
|
||||
|
||||
return list.map((it) => {
|
||||
const r = moved.get(it.id);
|
||||
if (!r) return it;
|
||||
add({ kind: 'relocate', itemId: it.id, remote: r });
|
||||
// Same bytes, so the new version marker is not a change: on a path-keyed
|
||||
// provider it differs only because the path is part of it.
|
||||
return { ...it, remoteId: r.remoteId, remoteVersion: r.remoteVersion };
|
||||
});
|
||||
};
|
||||
|
||||
// Restored rows whose copy turned out to be gone: missing, but not vanished.
|
||||
let foundGone = 0;
|
||||
|
||||
/**
|
||||
* 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
|
||||
* depends on the provider copy. If it is listed, 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.
|
||||
* in the meantime comes down. 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.
|
||||
*
|
||||
* A copy gone by somebody else's hand is flagged as missing rather than
|
||||
* uploaded over their deletion, and flagged right here rather than counted
|
||||
* as vanished. Nothing is recorded about a copy while its row sits in
|
||||
* `local_deleted`, so it may have been gone for weeks, and counting it
|
||||
* towards the mass-delete guard read a handful of such restores as a listing
|
||||
* that shrank: the guard tripped, the run was abandoned before the restores
|
||||
* were written, and the next run found the same plan, for good. Only a
|
||||
* listing that could have shown the copy may say it is gone, so otherwise
|
||||
* the row waits; `needsFullListing` keeps that to a single run.
|
||||
*
|
||||
* 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) {
|
||||
if (!isBackFromTrash(it, localById)) return it;
|
||||
if (!input.remoteUnchanged && remoteById.has(it.remoteId)) {
|
||||
add({ kind: 'local_restored', itemId: it.id });
|
||||
return { ...it, state: 'synced', remoteMissingAt: null, remoteTrashedAt: null };
|
||||
}
|
||||
if (it.remoteTrashedAt === null) {
|
||||
if (input.remoteUnchanged || remoteTruncated) return it;
|
||||
foundGone += 1;
|
||||
add({ kind: 'local_restored', itemId: it.id, missing: true });
|
||||
return { ...it, state: 'remote_missing', remoteMissingAt: now, 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));
|
||||
|
||||
// Upstream unchanged means there is no listing to follow anything into.
|
||||
const followed = stableRemoteIds || input.remoteUnchanged ? input.items : followMoves(input.items);
|
||||
const items = followed.map(reopen);
|
||||
|
||||
const itemsByRemote = new Map<string, SyncItemState>();
|
||||
const itemsByFile = new Map<number, SyncItemState>();
|
||||
@@ -326,6 +440,8 @@ export function planReconcile(input: ReconcileInput): ReconcilePlan {
|
||||
|
||||
// A listing that lost most of what it had last time is far more likely to be
|
||||
// a broken mount, a revoked token or a moved folder than a real mass delete.
|
||||
// A row `reopen` resumed is only `synced` here when its copy is listed, so a
|
||||
// restore never reads as part of a listing that shrank.
|
||||
const known = items.filter((i) => i.remoteId && i.state === 'synced');
|
||||
const vanished = known.filter((i) => !remoteById.has(i.remoteId as string));
|
||||
const guardTripped =
|
||||
@@ -343,7 +459,7 @@ export function planReconcile(input: ReconcileInput): ReconcilePlan {
|
||||
const remoteChanged = it.remoteVersion !== null && r.remoteVersion !== it.remoteVersion;
|
||||
// The echo guard: bytes TREK itself pushed come back as a change on the
|
||||
// provider side. They are only TREK's own write if the hash still matches
|
||||
// what was pushed, so this compares content, never timestamps — a time
|
||||
// what was pushed, so this compares content, never timestamps: a time
|
||||
// window would misfire on every clock skew.
|
||||
const isEcho =
|
||||
remoteChanged &&
|
||||
@@ -377,14 +493,14 @@ export function planReconcile(input: ReconcileInput): ReconcilePlan {
|
||||
*
|
||||
* A failed pull stores the remote's version alongside the error, so on the
|
||||
* next run `remoteChanged` is false, there is no local file for the other
|
||||
* branches to compare against, and the row fell through to `touch` — which
|
||||
* branches to compare against, and the row fell through to `touch`, which
|
||||
* refreshes it and changes nothing. Nothing anywhere planned a second
|
||||
* attempt: the document never arrived, no retry ever happened, and pressing
|
||||
* "Sync now" did not help either, because the plan was the same.
|
||||
*
|
||||
* A rejected type and an oversized file are left out on purpose. Those are
|
||||
* not transport failures; they are answers, and they stay true until the
|
||||
* document upstream changes — which the `remoteChanged` branch below picks
|
||||
* document upstream changes, which the `remoteChanged` branch below picks
|
||||
* up on its own.
|
||||
*/
|
||||
if (!l && it.fileId === null && direction !== 'push'
|
||||
@@ -409,21 +525,27 @@ export function planReconcile(input: ReconcileInput): ReconcilePlan {
|
||||
}
|
||||
|
||||
// Same bytes, different name. Which side renamed is decided by comparing
|
||||
// each against the name they last agreed on — not by direction. Reading
|
||||
// each against the name they last agreed on, not by direction. Reading
|
||||
// `both` as "TREK always wins" silently renamed the provider's copy back
|
||||
// every time somebody tidied up a folder, which is the opposite of a
|
||||
// two-way sync.
|
||||
if (l && sanitizeIncomingName(r.name) !== l.name) {
|
||||
// Compared through the same sanitiser the local name went through, or a
|
||||
// provider name TREK had to clean up (a slash, a control character, a
|
||||
// leading dot) reads as "TREK renamed this" on the very next run — and
|
||||
// the provider's copy gets renamed to the cleaned version, unasked.
|
||||
//
|
||||
// Compared through the same sanitiser the local name went through, or a
|
||||
// provider name TREK had to clean up (a slash, a control character, a
|
||||
// leading dot) reads as "TREK renamed this" on the very next run, and the
|
||||
// provider's copy gets renamed to the cleaned version, unasked. For the
|
||||
// same reason TREK takes a new name in its cleaned form, the one a download
|
||||
// would have given it: a Paperless title with a slash in it used to land
|
||||
// in the file manager as it was, and the next run read that as a rename
|
||||
// made in TREK and sent it upstream again.
|
||||
const incoming = sanitizeIncomingName(r.name);
|
||||
if (l && incoming !== l.name) {
|
||||
const agreed = it.remoteName === null ? null : sanitizeIncomingName(it.remoteName);
|
||||
const providerRenamed = agreed !== null && sanitizeIncomingName(r.name) !== agreed;
|
||||
const providerRenamed = agreed !== null && incoming !== agreed;
|
||||
const localRenamed = agreed !== null && l.name !== agreed;
|
||||
|
||||
if (providerRenamed && !localRenamed && direction !== 'push') {
|
||||
add({ kind: 'rename_local', itemId: it.id, fileId: l.fileId, name: r.name });
|
||||
add({ kind: 'rename_local', itemId: it.id, fileId: l.fileId, name: incoming });
|
||||
continue;
|
||||
}
|
||||
if (localRenamed && !providerRenamed && direction !== 'pull') {
|
||||
@@ -432,14 +554,14 @@ export function planReconcile(input: ReconcileInput): ReconcilePlan {
|
||||
}
|
||||
if (providerRenamed && localRenamed) {
|
||||
// Both sides renamed. Nothing here can pick the right one on its own,
|
||||
// and picking wrong loses a name somebody chose — so it follows the
|
||||
// and picking wrong loses a name somebody chose, so it follows the
|
||||
// binding's answer, and goes to a person when there is none.
|
||||
settle(it, r, l);
|
||||
settleName(it, r, l, incoming);
|
||||
continue;
|
||||
}
|
||||
// No stored name to arbitrate with (a row from before this column, or a
|
||||
// freshly paired document): follow the binding's direction.
|
||||
if (direction === 'pull') add({ kind: 'rename_local', itemId: it.id, fileId: l.fileId, name: r.name });
|
||||
if (direction === 'pull') add({ kind: 'rename_local', itemId: it.id, fileId: l.fileId, name: incoming });
|
||||
else if (direction === 'push') add({ kind: 'rename_remote', itemId: it.id, remoteId: it.remoteId, name: l.name });
|
||||
else add({ kind: 'touch', itemId: it.id, remote: r });
|
||||
continue;
|
||||
@@ -452,21 +574,8 @@ export function planReconcile(input: ReconcileInput): ReconcilePlan {
|
||||
if (direction !== 'push') {
|
||||
for (const r of remote) {
|
||||
if (r.isDeleted) continue;
|
||||
const existing = itemsByRemote.get(r.remoteId);
|
||||
if (existing) continue;
|
||||
|
||||
// Without stable ids a rename reads as "old one gone, new one appeared".
|
||||
// Re-pair on content instead of downloading the same bytes again and
|
||||
// leaving a phantom behind.
|
||||
if (!stableRemoteIds && r.contentHash) {
|
||||
const reuse = items.find(
|
||||
(i) => i.contentSha256 === r.contentHash && i.remoteId !== null && !remoteById.has(i.remoteId),
|
||||
);
|
||||
if (reuse) {
|
||||
add({ kind: 'touch', itemId: reuse.id, remote: r });
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// A copy that was only renamed or moved is paired already (followMoves).
|
||||
if (itemsByRemote.has(r.remoteId)) continue;
|
||||
add({ kind: 'pull', remote: r, itemId: null });
|
||||
}
|
||||
}
|
||||
@@ -478,7 +587,7 @@ export function planReconcile(input: ReconcileInput): ReconcilePlan {
|
||||
const it = itemsByFile.get(l.fileId);
|
||||
if (it?.remoteId && remoteById.has(it.remoteId)) continue;
|
||||
// A row that has a remoteId which is no longer listed is handled below as
|
||||
// "missing", not re-uploaded — otherwise a broken listing duplicates the
|
||||
// "missing", not re-uploaded. Otherwise a broken listing duplicates the
|
||||
// entire trip upstream.
|
||||
if (it?.remoteId) continue;
|
||||
add({ kind: 'push', local: l, itemId: it?.id ?? null, remoteId: null });
|
||||
@@ -493,7 +602,54 @@ export function planReconcile(input: ReconcileInput): ReconcilePlan {
|
||||
}
|
||||
}
|
||||
|
||||
return { actions, massDeleteGuardTripped: guardTripped, missingCount: vanished.length };
|
||||
return { actions, massDeleteGuardTripped: guardTripped, missingCount: vanished.length + foundGone };
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this run has to list the provider in full, whatever its cursor says.
|
||||
*
|
||||
* A file back out of TREK's trash whose copy TREK did not bin can only be
|
||||
* settled by a listing that shows whether that copy is still there (see
|
||||
* `reopen`). An unchanged cursor gives the planner nothing to read, and on a
|
||||
* quiet binding the cursor stays unchanged until somebody touches the folder,
|
||||
* so the restore would wait that long.
|
||||
*/
|
||||
export function needsFullListing(items: readonly SyncItemState[], local: readonly LocalDocument[]): boolean {
|
||||
const localById = new Map(local.map((l) => [l.fileId, l]));
|
||||
return items.some((it) => it.remoteTrashedAt === null && isBackFromTrash(it, localById));
|
||||
}
|
||||
|
||||
/** A deletion TREK acted on whose file has since come back out of TREK's trash. */
|
||||
function isBackFromTrash(it: SyncItemState, localById: ReadonlyMap<number, LocalDocument>): boolean {
|
||||
if (it.state !== 'local_deleted' || !it.remoteId || it.fileId === null) return false;
|
||||
const l = localById.get(it.fileId);
|
||||
return !!l && !l.deletedAt;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a listing entry is recognisably the copy a pairing had, judged by
|
||||
* content rather than by id.
|
||||
*
|
||||
* A hash on both sides settles it either way. Without one (Synology offers
|
||||
* none), size and modification time together: a rename or a move leaves both
|
||||
* alone, and an edit does not.
|
||||
*/
|
||||
function sameCopy(it: SyncItemState, r: RemoteDocument): boolean {
|
||||
if (r.contentHash !== null && it.contentSha256 !== null) return r.contentHash === it.contentSha256;
|
||||
return r.size !== null && r.remoteModifiedAt !== null
|
||||
&& r.size === it.remoteSize && r.remoteModifiedAt === it.remoteModifiedAt;
|
||||
}
|
||||
|
||||
/** The copy is listed under the name both sides last agreed on: moved, not renamed. */
|
||||
function keptName(it: SyncItemState, r: RemoteDocument): boolean {
|
||||
return it.remoteName !== null && sanitizeIncomingName(it.remoteName) === sanitizeIncomingName(r.name);
|
||||
}
|
||||
|
||||
/** The only candidate, or else the only one that kept its name; otherwise none. */
|
||||
function soleMatch<T>(candidates: T[], keptItsName: (candidate: T) => boolean): T | null {
|
||||
if (candidates.length === 1) return candidates[0];
|
||||
const named = candidates.filter(keptItsName);
|
||||
return named.length === 1 ? named[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -502,11 +658,16 @@ export function planReconcile(input: ReconcileInput): ReconcilePlan {
|
||||
* `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.
|
||||
* policy never ran. A first download that failed has no file either, and is
|
||||
* retried, so `error` and `pending` count only with an agreed hash: that is
|
||||
* written by a pull or a push that landed and by nothing else, and a failed
|
||||
* update keeps the file it had. A row shelved in `error` that the person then
|
||||
* deleted and purged is a deletion, not a document to fetch again.
|
||||
*/
|
||||
function lostItsFile(it: SyncItemState): boolean {
|
||||
return it.fileId === null && (it.state === 'synced' || it.state === 'conflict' || it.state === 'remote_missing');
|
||||
if (it.fileId !== null) return false;
|
||||
if (it.state === 'synced' || it.state === 'conflict' || it.state === 'remote_missing') return true;
|
||||
return (it.state === 'error' || it.state === 'pending') && it.contentSha256 !== null;
|
||||
}
|
||||
|
||||
/** Backoff lookup that saturates instead of running off the end of the curve. */
|
||||
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
backoffSeconds,
|
||||
isAllowedByOperator,
|
||||
isBlockedName,
|
||||
needsFullListing,
|
||||
newTrekDocUid,
|
||||
planReconcile,
|
||||
sanitizeIncomingName,
|
||||
@@ -37,6 +38,20 @@ import {
|
||||
type SyncItemState,
|
||||
} from './doc-sync.helpers';
|
||||
|
||||
/**
|
||||
* The SET clause for a pairing whose copy is listed again: `touch`, `relocate`
|
||||
* and both renames.
|
||||
*
|
||||
* Only `touch` lifted the missing flag, so a copy found again under a new name
|
||||
* (a rename, or a move where ids are paths) stayed on the issues list for one
|
||||
* more run. `error` is left alone on purpose: that row either never got its
|
||||
* bytes or holds older ones than the provider, and calling it synced would hide
|
||||
* it for good. So is a row without a file, which is a deletion the planner
|
||||
* turns into `local_deleted`.
|
||||
*/
|
||||
const FOUND_AGAIN = `remote_missing_at = NULL,
|
||||
state = CASE WHEN state = 'remote_missing' AND file_id IS NOT NULL THEN 'synced' ELSE state END`;
|
||||
|
||||
/**
|
||||
* The reconciler: it executes what `planReconcile` decided, and does nothing
|
||||
* else of consequence.
|
||||
@@ -46,7 +61,7 @@ import {
|
||||
* deletion at all; Papra does not bump `updatedAt` when a tag changes (measured,
|
||||
* not assumed); Paperless's bulk edit changes documents without touching
|
||||
* `modified`; and Synology FileStation has no change feed whatsoever. A webhook
|
||||
* is therefore only ever "look now", never a source of truth — the same
|
||||
* is therefore only ever "look now", never a source of truth: the same
|
||||
* conclusion AirTrail and Dawarich reached for their own providers, written
|
||||
* down in dawarich-sync.service.ts.
|
||||
*
|
||||
@@ -82,7 +97,7 @@ export class DocSyncService {
|
||||
*
|
||||
* The second sort key is what makes the limit fair. A successful run clears
|
||||
* `next_attempt_at`, so on a healthy instance every binding sorts equal on the
|
||||
* first key and SQLite falls back to insertion order — which means the twenty
|
||||
* first key and SQLite falls back to insertion order, which means the twenty
|
||||
* oldest bindings were picked on every tick and everything created after them
|
||||
* was never synced automatically at all. Found on a dev database with 170
|
||||
* bindings, where a freshly created one was still untouched minutes later
|
||||
@@ -156,7 +171,6 @@ export class DocSyncService {
|
||||
|
||||
const ref = this.config.toRef(conn);
|
||||
const scope = this.config.toScopeRef(link);
|
||||
if (opts.full) scope.cursor = null;
|
||||
|
||||
// A binding whose folder or tag is gone needs a human, not a retry: the
|
||||
// alternative is re-creating someone's deleted folder and filling it again.
|
||||
@@ -167,6 +181,12 @@ export class DocSyncService {
|
||||
return { state: code === 'scope_missing' ? 'scope_lost' : 'failed', pulled: 0, pushed: 0, conflicts: 0, missing: 0, errorCode: code };
|
||||
}
|
||||
|
||||
// Read before the listing because they decide how much of it is needed: a
|
||||
// file restored in TREK waits for a listing that shows its copy.
|
||||
const items = this.loadItems(link.id);
|
||||
const local = this.loadLocalDocuments(link);
|
||||
if (opts.full || needsFullListing(items, local)) scope.cursor = null;
|
||||
|
||||
const listing = await provider.list(ref, scope);
|
||||
if (docFailed(listing)) {
|
||||
const state = listing.error.code === 'unauthorized' ? 'needs_reauth' : 'failed';
|
||||
@@ -174,9 +194,6 @@ export class DocSyncService {
|
||||
return { state, pulled: 0, pushed: 0, conflicts: 0, missing: 0, errorCode: listing.error.code };
|
||||
}
|
||||
|
||||
const items = this.loadItems(link.id);
|
||||
const local = this.loadLocalDocuments(link);
|
||||
|
||||
const plan = planReconcile({
|
||||
items,
|
||||
remote: listing.data.documents,
|
||||
@@ -241,11 +258,31 @@ export class DocSyncService {
|
||||
case 'push':
|
||||
case 'push_update':
|
||||
return this.push(action.local, 'itemId' in action ? action.itemId : null, 'remoteId' in action ? action.remoteId : null, ctx);
|
||||
case 'relocate': {
|
||||
// The id and the version only. `remote_name` is the rename arbiter and
|
||||
// keeps the agreed name; the rename, if there was one, is the planner's
|
||||
// next action for this row. That may be a rename rather than a `touch`,
|
||||
// so a copy on record as missing is lifted here already.
|
||||
this.db.connection
|
||||
.prepare(
|
||||
`UPDATE document_sync_items
|
||||
SET remote_id = ?, remote_version = ?,
|
||||
remote_size = COALESCE(?, remote_size), remote_modified_at = COALESCE(?, remote_modified_at),
|
||||
${FOUND_AGAIN}, last_seen_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.run(action.remote.remoteId, action.remote.remoteVersion, action.remote.size, action.remote.remoteModifiedAt, action.itemId);
|
||||
return 'ok';
|
||||
}
|
||||
case 'rename_remote': {
|
||||
const res = await ctx.provider.rename(ctx.ref, ctx.scope, action.remoteId, action.name);
|
||||
if (docFailed(res)) return res.error.code;
|
||||
this.db.connection
|
||||
.prepare('UPDATE document_sync_items SET remote_name = ?, remote_version = ?, last_seen_at = CURRENT_TIMESTAMP WHERE id = ?')
|
||||
.prepare(
|
||||
`UPDATE document_sync_items
|
||||
SET remote_name = ?, remote_version = ?, ${FOUND_AGAIN}, last_seen_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.run(action.name, res.data.remoteVersion, action.itemId);
|
||||
return 'ok';
|
||||
}
|
||||
@@ -255,7 +292,7 @@ export class DocSyncService {
|
||||
file: this.files.getFileById(action.fileId, ctx.link.trip_id),
|
||||
});
|
||||
this.db.connection
|
||||
.prepare('UPDATE document_sync_items SET remote_name = ?, last_seen_at = CURRENT_TIMESTAMP WHERE id = ?')
|
||||
.prepare(`UPDATE document_sync_items SET remote_name = ?, ${FOUND_AGAIN}, last_seen_at = CURRENT_TIMESTAMP WHERE id = ?`)
|
||||
.run(action.name, action.itemId);
|
||||
return 'ok';
|
||||
}
|
||||
@@ -298,13 +335,17 @@ export class DocSyncService {
|
||||
return 'ok';
|
||||
}
|
||||
case 'local_restored': {
|
||||
// A copy found gone is flagged the way `mark_remote_missing` flags one,
|
||||
// and recovers the same way: a later `touch` lifts it once it is back.
|
||||
const missing = action.missing === true;
|
||||
this.db.connection
|
||||
.prepare(
|
||||
`UPDATE document_sync_items
|
||||
SET state = 'synced', remote_missing_at = NULL, remote_trashed_at = NULL, last_seen_at = CURRENT_TIMESTAMP
|
||||
SET state = ?, remote_missing_at = CASE WHEN ? = 1 THEN CURRENT_TIMESTAMP ELSE NULL END,
|
||||
remote_trashed_at = NULL, last_seen_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.run(action.itemId);
|
||||
.run(missing ? 'remote_missing' : 'synced', missing ? 1 : 0, action.itemId);
|
||||
return 'ok';
|
||||
}
|
||||
case 'detach': {
|
||||
@@ -329,10 +370,13 @@ export class DocSyncService {
|
||||
return 'ok';
|
||||
}
|
||||
case 'touch': {
|
||||
// `error` is NOT cleared here. A row in that state has no bytes in TREK
|
||||
// — the download failed — and marking it synced would hide a document
|
||||
// that nobody will ever fetch again. Only a `remote_missing` row, whose
|
||||
// file is present and whose provider copy came back, recovers this way.
|
||||
// `error` is NOT cleared here (see FOUND_AGAIN). Only a `remote_missing`
|
||||
// row, whose file is present and whose provider copy came back,
|
||||
// recovers this way.
|
||||
//
|
||||
// Size and modification time are kept current as well: where the id is
|
||||
// the path, they are how the planner recognises this copy once it has
|
||||
// been renamed or moved.
|
||||
this.db.connection
|
||||
.prepare(
|
||||
`UPDATE document_sync_items
|
||||
@@ -340,11 +384,15 @@ export class DocSyncService {
|
||||
remote_version = COALESCE(?, remote_version),
|
||||
remote_name = COALESCE(?, remote_name),
|
||||
remote_id = COALESCE(?, remote_id),
|
||||
remote_missing_at = NULL,
|
||||
state = CASE WHEN state = 'remote_missing' AND file_id IS NOT NULL THEN 'synced' ELSE state END
|
||||
remote_size = COALESCE(?, remote_size),
|
||||
remote_modified_at = COALESCE(?, remote_modified_at),
|
||||
${FOUND_AGAIN}
|
||||
WHERE id = ?`,
|
||||
)
|
||||
.run(action.remote?.remoteVersion ?? null, action.remote?.name ?? null, action.remote?.remoteId ?? null, action.itemId);
|
||||
.run(
|
||||
action.remote?.remoteVersion ?? null, action.remote?.name ?? null, action.remote?.remoteId ?? null,
|
||||
action.remote?.size ?? null, action.remote?.remoteModifiedAt ?? null, action.itemId,
|
||||
);
|
||||
return 'ok';
|
||||
}
|
||||
default:
|
||||
@@ -362,7 +410,7 @@ export class DocSyncService {
|
||||
|
||||
// The same defences an upload goes through. A provider folder routinely
|
||||
// holds .svg and .html, and TREK serves downloads inline with a
|
||||
// Content-Type derived from the extension — letting those through would be
|
||||
// Content-Type derived from the extension: letting those through would be
|
||||
// stored XSS. Rejected documents become a visible row, never a silent skip.
|
||||
if (isBlockedName(name) || !isAllowedByOperator(name, this.allowedTypes.get())) {
|
||||
this.upsertItem(ctx.link, { itemId, remote, state: 'rejected_type', errorCode: 'unsupported_type' });
|
||||
@@ -427,7 +475,7 @@ export class DocSyncService {
|
||||
/**
|
||||
* The file row, the retirement of the copy it replaces and the pairing are
|
||||
* one fact, so they are one transaction. Written separately, a crash
|
||||
* between them leaves a trip file with no sync item — which the next run
|
||||
* between them leaves a trip file with no sync item, which the next run
|
||||
* reads as a document TREK gained and pushes straight back up, turning one
|
||||
* upstream edit into two documents on both sides.
|
||||
*
|
||||
@@ -457,7 +505,7 @@ export class DocSyncService {
|
||||
return { created: file, retired: gone ? (supersededId as number) : null };
|
||||
});
|
||||
|
||||
// Announced only once it is committed — a rolled-back transaction that had
|
||||
// Announced only once it is committed: a rolled-back transaction that had
|
||||
// already told every client the file exists cannot be taken back.
|
||||
if (retired !== null) this.realtime.broadcast(ctx.link.trip_id, 'file:deleted', { fileId: retired });
|
||||
this.realtime.broadcast(ctx.link.trip_id, 'file:created', { file: created });
|
||||
@@ -489,7 +537,7 @@ export class DocSyncService {
|
||||
if (!file) return 'not_found';
|
||||
// Re-read rather than trust the plan: a run walks a whole folder, and a
|
||||
// document somebody deleted in the meantime would otherwise still be
|
||||
// uploaded — a delete that looks ignored, and a document that reappears
|
||||
// uploaded: a delete that looks ignored, and a document that reappears
|
||||
// upstream after the person watched it go.
|
||||
if ((file as { deleted_at?: string | null }).deleted_at) return 'not_found';
|
||||
|
||||
@@ -528,8 +576,8 @@ export class DocSyncService {
|
||||
// A provider may answer a push with a document it already held rather than
|
||||
// a new one: Papra deduplicates identical bytes, and `deduplicated` says so.
|
||||
// Two trip files with the same content therefore push to ONE document, and
|
||||
// the unique index on (link_id, remote_id) would abort this run — and every
|
||||
// run after it — with a constraint error. The upload happened, so nothing is
|
||||
// the unique index on (link_id, remote_id) would abort this run (and every
|
||||
// run after it) with a constraint error. The upload happened, so nothing is
|
||||
// lost; what must not happen is the pairing moving off the file that owns it.
|
||||
const heldBy = this.pairingOwner(ctx.link.id, res.data.remoteId, itemId);
|
||||
if (heldBy !== null) {
|
||||
@@ -553,10 +601,15 @@ export class DocSyncService {
|
||||
pushedSha256: sha256,
|
||||
// The name both sides now agree on. Leaving it null meant the first
|
||||
// `touch` filled the arbiter with whatever the provider had made of the
|
||||
// name — Paperless stores a title and keeps its own filename — and the
|
||||
// name (Paperless stores a title and keeps its own filename), and the
|
||||
// run after that read the difference as TREK having renamed the document
|
||||
// and renamed the provider's copy to match, unasked.
|
||||
remoteNameOverride: local.name,
|
||||
// What the copy looks like at the provider, for the same reason as the
|
||||
// name: on a provider whose id is the path, a rename before the next run
|
||||
// could otherwise not be told from a deletion.
|
||||
remoteSize: local.size,
|
||||
remoteModifiedAt: res.data.remoteModifiedAt,
|
||||
trekDocUid: uid,
|
||||
errorCode: null,
|
||||
});
|
||||
@@ -570,7 +623,7 @@ export class DocSyncService {
|
||||
* and pressing "Sync now" is that person saying otherwise: they have taken the
|
||||
* bad document out of the folder, cleared the quota, or fixed the permission,
|
||||
* and the only way to find out is to try again. The scheduler never calls
|
||||
* this — automatic retries are exactly what the limit exists to stop.
|
||||
* this: automatic retries are exactly what the limit exists to stop.
|
||||
*/
|
||||
retryShelvedItems(linkId: number): void {
|
||||
this.db.connection
|
||||
@@ -587,8 +640,8 @@ export class DocSyncService {
|
||||
private loadItems(linkId: number): SyncItemState[] {
|
||||
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, remote_trashed_at
|
||||
`SELECT id, file_id, trek_doc_uid, remote_id, remote_version, remote_name, remote_size, remote_modified_at,
|
||||
content_sha256, 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>>;
|
||||
@@ -599,6 +652,8 @@ export class DocSyncService {
|
||||
remoteId: r.remote_id === null ? null : String(r.remote_id),
|
||||
remoteVersion: r.remote_version === null ? null : String(r.remote_version),
|
||||
remoteName: r.remote_name === null || r.remote_name === undefined ? null : String(r.remote_name),
|
||||
remoteSize: r.remote_size === null ? null : Number(r.remote_size),
|
||||
remoteModifiedAt: r.remote_modified_at === null ? null : String(r.remote_modified_at),
|
||||
contentSha256: r.content_sha256 === null ? null : String(r.content_sha256),
|
||||
pushedSha256: r.pushed_sha256 === null ? null : String(r.pushed_sha256),
|
||||
state: String(r.state),
|
||||
@@ -613,14 +668,14 @@ export class DocSyncService {
|
||||
* The trip's own documents, minus the ones that are not really documents.
|
||||
*
|
||||
* Chat attachments (`message_id`) and note attachments (`note_id`) live in
|
||||
* the same table but belong to a conversation, not to the trip's paperwork —
|
||||
* the same table but belong to a conversation, not to the trip's paperwork:
|
||||
* syncing them would push someone's chat screenshot into a shared Paperless.
|
||||
*
|
||||
* The set is per TRIP, not per binding, and that is a property rather than an
|
||||
* oversight: a binding mirrors the trip's documents, so two bindings on one
|
||||
* trip each hold a full copy. That is the right answer for "the same papers,
|
||||
* in both my Nextcloud and my Paperless", and the wrong one for "receipts to
|
||||
* Paperless, everything else to Nextcloud" — splitting a trip across bindings
|
||||
* Paperless, everything else to Nextcloud": splitting a trip across bindings
|
||||
* would need a per-document assignment that nothing in the UI offers yet.
|
||||
*/
|
||||
private loadLocalDocuments(link: LinkRow): LocalDocument[] {
|
||||
@@ -639,7 +694,7 @@ export class DocSyncService {
|
||||
// Deliberately null, and not the agreed hash out of document_sync_items:
|
||||
// reading that column here would compare it against itself, and
|
||||
// `localChanged` in the planner could then never be true. TREK has no way
|
||||
// to replace a document's bytes — an upload creates a new row — so there
|
||||
// to replace a document's bytes (an upload creates a new row), so there
|
||||
// is no local change to detect, and claiming otherwise would be worse
|
||||
// than admitting it. The day the file manager grows a replace, this needs
|
||||
// a real hash column on trip_files, filled at upload time.
|
||||
@@ -681,6 +736,9 @@ export class DocSyncService {
|
||||
/** The agreed name, where the caller knows it better than the listing does. */
|
||||
remoteNameOverride?: string;
|
||||
remoteVersion?: string;
|
||||
/** Size and modification time at the provider, where there is no listing entry to take them from. */
|
||||
remoteSize?: number;
|
||||
remoteModifiedAt?: string | null;
|
||||
fileId?: number;
|
||||
state: string;
|
||||
errorCode?: DocsyncErrorCode | null;
|
||||
@@ -692,6 +750,8 @@ export class DocSyncService {
|
||||
const remoteId = patch.remoteIdOverride ?? patch.remote?.remoteId ?? null;
|
||||
const remoteVersion = patch.remoteVersion ?? patch.remote?.remoteVersion ?? null;
|
||||
const remoteName = patch.remoteNameOverride ?? patch.remote?.name ?? null;
|
||||
const remoteSize = patch.remoteSize ?? patch.remote?.size ?? null;
|
||||
const remoteModifiedAt = patch.remoteModifiedAt ?? patch.remote?.remoteModifiedAt ?? null;
|
||||
const failed = patch.state === 'error';
|
||||
|
||||
if (patch.itemId !== null) {
|
||||
@@ -699,7 +759,7 @@ export class DocSyncService {
|
||||
* The attempt counter, read before it is written.
|
||||
*
|
||||
* Two things needed it. The backoff was computed as
|
||||
* `backoffSeconds(curve, 1)` — always the first step, so three of the
|
||||
* `backoffSeconds(curve, 1)`, always the first step, so three of the
|
||||
* four steps in the curve were unreachable and a provider that was down
|
||||
* got asked again at the same short interval. And the counter only ever
|
||||
* grew: a row that failed once a month reached the limit after six months
|
||||
@@ -737,7 +797,7 @@ export class DocSyncService {
|
||||
.run(
|
||||
patch.state, patch.errorCode ?? null, patch.fileId ?? null,
|
||||
remoteId, remoteVersion,
|
||||
remoteName, patch.remote?.size ?? null, patch.remote?.remoteModifiedAt ?? null,
|
||||
remoteName, remoteSize, remoteModifiedAt,
|
||||
patch.contentSha256 ?? null, patch.pushedSha256 ?? null,
|
||||
attempts,
|
||||
failed ? 1 : 0, attempts, ITEM_MAX_ATTEMPTS,
|
||||
@@ -770,8 +830,8 @@ export class DocSyncService {
|
||||
)
|
||||
.run(
|
||||
link.id, link.trip_id, patch.fileId ?? null, patch.trekDocUid ?? newTrekDocUid(),
|
||||
remoteId, remoteName, remoteVersion, patch.remote?.size ?? null,
|
||||
patch.remote?.remoteModifiedAt ?? null, patch.contentSha256 ?? null, patch.pushedSha256 ?? null,
|
||||
remoteId, remoteName, remoteVersion, remoteSize,
|
||||
remoteModifiedAt, patch.contentSha256 ?? null, patch.pushedSha256 ?? null,
|
||||
patch.state, patch.errorCode ?? null, failed ? 1 : 0,
|
||||
failed ? 1 : 0, backoffSeconds(ITEM_BACKOFF_SECONDS, 1), // a new row is always at its first failure
|
||||
patch.state,
|
||||
@@ -817,7 +877,7 @@ export class DocSyncService {
|
||||
*
|
||||
* `tripId` is not decoration: the route authorises the caller against the trip
|
||||
* in its URL, and without checking the row against the same trip an owner of
|
||||
* any trip could resolve a conflict in somebody else's — the id is a plain
|
||||
* any trip could resolve a conflict in somebody else's: the id is a plain
|
||||
* integer and nothing else tied the two together. Same rule as everywhere in
|
||||
* this codebase: every referenced id must exist AND belong to the same trip.
|
||||
*/
|
||||
@@ -826,7 +886,7 @@ export class DocSyncService {
|
||||
* Rename the provider's copy, outside a run.
|
||||
*
|
||||
* Only `resolveConflict` needs this: every other rename is planned and
|
||||
* executed by a run. A failure is not fatal here — the row is left for the
|
||||
* executed by a run. A failure is not fatal here: the row is left for the
|
||||
* next run to sort out rather than the owner's choice being refused.
|
||||
*/
|
||||
private async renameRemoteTo(link: LinkRow, remoteId: string, name: string, itemId: number): Promise<void> {
|
||||
@@ -856,7 +916,7 @@ export class DocSyncService {
|
||||
* The name has to be decided too, not only the bytes.
|
||||
*
|
||||
* The only conflict the planner can actually raise today is a double
|
||||
* rename, and that is arbitrated entirely by `remote_name` — the name both
|
||||
* rename, and that is arbitrated entirely by `remote_name`, the name both
|
||||
* sides last agreed on. Clearing a version marker leaves all three names
|
||||
* exactly as they were, so the next run compared them again, found both
|
||||
* sides changed again, and wrote the conflict straight back. Whatever the
|
||||
@@ -884,7 +944,7 @@ export class DocSyncService {
|
||||
// Carry the name over as well, here rather than by moving the arbiter:
|
||||
// TREK winning means the provider's copy takes TREK's name, and the only
|
||||
// way to say that through `remote_name` would be to write the provider's
|
||||
// CURRENT name into it — which this method does not know without asking.
|
||||
// CURRENT name into it, which this method does not know without asking.
|
||||
if (localName && item.remote_id && localName !== item.remote_name) {
|
||||
await this.renameRemoteTo(link, String(item.remote_id), localName, itemId);
|
||||
}
|
||||
@@ -894,11 +954,14 @@ export class DocSyncService {
|
||||
.run(itemId);
|
||||
// Take the local rename back to the agreed name. Only the provider's
|
||||
// rename is then left standing, and the next run follows it the ordinary
|
||||
// way — through the planner, with no special case in it.
|
||||
if (item.file_id !== null && item.remote_name && localName !== item.remote_name) {
|
||||
// way, through the planner, with no special case in it. Cleaned the way
|
||||
// a download cleans it: the planner compares cleaned names, so a raw one
|
||||
// with a slash in it would read as TREK renaming the file all over again.
|
||||
const agreedName = item.remote_name ? sanitizeIncomingName(String(item.remote_name)) : null;
|
||||
if (item.file_id !== null && agreedName && localName !== agreedName) {
|
||||
this.db.connection
|
||||
.prepare('UPDATE trip_files SET original_name = ? WHERE id = ?')
|
||||
.run(item.remote_name, item.file_id);
|
||||
.run(agreedName, item.file_id);
|
||||
}
|
||||
} else {
|
||||
// Detach the pairing and let the next run pull the provider copy as a new
|
||||
@@ -913,7 +976,7 @@ export class DocSyncService {
|
||||
|
||||
/**
|
||||
* The documents a person has to decide about: conflicts, refusals and things
|
||||
* that vanished upstream. Deliberately not "everything not synced" — a row
|
||||
* that vanished upstream. Deliberately not "everything not synced": a row
|
||||
* waiting for its turn is not a problem anyone should be shown.
|
||||
*/
|
||||
issues(tripId: number): Array<Record<string, unknown>> {
|
||||
@@ -941,7 +1004,7 @@ export class DocSyncService {
|
||||
* What each side is actually holding, per binding.
|
||||
*
|
||||
* The UI showed the last run's transfer counts, which are zero on a binding
|
||||
* that is already in step — so the interesting screen said nothing. These
|
||||
* that is already in step, so the interesting screen said nothing. These
|
||||
* are the standing numbers instead: how many documents this trip has, how
|
||||
* many of them the store has, and how many are only on one side.
|
||||
*/
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
backoffSeconds,
|
||||
isAllowedByOperator,
|
||||
isBlockedName,
|
||||
needsFullListing,
|
||||
planReconcile,
|
||||
sanitizeIncomingName,
|
||||
type LocalDocument,
|
||||
@@ -13,8 +14,8 @@ import type { RemoteDocument } from '../../../../src/nest/doc-sync/document-prov
|
||||
/**
|
||||
* The planner decides everything that matters about two-way sync, and it does
|
||||
* it without a database, a container or a network. That is the point of having
|
||||
* it: the cases that are painful to reproduce against a live provider — an
|
||||
* unmounted share, a rename with no stable id, TREK's own write echoing back —
|
||||
* it: the cases that are painful to reproduce against a live provider (an
|
||||
* unmounted share, a rename with no stable id, TREK's own write echoing back)
|
||||
* are cheap to state here as data.
|
||||
*/
|
||||
|
||||
@@ -47,6 +48,8 @@ const item = (over: Partial<SyncItemState> = {}): SyncItemState => ({
|
||||
remoteId: 'r1',
|
||||
remoteVersion: 'v1',
|
||||
remoteName: 'boarding.pdf',
|
||||
remoteSize: null,
|
||||
remoteModifiedAt: null,
|
||||
contentSha256: 'aaa',
|
||||
pushedSha256: null,
|
||||
state: 'synced',
|
||||
@@ -156,22 +159,6 @@ describe('planReconcile', () => {
|
||||
expect(p.actions.filter((a) => a.kind === 'mark_remote_missing')).toHaveLength(0);
|
||||
});
|
||||
|
||||
/**
|
||||
* Synology FileStation has no stable file id, so a rename upstream looks like
|
||||
* a deletion plus a new file. Re-pairing on content avoids downloading the
|
||||
* same bytes again and leaving a phantom behind.
|
||||
*/
|
||||
it('re-pairs a renamed document when the provider has no stable ids', () => {
|
||||
const p = plan({
|
||||
items: [item({ remoteId: '/trek/old.pdf', contentSha256: 'hash-x' })],
|
||||
remote: [remote({ remoteId: '/trek/new.pdf', contentHash: 'hash-x' })],
|
||||
local: [local()],
|
||||
stableRemoteIds: false,
|
||||
});
|
||||
expect(p.actions.some((a) => a.kind === 'pull')).toBe(false);
|
||||
expect(p.actions.some((a) => a.kind === 'touch')).toBe(true);
|
||||
});
|
||||
|
||||
/**
|
||||
* Which side renamed is decided against the name both sides last agreed on.
|
||||
* Reading a `both` binding as "TREK always wins" renamed the provider's copy
|
||||
@@ -346,8 +333,8 @@ describe('backoffSeconds', () => {
|
||||
*
|
||||
* Found in a live database at 46 attempts against a limit of 6: the counter and
|
||||
* the backoff were written on every failure and never consulted when planning
|
||||
* the next run, so a document a provider refuses — a file it reads as corrupt,
|
||||
* a type it will not take, a full quota — was re-uploaded on every cron tick
|
||||
* the next run, so a document a provider refuses (a file it reads as corrupt,
|
||||
* a type it will not take, a full quota) was re-uploaded on every cron tick
|
||||
* for as long as it existed.
|
||||
*/
|
||||
describe('planReconcile > shelving a row that keeps failing', () => {
|
||||
@@ -416,7 +403,7 @@ describe('planReconcile > shelving a row that keeps failing', () => {
|
||||
it('leaves a shelved row alone even when its provider copy is gone too', () => {
|
||||
// "Vanished" is only said about a row that was synced last run, and a row is
|
||||
// only shelved while it is in error, so these two never describe the same
|
||||
// row — nothing is planned for it either way.
|
||||
// row. Nothing is planned for it either way.
|
||||
const p = plan({
|
||||
items: [item({ id: 30, attempts: 99, state: 'error', remoteId: 'r-gone' })],
|
||||
remote: [],
|
||||
@@ -434,7 +421,7 @@ describe('planReconcile > shelving a row that keeps failing', () => {
|
||||
* they then return an empty document list. The core ignored the flag and read
|
||||
* that emptiness as a folder somebody had emptied: from the third run of every
|
||||
* idle Nextcloud or OpenCloud binding onward, every run ended in the mass-delete
|
||||
* guard — `partial`, `missing: 6`, forever, until the failure counter opened the
|
||||
* guard: `partial`, `missing: 6`, forever, until the failure counter opened the
|
||||
* circuit and the scheduler dropped the binding entirely. Reproduced against a
|
||||
* live Nextcloud before this was written.
|
||||
*/
|
||||
@@ -600,8 +587,8 @@ describe('planReconcile > a pairing whose download never landed', () => {
|
||||
/**
|
||||
* Names are compared through the same sanitiser the local one went through.
|
||||
*
|
||||
* A provider name TREK had to clean up — a slash, a control character, a
|
||||
* leading dot — otherwise reads as "TREK renamed this document" on the very
|
||||
* A provider name TREK had to clean up (a slash, a control character, a
|
||||
* leading dot) otherwise reads as "TREK renamed this document" on the very
|
||||
* next run, and the provider's copy is renamed to the cleaned version without
|
||||
* anybody asking for it.
|
||||
*/
|
||||
@@ -623,6 +610,325 @@ describe('planReconcile > a provider name TREK had to clean up', () => {
|
||||
});
|
||||
expect(p.actions.map(a => a.kind)).toEqual(['rename_local']);
|
||||
});
|
||||
|
||||
it('takes a new name into TREK the way a download would have written it, so it stays', () => {
|
||||
// Taken over raw, the next run compared the cleaned provider name with the
|
||||
// raw one in TREK, read the difference as a rename made in TREK, and sent
|
||||
// the name back upstream: a Paperless title with a colon, renamed to itself.
|
||||
const renamed = remote({ remoteId: 'r1', name: 'Hotel: Kyoto.pdf', remoteVersion: 'v1' });
|
||||
const first = plan({
|
||||
items: [item({ remoteName: 'b.pdf' })],
|
||||
remote: [renamed],
|
||||
local: [local({ fileId: 1, name: 'b.pdf' })],
|
||||
});
|
||||
expect(first.actions).toEqual([{ kind: 'rename_local', itemId: 10, fileId: 1, name: 'Hotel_ Kyoto.pdf' }]);
|
||||
|
||||
const second = plan({
|
||||
items: [item({ remoteName: 'Hotel_ Kyoto.pdf' })],
|
||||
remote: [renamed],
|
||||
local: [local({ fileId: 1, name: 'Hotel_ Kyoto.pdf' })],
|
||||
});
|
||||
expect(second.actions).toEqual([{ kind: 'touch', itemId: 10, remote: renamed }]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A rename upstream where the id is the path, which is Synology FileStation.
|
||||
*
|
||||
* It bounced. The upstream-only loop re-paired the renamed copy with a `touch`,
|
||||
* and `touch` wrote the provider's new name into the rename arbiter while the
|
||||
* trip file kept the old one: the next run read that as a rename made in TREK
|
||||
* and renamed the provider's copy back. The same run had flagged the pairing
|
||||
* missing too, because the vanished list was drawn up before the re-pairing.
|
||||
* Observed: run 1 [touch b.pdf, mark_remote_missing], run 2 [rename_remote a.pdf].
|
||||
*/
|
||||
describe('planReconcile > a rename upstream where the id is the path', () => {
|
||||
const at = '2026-09-18T10:00:00Z';
|
||||
const paired = (over: Partial<SyncItemState> = {}) => item({
|
||||
remoteId: '/trek/a.pdf', remoteVersion: 'va', remoteName: 'a.pdf', contentSha256: 'hash-a',
|
||||
remoteSize: 1024, remoteModifiedAt: at, ...over,
|
||||
});
|
||||
const listed = (over: Partial<RemoteDocument> = {}) => remote({
|
||||
remoteId: '/trek/b.pdf', name: 'b.pdf', remoteVersion: 'vb', contentHash: 'hash-a',
|
||||
size: 1024, remoteModifiedAt: at, ...over,
|
||||
});
|
||||
// No local hash, as in production: TREK has no way to change a file's bytes,
|
||||
// so the service never reports one (see loadLocalDocuments).
|
||||
const file = (over: Partial<LocalDocument> = {}) => local({ name: 'a.pdf', sha256: null, ...over });
|
||||
const pathIds = (over: Partial<Parameters<typeof planReconcile>[0]> = {}) => plan({ stableRemoteIds: false, ...over });
|
||||
const kinds = (p: ReturnType<typeof plan>) => p.actions.map(a => a.kind);
|
||||
|
||||
it('takes the new name into TREK and leaves it there on the next run', () => {
|
||||
const first = pathIds({ items: [paired()], remote: [listed()], local: [file()] });
|
||||
expect(first.actions).toEqual([
|
||||
{ kind: 'relocate', itemId: 10, remote: listed() },
|
||||
{ kind: 'rename_local', itemId: 10, fileId: 1, name: 'b.pdf' },
|
||||
]);
|
||||
|
||||
// What the executor leaves behind: the pairing on the new path, the agreed
|
||||
// name moved on by rename_local, the trip file renamed.
|
||||
const second = pathIds({
|
||||
items: [paired({ remoteId: '/trek/b.pdf', remoteVersion: 'vb', remoteName: 'b.pdf' })],
|
||||
remote: [listed()],
|
||||
local: [file({ name: 'b.pdf' })],
|
||||
});
|
||||
expect(second.actions).toEqual([{ kind: 'touch', itemId: 10, remote: listed() }]);
|
||||
});
|
||||
|
||||
it('ends the way it ends where ids are stable, apart from moving the pairing', () => {
|
||||
const pathKeyed = pathIds({ items: [paired()], remote: [listed()], local: [file()] });
|
||||
const stable = plan({
|
||||
items: [paired({ remoteId: '/trek/b.pdf', remoteVersion: 'vb' })],
|
||||
remote: [listed()],
|
||||
local: [file()],
|
||||
});
|
||||
expect(pathKeyed.actions.filter(a => a.kind !== 'relocate')).toEqual(stable.actions);
|
||||
});
|
||||
|
||||
it('does not flag the renamed copy as missing', () => {
|
||||
const p = pathIds({ items: [paired()], remote: [listed()], local: [file()] });
|
||||
expect(p.missingCount).toBe(0);
|
||||
expect(kinds(p)).not.toContain('mark_remote_missing');
|
||||
});
|
||||
|
||||
it('does not read a whole folder moved in one go as a mass deletion', () => {
|
||||
const rows = Array.from({ length: 10 }, (_, i) => paired({
|
||||
id: 100 + i, fileId: 100 + i, remoteId: `/trek/${i}.pdf`, remoteName: `${i}.pdf`, contentSha256: `hash-${i}`,
|
||||
}));
|
||||
const p = pathIds({
|
||||
items: rows,
|
||||
remote: rows.map((_, i) => listed({ remoteId: `/trek/2026/${i}.pdf`, name: `${i}.pdf`, contentHash: `hash-${i}` })),
|
||||
local: rows.map((r, i) => file({ fileId: r.fileId as number, name: `${i}.pdf` })),
|
||||
});
|
||||
expect(p.massDeleteGuardTripped).toBe(false);
|
||||
expect(p.missingCount).toBe(0);
|
||||
expect(kinds(p).filter(k => k === 'relocate')).toHaveLength(10);
|
||||
expect(kinds(p)).not.toContain('pull');
|
||||
});
|
||||
|
||||
it('follows a copy moved to another folder and leaves its name alone', () => {
|
||||
const moved = listed({ remoteId: '/trek/receipts/a.pdf', name: 'a.pdf' });
|
||||
const p = pathIds({ items: [paired()], remote: [moved], local: [file()] });
|
||||
expect(p.actions).toEqual([
|
||||
{ kind: 'relocate', itemId: 10, remote: moved },
|
||||
{ kind: 'touch', itemId: 10, remote: moved },
|
||||
]);
|
||||
});
|
||||
|
||||
it('recognises the copy by size and time where the listing carries no hash', () => {
|
||||
// Synology offers no sha256 at all, so this is the case that actually
|
||||
// happens there; the hash-only version of this never fired.
|
||||
const p = pathIds({ items: [paired()], remote: [listed({ contentHash: null })], local: [file()] });
|
||||
expect(kinds(p)).toEqual(['relocate', 'rename_local']);
|
||||
});
|
||||
|
||||
it('does not follow into a copy with other bytes, whatever its size and time', () => {
|
||||
const p = pathIds({ items: [paired()], remote: [listed({ contentHash: 'hash-other' })], local: [file()] });
|
||||
expect(kinds(p)).toEqual(['pull', 'mark_remote_missing']);
|
||||
});
|
||||
|
||||
it('does not follow into a copy that was edited as well', () => {
|
||||
const edited = listed({ contentHash: null, remoteModifiedAt: '2026-09-19T08:00:00Z' });
|
||||
const p = pathIds({ items: [paired()], remote: [edited], local: [file()] });
|
||||
expect(kinds(p)).toEqual(['pull', 'mark_remote_missing']);
|
||||
});
|
||||
|
||||
it('does not guess from size and time it never recorded', () => {
|
||||
const p = pathIds({
|
||||
items: [paired({ remoteSize: null, remoteModifiedAt: null })],
|
||||
remote: [listed({ contentHash: null })],
|
||||
local: [file()],
|
||||
});
|
||||
expect(kinds(p)).toEqual(['pull', 'mark_remote_missing']);
|
||||
});
|
||||
|
||||
it('pairs nothing when two copies of the same bytes were renamed at once', () => {
|
||||
// Either way round fits the content, and the crosswise one puts each name
|
||||
// on the other trip file. A missing flag and a second download can be seen
|
||||
// and undone; a swapped pairing cannot be seen at all.
|
||||
const p = pathIds({
|
||||
items: [
|
||||
paired({ id: 1, fileId: 1, remoteId: '/trek/a1.pdf', remoteName: 'a1.pdf' }),
|
||||
paired({ id: 2, fileId: 2, remoteId: '/trek/a2.pdf', remoteName: 'a2.pdf' }),
|
||||
],
|
||||
remote: [listed({ remoteId: '/trek/b1.pdf', name: 'b1.pdf' }), listed({ remoteId: '/trek/b2.pdf', name: 'b2.pdf' })],
|
||||
local: [file({ fileId: 1, name: 'a1.pdf' }), file({ fileId: 2, name: 'a2.pdf' })],
|
||||
});
|
||||
expect(kinds(p).sort()).toEqual(['mark_remote_missing', 'mark_remote_missing', 'pull', 'pull']);
|
||||
});
|
||||
|
||||
it('tells two copies of the same bytes apart when they were moved and kept their names', () => {
|
||||
const one = listed({ remoteId: '/trek/2026/a1.pdf', name: 'a1.pdf' });
|
||||
const two = listed({ remoteId: '/trek/2026/a2.pdf', name: 'a2.pdf' });
|
||||
const p = pathIds({
|
||||
items: [
|
||||
paired({ id: 1, fileId: 1, remoteId: '/trek/a1.pdf', remoteName: 'a1.pdf' }),
|
||||
paired({ id: 2, fileId: 2, remoteId: '/trek/a2.pdf', remoteName: 'a2.pdf' }),
|
||||
],
|
||||
remote: [two, one],
|
||||
local: [file({ fileId: 1, name: 'a1.pdf' }), file({ fileId: 2, name: 'a2.pdf' })],
|
||||
});
|
||||
expect(p.actions.filter(a => a.kind === 'relocate')).toEqual([
|
||||
{ kind: 'relocate', itemId: 1, remote: one },
|
||||
{ kind: 'relocate', itemId: 2, remote: two },
|
||||
]);
|
||||
expect(kinds(p).filter(k => k !== 'relocate')).toEqual(['touch', 'touch']);
|
||||
});
|
||||
|
||||
it('follows the one of two identical copies that was renamed and leaves the other', () => {
|
||||
const kept = listed({ remoteId: '/trek/a2.pdf', name: 'a2.pdf', remoteVersion: 'va' });
|
||||
const renamed = listed({ remoteId: '/trek/b1.pdf', name: 'b1.pdf' });
|
||||
const p = pathIds({
|
||||
items: [
|
||||
paired({ id: 1, fileId: 1, remoteId: '/trek/a1.pdf', remoteName: 'a1.pdf' }),
|
||||
paired({ id: 2, fileId: 2, remoteId: '/trek/a2.pdf', remoteName: 'a2.pdf' }),
|
||||
],
|
||||
remote: [kept, renamed],
|
||||
local: [file({ fileId: 1, name: 'a1.pdf' }), file({ fileId: 2, name: 'a2.pdf' })],
|
||||
});
|
||||
expect(p.actions).toEqual([
|
||||
{ kind: 'relocate', itemId: 1, remote: renamed },
|
||||
{ kind: 'rename_local', itemId: 1, fileId: 1, name: 'b1.pdf' },
|
||||
{ kind: 'touch', itemId: 2, remote: kept },
|
||||
]);
|
||||
});
|
||||
|
||||
it('finds a copy again after TREK renamed it upstream', () => {
|
||||
// The adapter cannot say what path a rename produced, so the row keeps the
|
||||
// old one (and the name rename_remote agreed) until the next listing.
|
||||
const p = pathIds({
|
||||
items: [paired({ remoteName: 'b.pdf', remoteVersion: 'vb' })],
|
||||
remote: [listed({ contentHash: null })],
|
||||
local: [file({ name: 'b.pdf' })],
|
||||
});
|
||||
expect(kinds(p)).toEqual(['relocate', 'touch']);
|
||||
});
|
||||
|
||||
it('keeps a document deleted in TREK deleted when its copy is renamed upstream', () => {
|
||||
// Where ids are stable a pairing already acted on stays put. Here the
|
||||
// renamed copy would otherwise come back as a new document.
|
||||
const p = pathIds({
|
||||
items: [paired({ state: 'local_deleted' })],
|
||||
remote: [listed()],
|
||||
local: [file({ deletedAt: '2026-09-18 08:00:00' })],
|
||||
});
|
||||
expect(p.actions).toEqual([{ kind: 'relocate', itemId: 10, remote: listed() }]);
|
||||
});
|
||||
|
||||
describe('next to a twin TREK binned', () => {
|
||||
// Deleting one of two identical files in TREK is the usual way two rows
|
||||
// come to share content. The binned one used to count as a candidate, so a
|
||||
// rename of the live one was a tie and came back as missing plus new.
|
||||
const binned = (over: Partial<SyncItemState> = {}) => paired({
|
||||
id: 1, fileId: 1, remoteId: '/trek/a1.pdf', remoteName: 'a1.pdf',
|
||||
state: 'local_deleted', remoteTrashedAt: '2026-09-18 08:05:00', ...over,
|
||||
});
|
||||
const live = paired({ id: 2, fileId: 2, remoteId: '/trek/a2.pdf', remoteName: 'a2.pdf' });
|
||||
const renamed = listed({ remoteId: '/trek/b.pdf', name: 'b.pdf', contentHash: null });
|
||||
|
||||
it('follows the rename of the live one', () => {
|
||||
const p = pathIds({
|
||||
items: [binned(), live],
|
||||
remote: [renamed],
|
||||
local: [file({ fileId: 1, name: 'a1.pdf', deletedAt: '2026-09-18 08:00:00' }), file({ fileId: 2, name: 'a2.pdf' })],
|
||||
});
|
||||
expect(p.actions).toEqual([
|
||||
{ kind: 'relocate', itemId: 2, remote: renamed },
|
||||
{ kind: 'rename_local', itemId: 2, fileId: 2, name: 'b.pdf' },
|
||||
]);
|
||||
expect(p.missingCount).toBe(0);
|
||||
});
|
||||
|
||||
it('does so once the binned one is purged as well', () => {
|
||||
const p = pathIds({ items: [binned({ fileId: null }), live], remote: [renamed], local: [file({ fileId: 2, name: 'a2.pdf' })] });
|
||||
expect(kinds(p)).toEqual(['relocate', 'rename_local']);
|
||||
expect(p.actions[0]).toMatchObject({ itemId: 2 });
|
||||
});
|
||||
|
||||
it('still recognises the binned copy when it comes back elsewhere on its own', () => {
|
||||
const back = listed({ remoteId: '/trek/restored/a1.pdf', name: 'a1.pdf', contentHash: null });
|
||||
const p = pathIds({
|
||||
items: [binned()],
|
||||
remote: [back],
|
||||
local: [file({ fileId: 1, name: 'a1.pdf', deletedAt: '2026-09-18 08:00:00' })],
|
||||
});
|
||||
expect(p.actions).toEqual([
|
||||
{ kind: 'relocate', itemId: 1, remote: back },
|
||||
{ kind: 'remote_restored', itemId: 1 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves a shelved row where it is: that waits for a person', () => {
|
||||
const p = pathIds({
|
||||
items: [paired({ state: 'error', attempts: 6, fileId: null, contentSha256: null })],
|
||||
remote: [listed({ contentHash: null })],
|
||||
local: [],
|
||||
});
|
||||
expect(kinds(p)).not.toContain('relocate');
|
||||
});
|
||||
|
||||
it('follows nothing where ids are stable: a new id there is a new document', () => {
|
||||
const p = plan({ items: [paired()], remote: [listed()], local: [file()] });
|
||||
expect(kinds(p)).toEqual(['pull', 'mark_remote_missing']);
|
||||
});
|
||||
|
||||
it('follows nothing under an unchanged upstream, which lists nothing to follow into', () => {
|
||||
const p = pathIds({ items: [paired()], remote: [listed()], local: [file()], remoteUnchanged: true });
|
||||
expect(p.actions).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Both sides renamed a document whose bytes agree: only the name is in dispute.
|
||||
*
|
||||
* `trek_wins` used to settle it like changed bytes, with a push_update. That
|
||||
* re-sent identical bytes, and where the provider replaces in place (WebDAV,
|
||||
* and Synology once renames there were followed) the upload kept the
|
||||
* provider's name: the next run read it as a rename made upstream and renamed
|
||||
* TREK's file to it. Observed: run 1 [relocate, push_update], run 2 [rename_local].
|
||||
*/
|
||||
describe('planReconcile > both sides renamed', () => {
|
||||
const at = '2026-09-18T10:00:00Z';
|
||||
const paired = item({ remoteId: '/trek/a.pdf', remoteName: 'a.pdf', remoteSize: 1024, remoteModifiedAt: at });
|
||||
const theirs = remote({ remoteId: '/trek/c.pdf', name: 'c.pdf', remoteModifiedAt: at });
|
||||
const mine = local({ name: 'b.pdf', sha256: null });
|
||||
const pathIds = (over: Partial<Parameters<typeof planReconcile>[0]> = {}) =>
|
||||
plan({ stableRemoteIds: false, items: [paired], remote: [theirs], local: [mine], ...over });
|
||||
|
||||
it('carries TREK\'s name upstream when TREK wins, and sends no bytes', () => {
|
||||
const p = pathIds({ conflictPolicy: 'trek_wins' });
|
||||
expect(p.actions).toEqual([
|
||||
{ kind: 'relocate', itemId: 10, remote: theirs },
|
||||
{ kind: 'rename_remote', itemId: 10, remoteId: '/trek/c.pdf', name: 'b.pdf' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('takes the provider\'s name into TREK when the provider wins, and fetches nothing', () => {
|
||||
const p = pathIds({ conflictPolicy: 'provider_wins' });
|
||||
expect(p.actions).toEqual([
|
||||
{ kind: 'relocate', itemId: 10, remote: theirs },
|
||||
{ kind: 'rename_local', itemId: 10, fileId: 1, name: 'c.pdf' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('does the same where ids are stable', () => {
|
||||
const stable = (conflictPolicy: 'trek_wins' | 'provider_wins') => plan({
|
||||
conflictPolicy,
|
||||
items: [item({ remoteName: 'old.pdf' })],
|
||||
remote: [remote({ name: 'theirs.pdf' })],
|
||||
local: [local({ name: 'mine.pdf' })],
|
||||
}).actions;
|
||||
expect(stable('trek_wins')).toEqual([{ kind: 'rename_remote', itemId: 10, remoteId: 'r1', name: 'mine.pdf' }]);
|
||||
expect(stable('provider_wins')).toEqual([{ kind: 'rename_local', itemId: 10, fileId: 1, name: 'theirs.pdf' }]);
|
||||
});
|
||||
|
||||
it('parks it when the direction forbids what the winner would need', () => {
|
||||
expect(pathIds({ conflictPolicy: 'trek_wins', direction: 'pull' }).actions.map(a => a.kind))
|
||||
.toEqual(['relocate', 'conflict']);
|
||||
expect(pathIds({ conflictPolicy: 'provider_wins', direction: 'push' }).actions.map(a => a.kind))
|
||||
.toEqual(['relocate', 'conflict']);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -685,6 +991,47 @@ describe('planReconcile > a deletion TREK already acted on', () => {
|
||||
});
|
||||
expect(p.actions).toEqual([{ kind: 'remote_restored', itemId: 50 }]);
|
||||
});
|
||||
|
||||
/**
|
||||
* A failed update keeps the file it had and sets `error`. Deleted and purged
|
||||
* while the row was held back, it read as a first download that never
|
||||
* landed, and the next attempt ("Sync now" clears the counter) brought the
|
||||
* deleted document straight back.
|
||||
*/
|
||||
describe('on a row that failed an update first', () => {
|
||||
const failedUpdate = (over: Partial<SyncItemState> = {}) =>
|
||||
item({ fileId: null, state: 'error', attempts: 2, contentSha256: 'aaa', ...over });
|
||||
|
||||
it('reads the purge as a deletion', () => {
|
||||
const p = plan({ items: [failedUpdate()], 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: [failedUpdate()], remote: [], local: [], remoteUnchanged: true });
|
||||
expect(p.actions).toEqual([{ kind: 'local_deleted', itemId: 10, remoteId: 'r1' }]);
|
||||
});
|
||||
|
||||
it('waits out the backoff like any other action for the row', () => {
|
||||
const p = plan({
|
||||
items: [failedUpdate({ nextAttemptAt: '2030-01-01 00:00:00' })],
|
||||
remote: [remote()],
|
||||
local: [],
|
||||
now: '2026-09-19 08:00:00',
|
||||
});
|
||||
expect(p.actions).toEqual([]);
|
||||
});
|
||||
|
||||
it('treats a row waiting on a conflict answer the same way', () => {
|
||||
const p = plan({ items: [failedUpdate({ state: 'pending', attempts: 0 })], remote: [remote()], local: [] });
|
||||
expect(p.actions).toEqual([{ kind: 'local_deleted', itemId: 10, remoteId: 'r1' }]);
|
||||
});
|
||||
|
||||
it('still retries a first download, which never agreed on a hash', () => {
|
||||
const p = plan({ items: [failedUpdate({ contentSha256: null })], remote: [remote()], local: [] });
|
||||
expect(p.actions.map(a => a.kind)).toEqual(['pull_update']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -713,15 +1060,25 @@ describe('planReconcile > a file taken back out of TREK trash', () => {
|
||||
|
||||
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 },
|
||||
]);
|
||||
expect(p.actions).toEqual([{ kind: 'local_restored', itemId: 50, missing: true }]);
|
||||
expect(p.missingCount).toBe(1);
|
||||
});
|
||||
|
||||
it('resumes under an unchanged upstream and leaves the listing to a later run', () => {
|
||||
it('waits under an unchanged upstream, which shows nothing of the copy', () => {
|
||||
// Resumed as synced here, a copy that was long gone counted as one that
|
||||
// vanished on the next real listing, and could trip the guard with it.
|
||||
const p = plan({ items: [settled()], remote: [], local: [back], remoteUnchanged: true });
|
||||
expect(p.actions).toEqual([{ kind: 'local_restored', itemId: 50 }]);
|
||||
expect(p.actions).toEqual([]);
|
||||
});
|
||||
|
||||
it('waits on a truncated listing that does not show the copy', () => {
|
||||
const p = plan({ items: [settled()], remote: [], local: [back], remoteTruncated: true });
|
||||
expect(p.actions).toEqual([]);
|
||||
});
|
||||
|
||||
it('resumes from a truncated listing that does show the copy', () => {
|
||||
const p = plan({ items: [settled()], remote: [remote({ remoteId: 'r5' })], local: [back], remoteTruncated: true });
|
||||
expect(p.actions.map(a => a.kind)).toEqual(['local_restored', 'touch']);
|
||||
});
|
||||
|
||||
it('uploads it again when TREK was the one that binned the copy', () => {
|
||||
@@ -766,10 +1123,94 @@ describe('planReconcile > a file taken back out of TREK trash', () => {
|
||||
expect(p.actions.filter(a => a.kind === 'push')).toHaveLength(10);
|
||||
});
|
||||
|
||||
it('counts many restored rows whose copies vanished like any synced rows, guard included', () => {
|
||||
it('does not read many restores whose copies are gone as a mass deletion', () => {
|
||||
// Nothing was recorded about those copies while TREK's files sat in the
|
||||
// bin, so they may have been gone for weeks: the listing did not shrink.
|
||||
// Counted as vanished, they tripped the guard, the run was dropped before
|
||||
// the restores were written, and every run after it planned the same.
|
||||
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);
|
||||
expect(p.massDeleteGuardTripped).toBe(false);
|
||||
expect(p.actions).toEqual(rows.map((r) => ({ kind: 'local_restored', itemId: r.id, missing: true })));
|
||||
expect(p.missingCount).toBe(10);
|
||||
});
|
||||
|
||||
describe('beside a binding that is otherwise in step', () => {
|
||||
const synced = Array.from({ length: 6 }, (_, i) => item({ id: 200 + i, fileId: 200 + i, remoteId: `s${i}` }));
|
||||
const restored = Array.from({ length: 5 }, (_, i) => settled({ id: 300 + i, fileId: 300 + i, remoteId: `g${i}` }));
|
||||
const files = [...synced, ...restored].map((r) => local({ fileId: r.fileId as number }));
|
||||
const newUpstream = remote({ remoteId: 'fresh', name: 'visa.pdf' });
|
||||
|
||||
it('leaves the guard alone and syncs the rest as usual', () => {
|
||||
const p = plan({
|
||||
items: [...synced, ...restored],
|
||||
remote: [...synced.map((s) => remote({ remoteId: s.remoteId as string })), newUpstream],
|
||||
local: files,
|
||||
});
|
||||
expect(p.massDeleteGuardTripped).toBe(false);
|
||||
expect(p.actions.filter(a => a.kind === 'local_restored'))
|
||||
.toEqual(restored.map((r) => ({ kind: 'local_restored', itemId: r.id, missing: true })));
|
||||
expect(p.actions).toContainEqual({ kind: 'pull', remote: newUpstream, itemId: null });
|
||||
expect(p.actions.map(a => a.kind)).not.toContain('mark_remote_missing');
|
||||
});
|
||||
|
||||
it('still trips the guard when the synced rows themselves vanish', () => {
|
||||
const p = plan({ items: synced, remote: [remote({ remoteId: 's0' })], local: files });
|
||||
expect(p.massDeleteGuardTripped).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('resumes when the binned copy was restored at the provider under a new path', () => {
|
||||
// Detached first, the row lost its id before the move could be followed:
|
||||
// the restored copy came down as a new document and TREK's file went up as
|
||||
// another, two of each.
|
||||
const p = plan({
|
||||
stableRemoteIds: false,
|
||||
items: [binnedByTrek({ remoteId: '/trek/a.pdf', remoteName: 'a.pdf', remoteSize: 1024, remoteModifiedAt: '2026-09-18T10:00:00Z' })],
|
||||
remote: [remote({ remoteId: '/trek/a restored.pdf', name: 'a restored.pdf' })],
|
||||
local: [local({ fileId: 5, name: 'a.pdf', sha256: null })],
|
||||
});
|
||||
expect(p.actions.map(a => a.kind)).toEqual(['relocate', 'local_restored', 'rename_local']);
|
||||
});
|
||||
|
||||
it('follows a copy moved while the file was in the bin, when TREK did not bin it', () => {
|
||||
const moved = remote({ remoteId: '/trek/2026/a.pdf', name: 'a.pdf' });
|
||||
const p = plan({
|
||||
stableRemoteIds: false,
|
||||
items: [settled({ remoteId: '/trek/a.pdf', remoteName: 'a.pdf', remoteSize: 1024, remoteModifiedAt: '2026-09-18T10:00:00Z' })],
|
||||
remote: [moved],
|
||||
local: [local({ fileId: 5, name: 'a.pdf', sha256: null })],
|
||||
});
|
||||
expect(p.actions).toEqual([
|
||||
{ kind: 'relocate', itemId: 50, remote: moved },
|
||||
{ kind: 'local_restored', itemId: 50 },
|
||||
{ kind: 'touch', itemId: 50, remote: moved },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('needsFullListing', () => {
|
||||
const settled = (over: Partial<SyncItemState> = {}) =>
|
||||
item({ id: 50, fileId: 5, remoteId: 'r5', state: 'local_deleted', ...over });
|
||||
|
||||
it('asks for one once a file whose copy TREK left alone is back', () => {
|
||||
expect(needsFullListing([settled()], [local({ fileId: 5 })])).toBe(true);
|
||||
});
|
||||
|
||||
it('does not while the file is still in the trash', () => {
|
||||
expect(needsFullListing([settled()], [local({ fileId: 5, deletedAt: '2026-09-18 08:00:00' })])).toBe(false);
|
||||
});
|
||||
|
||||
it('does not for a copy TREK binned: that gap is known, and the file goes up again', () => {
|
||||
expect(needsFullListing([settled({ remoteTrashedAt: '2026-09-18 08:05:00' })], [local({ fileId: 5 })])).toBe(false);
|
||||
});
|
||||
|
||||
it('does not once the file is purged', () => {
|
||||
expect(needsFullListing([settled({ fileId: null })], [])).toBe(false);
|
||||
});
|
||||
|
||||
it('does not for pairings that are simply in step', () => {
|
||||
expect(needsFullListing([item()], [local()])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
* read back with plain SQL rather than through the service that wrote it.
|
||||
*
|
||||
* The provider is a fake because a unit test must not open a socket. Storage
|
||||
* and the files domain are fakes because the bytes are not what is under test —
|
||||
* and the files domain are fakes because the bytes are not what is under test,
|
||||
* but the fake files service writes real `trip_files` rows, so "nothing was
|
||||
* downloaded" is asserted against the table the file manager reads, not against
|
||||
* a spy alone.
|
||||
@@ -129,7 +129,7 @@ const provider = {
|
||||
listScopes: vi.fn(async (): Promise<DocResult<DocumentScopeOption[]>> => ok([SCOPE])),
|
||||
createScope: vi.fn(async (): Promise<DocResult<DocumentScopeOption>> => ok(SCOPE)),
|
||||
resolveScope: vi.fn(async (): Promise<DocResult<DocumentScopeOption>> => ok(SCOPE)),
|
||||
list: vi.fn(async (): Promise<DocResult<Listing>> => ok(listing())),
|
||||
list: vi.fn(async (_conn: DocumentConnectionRef, _scope: DocumentScopeRef): Promise<DocResult<Listing>> => ok(listing())),
|
||||
fetch: vi.fn(async (): Promise<DocResult<FetchResult>> =>
|
||||
ok({ body: Readable.from([FILE_BYTES]), size: FILE_BYTES.length, mimeType: 'application/pdf', remoteVersion: 'v9' })),
|
||||
push: vi.fn(async (
|
||||
@@ -188,6 +188,7 @@ let scopeSeq = 0;
|
||||
function makeLink(over: {
|
||||
direction?: string;
|
||||
deletePolicy?: string;
|
||||
conflictPolicy?: string;
|
||||
syncEnabled?: number;
|
||||
failureCount?: number;
|
||||
nextAttemptAt?: string | null;
|
||||
@@ -199,11 +200,11 @@ function makeLink(over: {
|
||||
`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 (?, ?, 'paperless', ?, '1', '/TREK/japan', 'Japan 2026', ?, ?, 'manual', ?, ?, ?, ?)`,
|
||||
VALUES (?, ?, 'paperless', ?, '1', '/TREK/japan', 'Japan 2026', ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.run(
|
||||
tripId, connectionId, `tag:${scopeSeq}`,
|
||||
over.direction ?? 'both', over.deletePolicy ?? 'unlink',
|
||||
over.direction ?? 'both', over.deletePolicy ?? 'unlink', over.conflictPolicy ?? 'manual',
|
||||
over.syncEnabled ?? 1, over.failureCount ?? 0, over.nextAttemptAt ?? null, ownerId,
|
||||
);
|
||||
if (over.lastSyncAt !== undefined) {
|
||||
@@ -222,19 +223,24 @@ function seedItem(link: LinkRow, over: {
|
||||
state?: string;
|
||||
contentSha256?: string;
|
||||
pushedSha256?: string;
|
||||
remoteSize?: number;
|
||||
remoteModifiedAt?: string;
|
||||
remoteMissingAt?: string;
|
||||
} = {}): number {
|
||||
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,
|
||||
content_sha256, pushed_sha256, state)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
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);
|
||||
}
|
||||
@@ -549,7 +555,7 @@ describe('DocSyncService', () => {
|
||||
expect(afterPush.content_sha256).toBe(FILE_SHA);
|
||||
expect(afterPush.remote_id).toBe('r-pushed');
|
||||
|
||||
// Same bytes back, under a new version marker — which is what a provider
|
||||
// Same bytes back, under a new version marker, which is what a provider
|
||||
// reports for TREK's own write.
|
||||
provider.list.mockResolvedValueOnce(ok(listing([
|
||||
remoteDoc({ remoteId: 'r-pushed', name: 'boarding.pdf', remoteVersion: 'v2', contentHash: FILE_SHA }),
|
||||
@@ -567,7 +573,7 @@ describe('DocSyncService', () => {
|
||||
it('refuses to move a pairing onto a document another file already owns', async () => {
|
||||
// Papra answers a push of bytes it already holds with the document that
|
||||
// has them. Two trip files with the same content therefore push to ONE
|
||||
// document — and the unique index on (link_id, remote_id) turned that
|
||||
// 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 = makeLink();
|
||||
makeFile({ name: 'boarding.pdf' });
|
||||
@@ -576,8 +582,8 @@ describe('DocSyncService', () => {
|
||||
|
||||
const run = await service.syncLink(link);
|
||||
|
||||
// Counted as a conflict, which is what it is — a document two files want
|
||||
// — and reported the same way as any other conflict rather than as a
|
||||
// Counted as a conflict, which is what it is (a document two files
|
||||
// want), and reported the same way as any other conflict rather than as a
|
||||
// failed run.
|
||||
expect(run.pushed).toBe(1);
|
||||
expect(run.conflicts).toBe(1);
|
||||
@@ -639,7 +645,7 @@ describe('DocSyncService', () => {
|
||||
/**
|
||||
* A chat screenshot and a note attachment live in `trip_files` like every
|
||||
* other document, but they belong to a conversation rather than to the
|
||||
* trip's paperwork — pushing them would put someone's chat into a shared
|
||||
* trip's paperwork: pushing them would put someone's chat into a shared
|
||||
* Paperless.
|
||||
*/
|
||||
it('leaves chat and note attachments out of the upload entirely', async () => {
|
||||
@@ -749,6 +755,64 @@ describe('DocSyncService', () => {
|
||||
expect(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 = makeLink({ conflictPolicy: 'trek_wins' });
|
||||
const fileId = makeFile({ name: 'mine.pdf' });
|
||||
const itemId = 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(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' });
|
||||
|
||||
provider.list.mockResolvedValueOnce(ok(listing([remoteDoc({ remoteId: 'r1', name: 'mine.pdf', remoteVersion: 'v2' })])));
|
||||
await service.syncLink(config.getLink(link.id));
|
||||
|
||||
expect(provider.rename).toHaveBeenCalledTimes(1);
|
||||
expect(fileRows().map((f) => f.original_name)).toEqual(['mine.pdf']);
|
||||
});
|
||||
|
||||
/**
|
||||
* Only `touch` used to lift the missing flag, and a copy that came back
|
||||
* under another name gets a rename instead, so it stayed on the issues list
|
||||
* for another run.
|
||||
*/
|
||||
it('takes a copy on record as missing off the list when it is back under a new name', async () => {
|
||||
const link = makeLink();
|
||||
const fileId = makeFile({ name: 'old.pdf' });
|
||||
const itemId = 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(service.issues(tripId)).toEqual([]);
|
||||
});
|
||||
|
||||
it('does the same when the name TREK gave it meanwhile is carried upstream', async () => {
|
||||
const link = makeLink();
|
||||
const fileId = makeFile({ name: 'new.pdf' });
|
||||
const itemId = 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' });
|
||||
});
|
||||
|
||||
/**
|
||||
* The size a listing claims is not a promise. The transfer is cut off at
|
||||
* the cap while it is still being written, so an oversized body can never
|
||||
@@ -792,7 +856,7 @@ describe('DocSyncService', () => {
|
||||
/**
|
||||
* Pins what happens today, and it is wrong. The failed pull already wrote
|
||||
* the provider's version marker into the pairing, so the next run sees no
|
||||
* change and emits `touch` — and `touch` promotes `error` to `synced`. The
|
||||
* change and emits `touch`, and `touch` promotes `error` to `synced`. The
|
||||
* document is then booked as synced with no `file_id`, out of the issues
|
||||
* list and never retried. Reported rather than fixed here; this is the test
|
||||
* that turns red once it is.
|
||||
@@ -958,7 +1022,7 @@ describe('DocSyncService', () => {
|
||||
* pin what the code does TODAY, and it is the wrong way round: 'trek'
|
||||
* clears `content_sha256`, which is the only record that TREK's copy ever
|
||||
* moved, so the run pulls the provider's version instead of sending TREK's.
|
||||
* Reported rather than fixed here — these are the tests that turn red when
|
||||
* Reported rather than fixed here; these are the tests that turn red when
|
||||
* the two branches are put back.
|
||||
*/
|
||||
it('does not pull the provider copy when the conflict was resolved in TREK favour', async () => {
|
||||
@@ -994,8 +1058,8 @@ describe('DocSyncService', () => {
|
||||
/**
|
||||
* The double rename is the only conflict a person can actually produce, and
|
||||
* it is arbitrated by `remote_name` alone. Clearing a version marker left
|
||||
* all three names untouched, so the next run raised the same conflict again
|
||||
* — whatever the owner chose, the issue came back within minutes, forever.
|
||||
* all three names untouched, so the next run raised the same conflict again:
|
||||
* whatever the owner chose, the issue came back within minutes, forever.
|
||||
*/
|
||||
describe('a double rename', () => {
|
||||
function seedRenameConflict(link: LinkRow) {
|
||||
@@ -1039,6 +1103,27 @@ describe('DocSyncService', () => {
|
||||
expect(file.original_name).toBe('Rechnung.pdf');
|
||||
});
|
||||
|
||||
it('takes the provider name back the way a download would have written it', async () => {
|
||||
// Written raw, the colon made the next run read TREK's name as a second
|
||||
// rename of its own, and the conflict the owner had just settled came
|
||||
// straight back.
|
||||
const link = makeLink();
|
||||
const fileId = makeFile({ name: 'Invoice.pdf' });
|
||||
const itemId = 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(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');
|
||||
});
|
||||
|
||||
it('does not leave the row in conflict either way', async () => {
|
||||
for (const keep of ['trek', 'provider'] as const) {
|
||||
const link = makeLink();
|
||||
@@ -1090,7 +1175,7 @@ describe('DocSyncService', () => {
|
||||
* An upstream edit replaces TREK's copy instead of joining it.
|
||||
*
|
||||
* `pull_update` routes into the same `pull()` as a first download, and
|
||||
* `FilesService.createFile` only inserts — so the edit arrived as a SECOND
|
||||
* `FilesService.createFile` only inserts, so the edit arrived as a SECOND
|
||||
* document while the first stayed in the file manager holding the old bytes.
|
||||
* Worse, the pairing moves to the new row, which leaves the old one with no
|
||||
* sync item: the next run reads it as a document TREK gained and pushes the
|
||||
@@ -1317,6 +1402,15 @@ describe('DocSyncService', () => {
|
||||
const restore = (fileId: number) =>
|
||||
testDb.prepare('UPDATE trip_files SET deleted_at = NULL WHERE id = ?').run(fileId);
|
||||
|
||||
/**
|
||||
* A provider answering the way the WebDAV adapters do: asked with the
|
||||
* cursor it handed out, "nothing changed" and no documents at all; asked
|
||||
* without one, the whole folder.
|
||||
*/
|
||||
const quietUnlessAsked = (documents: RemoteDocument[]) =>
|
||||
async (_conn: DocumentConnectionRef, scope: DocumentScopeRef) =>
|
||||
ok(scope.cursor === null ? listing(documents) : listing([], { cursorUnchanged: true }));
|
||||
|
||||
/** 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' });
|
||||
@@ -1447,8 +1541,8 @@ describe('DocSyncService', () => {
|
||||
|
||||
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.
|
||||
// binned it is dropped. Without that, a later restore on a quiet binding
|
||||
// would upload a second copy.
|
||||
const link = makeLink({ deletePolicy: 'trash' });
|
||||
const { itemId, fileId } = await deletedAndSeen(link);
|
||||
|
||||
@@ -1458,7 +1552,7 @@ describe('DocSyncService', () => {
|
||||
expect(provider.fetch).not.toHaveBeenCalled();
|
||||
|
||||
restore(fileId);
|
||||
provider.list.mockResolvedValueOnce(ok(listing([], { cursorUnchanged: true })));
|
||||
provider.list.mockImplementationOnce(quietUnlessAsked([remoteDoc({ remoteId: 'r1' })]));
|
||||
await service.syncLink(config.getLink(link.id));
|
||||
|
||||
expect(provider.push).not.toHaveBeenCalled();
|
||||
@@ -1466,6 +1560,60 @@ describe('DocSyncService', () => {
|
||||
expect(itemRow(itemId).remote_id).toBe('r1');
|
||||
});
|
||||
|
||||
it('asks a quiet binding for the whole listing and flags a copy that is gone', async () => {
|
||||
// An unchanged cursor shows nothing of the copy, and on a quiet binding
|
||||
// it stays unchanged until somebody touches the folder. Resumed blind,
|
||||
// the row was booked as synced and counted as vanished later on.
|
||||
const link = makeLink({ deletePolicy: 'unlink' });
|
||||
const { itemId, fileId } = await deletedAndSeen(link);
|
||||
const visa = remoteDoc({ remoteId: 'r-visa', name: 'visa.pdf' });
|
||||
|
||||
restore(fileId);
|
||||
provider.list.mockImplementation(quietUnlessAsked([visa]));
|
||||
const run = await service.syncLink(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();
|
||||
|
||||
// Once the restore is settled the binding goes back to its cursor.
|
||||
await service.syncLink(config.getLink(link.id));
|
||||
expect(provider.list.mock.calls[2][1].cursor).toBe('cursor-1');
|
||||
});
|
||||
|
||||
it('keeps the binding running when several restored files lost their copies meanwhile', async () => {
|
||||
// Counted as vanished, five of them next to six in step tripped the
|
||||
// mass-delete guard. The run was dropped before the restores were
|
||||
// written, the next run planned the same, and the binding synced nothing
|
||||
// at all, new documents included, until its circuit opened.
|
||||
const link = 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 newThere = remoteDoc({ remoteId: 'r-new', name: 'new-there.pdf' });
|
||||
|
||||
provider.list.mockResolvedValueOnce(ok(listing([...inStep, newThere])));
|
||||
const first = await service.syncLink(link);
|
||||
provider.list.mockResolvedValueOnce(ok(listing([
|
||||
...inStep, newThere, remoteDoc({ remoteId: 'r-pushed', name: 'new-here.pdf' }),
|
||||
])));
|
||||
const second = await service.syncLink(config.getLink(link.id));
|
||||
|
||||
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');
|
||||
});
|
||||
|
||||
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);
|
||||
@@ -1520,6 +1668,174 @@ describe('DocSyncService', () => {
|
||||
expect(fileRows()).toHaveLength(0);
|
||||
expect(itemRow(itemId).state).toBe('local_deleted');
|
||||
});
|
||||
|
||||
it('does not bring back a document purged while its failed update was held back', async () => {
|
||||
// A failed update keeps the file and leaves the row in `error`, where the
|
||||
// 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 = makeLink({ deletePolicy: 'trash' });
|
||||
const fileId = makeFile({ name: 'boarding.pdf' });
|
||||
const itemId = 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 });
|
||||
|
||||
purge(fileId);
|
||||
service.retryShelvedItems(link.id);
|
||||
await service.syncLink(config.getLink(link.id));
|
||||
|
||||
expect(provider.fetch).toHaveBeenCalledTimes(1);
|
||||
expect(fileRows()).toHaveLength(0);
|
||||
expect(provider.trash).toHaveBeenCalledTimes(1);
|
||||
expect(itemRow(itemId).state).toBe('local_deleted');
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* A rename where the id is the path, walked through real runs.
|
||||
*
|
||||
* On Synology a rename upstream lists the same copy under a new id. The
|
||||
* pairing used to be re-attached with a `touch` that wrote the new name into
|
||||
* the arbiter while the trip file kept the old one, and was flagged missing
|
||||
* in the same run; the run after that renamed the provider's copy back.
|
||||
*/
|
||||
describe('a rename where the id is the path', () => {
|
||||
const AT = '2026-09-18T10:00:00Z';
|
||||
|
||||
beforeEach(() => {
|
||||
capabilities = { ...BASE_CAPABILITIES, stableId: false, contentHashInListing: false };
|
||||
});
|
||||
|
||||
it('takes a rename made at the provider into TREK and does not rename it back', async () => {
|
||||
const link = makeLink();
|
||||
const fileId = makeFile({ name: 'a.pdf' });
|
||||
const itemId = 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 }),
|
||||
])));
|
||||
|
||||
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({
|
||||
remote_id: '/trek/b.pdf', remote_name: 'b.pdf', remote_version: 'vb', state: 'synced', remote_missing_at: null,
|
||||
});
|
||||
|
||||
const second = await service.syncLink(config.getLink(link.id));
|
||||
|
||||
expect(second).toMatchObject({ state: 'ok', missing: 0 });
|
||||
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);
|
||||
});
|
||||
|
||||
it('takes a copy on record as missing off the list in the run that finds it renamed', async () => {
|
||||
const link = makeLink();
|
||||
const fileId = makeFile({ name: 'a.pdf' });
|
||||
const itemId = 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(service.issues(tripId)).toEqual([]);
|
||||
|
||||
await service.syncLink(config.getLink(link.id));
|
||||
|
||||
expect(itemRow(itemId).state).toBe('synced');
|
||||
expect(provider.rename).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('recognises the copy by size and time where the listing has no hash', async () => {
|
||||
// What Synology actually sends. The pull records size and time, which is
|
||||
// all there is to go on once the path has changed.
|
||||
const link = makeLink();
|
||||
provider.list.mockResolvedValueOnce(ok(listing([
|
||||
remoteDoc({ remoteId: '/trek/a.pdf', name: 'a.pdf', remoteVersion: 'va' }),
|
||||
])));
|
||||
await service.syncLink(link);
|
||||
expect(provider.fetch).toHaveBeenCalledTimes(1);
|
||||
const fileId = Number(fileRows()[0].id);
|
||||
|
||||
provider.list.mockResolvedValue(ok(listing([
|
||||
remoteDoc({ remoteId: '/trek/2026/b.pdf', name: 'b.pdf', remoteVersion: 'vb' }),
|
||||
])));
|
||||
const renamed = await service.syncLink(config.getLink(link.id));
|
||||
await service.syncLink(config.getLink(link.id));
|
||||
|
||||
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']]);
|
||||
});
|
||||
|
||||
it('recognises a copy it uploaded itself when it is renamed before the next run', async () => {
|
||||
const link = makeLink();
|
||||
makeFile({ name: 'boarding.pdf' });
|
||||
provider.list.mockResolvedValueOnce(ok(listing([])));
|
||||
provider.push.mockResolvedValueOnce(ok({
|
||||
remoteId: '/trek/boarding.pdf', remoteVersion: 'v1', remoteModifiedAt: AT, deduplicated: false,
|
||||
}));
|
||||
await service.syncLink(link);
|
||||
|
||||
provider.list.mockResolvedValue(ok(listing([
|
||||
remoteDoc({ remoteId: '/trek/flight.pdf', name: 'flight.pdf', remoteVersion: 'v2', size: FILE_BYTES.length }),
|
||||
])));
|
||||
await service.syncLink(config.getLink(link.id));
|
||||
await service.syncLink(config.getLink(link.id));
|
||||
|
||||
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']]);
|
||||
});
|
||||
|
||||
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 = makeLink();
|
||||
const fileId = makeFile({ name: 'b.pdf' });
|
||||
const itemId = 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' }),
|
||||
])));
|
||||
provider.rename.mockResolvedValueOnce(ok({ remoteVersion: 'vb' }));
|
||||
await service.syncLink(link);
|
||||
expect(provider.rename).toHaveBeenCalledWith(expect.anything(), expect.anything(), '/trek/a.pdf', 'b.pdf');
|
||||
|
||||
provider.list.mockResolvedValue(ok(listing([
|
||||
remoteDoc({ remoteId: '/trek/b.pdf', name: 'b.pdf', remoteVersion: 'vb' }),
|
||||
])));
|
||||
const next = await service.syncLink(config.getLink(link.id));
|
||||
await service.syncLink(config.getLink(link.id));
|
||||
|
||||
expect(next.missing).toBe(0);
|
||||
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' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveConflict across trips', () => {
|
||||
|
||||
Reference in New Issue
Block a user