fix(docsync): read the retry limit back out, debounce the webhook, and cover the UI

The attempt counter and the backoff were written on every failure and never
consulted when planning the next run: rows were found in a live database at 46
attempts against a limit of 6, so a document a provider refuses — one it reads
as corrupt, a type it will not take, a full quota — was re-uploaded on every
cron tick forever. planReconcile now skips a row that is shelved or still inside
its window, and a manual run clears the counters, which is what "waits for a
person" has to mean.

The webhook had the same shape of bug: WEBHOOK_NUDGE_DEBOUNCE_SECONDS existed,
with a comment explaining it, and nothing read it. Providers fire once per
document, so twenty files dropped into a watched folder were twenty runs — the
in-flight guard answered `busy` to nineteen of them, throwing away nineteen
announcements, and the one run that did start had begun before most of the
changes landed. A burst now folds into one trailing run, and the binding is
re-read when the timer fires so a sync switched off in between does not get a
last run out of a stale row.

Four defects the new suites turned up, all fixed: the sync button lost its
accessible name below sm, where its label is hidden; the stored address was
never shown in the connect dialog although the form kept submitting it; the
allow-self-signed switch only read the connection at mount, so a stored "on"
showed as off and the next save wrote that back; and a refused folder create
escaped as an unhandled rejection with nothing said to anybody.

Also here: the phone shell gets the same feature (one sheet, four views, the
same hook as the desktop), the marks come from the product's own icon files
rather than redrawn approximations, and the 22 non-English locales carry the
new keys.

ADMIN-SVC-069 asserted that `documents` has no MCP surface. It does now, so the
test takes its example from MCP_GATED_ADDON_IDS instead of naming one.
This commit is contained in:
Maurice
2026-09-18 22:44:45 +02:00
parent 2d7cb124b3
commit 1430056d24
62 changed files with 7263 additions and 724 deletions
@@ -74,8 +74,14 @@ export interface LinkRow {
interface ProviderFieldRow {
field_key: string;
/** i18n key suffix, never display text — the client resolves `docsync.<label>`. */
label: string;
input_type: string;
placeholder: string | null;
hint: string | null;
secret: number;
required: number;
sort_order: number;
}
@Injectable()
@@ -96,8 +102,14 @@ export class DocSyncConfigService {
}
providerFields(providerId: string): ProviderFieldRow[] {
// Every column the form needs, not just the three the secret bookkeeping
// uses: the client renders the field from this row, and a missing `label`
// turns into a literal "docsync.undefined" on screen.
return this.db.connection
.prepare('SELECT field_key, secret, required FROM document_provider_fields WHERE provider_id = ? ORDER BY sort_order')
.prepare(
`SELECT field_key, label, input_type, placeholder, hint, secret, required, sort_order
FROM document_provider_fields WHERE provider_id = ? ORDER BY sort_order`,
)
.all(providerId) as ProviderFieldRow[];
}
@@ -1,7 +1,8 @@
import { Controller, HttpCode, Param, Post, Req } from '@nestjs/common';
import { Controller, HttpCode, OnModuleDestroy, Param, Post, Req } from '@nestjs/common';
import type { Request } from 'express';
import crypto from 'crypto';
import { Public } from '../auth/public.decorator';
import { WEBHOOK_NUDGE_DEBOUNCE_SECONDS } from './doc-sync.constants';
import { DocSyncConfigService } from './doc-sync-config.service';
import { DocSyncService } from './doc-sync.service';
@@ -22,12 +23,29 @@ import { DocSyncService } from './doc-sync.service';
* supports it, a shared secret is checked as well.
*/
@Controller('api/docsync/webhook')
export class DocSyncWebhookController {
export class DocSyncWebhookController implements OnModuleDestroy {
/**
* One pending run per binding, so a burst folds into a single pass.
*
* Providers fire per document: dropping twenty files into a watched Nextcloud
* folder is twenty calls within a second or two. Each one used to start its
* own run, which the service's in-flight guard then answered with `busy` —
* so nineteen changes were announced and thrown away, and the one run that
* did start had begun before most of them landed. Collecting them for a beat
* and then running once is both less work and more correct.
*/
private readonly pending = new Map<number, ReturnType<typeof setTimeout>>();
constructor(
private readonly config: DocSyncConfigService,
private readonly sync: DocSyncService,
) {}
onModuleDestroy(): void {
for (const timer of this.pending.values()) clearTimeout(timer);
this.pending.clear();
}
@Post(':token')
@Public('A provider cannot hold a TREK session; the per-link token in the URL is the authentication, and the call can only ever trigger a sync run.')
@HttpCode(200)
@@ -42,11 +60,34 @@ export class DocSyncWebhookController {
// Fire and forget. Paperless allows five seconds before it counts the call
// as failed and retries, and a sync run takes longer than that whenever
// there is anything to do.
void this.sync.syncLink(link);
// there is anything to do — so the answer goes out now and the run happens
// after the debounce window, by which time the rest of the burst has
// arrived and been folded into this same timer.
this.schedule(link.id, () => this.config.getLink(link.id));
return { received: true };
}
/**
* Start one run per binding per window, trailing rather than leading.
*
* Trailing on purpose: the first call of a burst is the least informed one,
* because the provider is usually still writing the rest. The link is looked
* up again when the timer fires, so a binding switched off or deleted in the
* meantime does not get one last run out of a stale row.
*/
private schedule(linkId: number, reload: () => ReturnType<DocSyncConfigService['getLink']>): void {
if (this.pending.has(linkId)) return;
const timer = setTimeout(() => {
this.pending.delete(linkId);
const fresh = reload();
if (!fresh || fresh.sync_enabled !== 1) return;
void this.sync.syncLink(fresh);
}, WEBHOOK_NUDGE_DEBOUNCE_SECONDS * 1000);
// A pending nudge must not hold the process open at shutdown.
if (typeof timer.unref === 'function') timer.unref();
this.pending.set(linkId, timer);
}
/**
* Accept either a plain shared-secret header (Paperless workflows, Nextcloud
* `authMethod: header`) or Papra's standard-webhooks HMAC. Compared in
@@ -77,4 +77,8 @@ export const MAX_TRANSFERS_PER_RUN = 25;
/** app_settings keys, read per tick so an admin toggle needs no restart. */
export const SETTING_POLL_INTERVAL = 'docsync_poll_interval_seconds';
export const SETTING_SYNC_ENABLED = 'docsync_sync_enabled';
export const SETTING_MAX_FILE_BYTES = 'docsync_max_file_bytes';
// There is deliberately no size key here: a document that arrives through a
// binding is bound by the same ceiling as one somebody uploads (MAX_FILE_SIZE in
// files.constants), checked against the announced size and again while the
// bytes stream. A second, sync-only limit would be a number nobody maintains
// and a way for the two paths to disagree about what fits.
@@ -319,6 +319,9 @@ export class DocSyncController {
) {
const link = this.config.getLink(Number(linkId));
if (!link || link.trip_id !== Number(tripId)) throw new HttpException('Link not found', 404);
// A person asking for a run is also asking for the rows that gave up to be
// tried once more; the scheduler gets no such reprieve.
this.sync.retryShelvedItems(link.id);
return this.sync.syncLink(link, { full: body.full });
}
+58 -18
View File
@@ -105,6 +105,8 @@ export interface SyncItemState {
pushedSha256: string | null;
state: string;
attempts: number;
/** When the backoff lets this row be tried again; null once it is shelved. */
nextAttemptAt: string | null;
remoteMissingAt: string | null;
}
@@ -156,6 +158,10 @@ export interface ReconcileInput {
remoteTruncated: boolean;
/** Providers without stable ids need the rename heuristic. */
stableRemoteIds: boolean;
/** How many failures a row gets before it is shelved for a person to look at. */
maxAttempts: number;
/** Injected so the backoff window is testable; defaults to now. */
now?: string;
}
/**
@@ -168,9 +174,43 @@ export interface ReconcileInput {
* or ping-pong forever.
*/
export function planReconcile(input: ReconcileInput): ReconcilePlan {
const { items, remote, local, direction, remoteTruncated, stableRemoteIds } = input;
const { items, remote, local, direction, remoteTruncated, stableRemoteIds, maxAttempts } = input;
const now = input.now ?? new Date().toISOString().replace('T', ' ').slice(0, 19);
const actions: PlanAction[] = [];
/**
* Rows that must not be tried again on this run.
*
* 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
* against a limit of 6, because the limit was written to the database and
* never read back out.
*
* Shelved is not dead: a manual run clears the counter (see resetItemAttempts),
* which is what "waits for a person" means.
*/
const blocked = new Set<number>();
for (const it of items) {
if (it.state !== 'error') continue;
if (it.attempts >= maxAttempts) { blocked.add(it.id); continue; }
if (it.nextAttemptAt !== null && it.nextAttemptAt > now) blocked.add(it.id);
}
/**
* Every action goes through here so one rule covers all of them.
*
* No exception for `mark_remote_missing`: a row only counts as vanished if it
* was `synced` last run, and a row is only shelved if it is in `error`, so the
* two can never describe the same row.
*/
const add = (action: PlanAction): void => {
const itemId = 'itemId' in action ? action.itemId : null;
if (itemId !== null && blocked.has(itemId)) return;
actions.push(action);
};
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>();
@@ -213,22 +253,22 @@ export function planReconcile(input: ReconcileInput): ReconcilePlan {
const localChanged = !!l && l.sha256 !== null && it.contentSha256 !== null && l.sha256 !== it.contentSha256;
if (l?.deletedAt) {
actions.push({ kind: 'local_deleted', itemId: it.id, remoteId: it.remoteId });
add({ kind: 'local_deleted', itemId: it.id, remoteId: it.remoteId });
continue;
}
if (remoteChanged && !isEcho && localChanged) {
actions.push({ kind: 'conflict', itemId: it.id, remote: r, local: l ?? null });
add({ kind: 'conflict', itemId: it.id, remote: r, local: l ?? null });
continue;
}
if (remoteChanged && !isEcho) {
if (direction === 'push') { actions.push({ kind: 'touch', itemId: it.id, remote: r }); continue; }
actions.push({ kind: 'pull_update', remote: r, itemId: it.id });
if (direction === 'push') { add({ kind: 'touch', itemId: it.id, remote: r }); continue; }
add({ kind: 'pull_update', remote: r, itemId: it.id });
continue;
}
if (localChanged && l) {
if (direction === 'pull') { actions.push({ kind: 'touch', itemId: it.id, remote: r }); continue; }
actions.push({ kind: 'push_update', local: l, itemId: it.id, remoteId: it.remoteId });
if (direction === 'pull') { add({ kind: 'touch', itemId: it.id, remote: r }); continue; }
add({ kind: 'push_update', local: l, itemId: it.id, remoteId: it.remoteId });
continue;
}
@@ -242,28 +282,28 @@ export function planReconcile(input: ReconcileInput): ReconcilePlan {
const localRenamed = it.remoteName !== null && l.name !== it.remoteName;
if (providerRenamed && !localRenamed && direction !== 'push') {
actions.push({ kind: 'rename_local', itemId: it.id, fileId: l.fileId, name: r.name });
add({ kind: 'rename_local', itemId: it.id, fileId: l.fileId, name: r.name });
continue;
}
if (localRenamed && !providerRenamed && direction !== 'pull') {
actions.push({ kind: 'rename_remote', itemId: it.id, remoteId: it.remoteId, name: l.name });
add({ kind: 'rename_remote', itemId: it.id, remoteId: it.remoteId, name: l.name });
continue;
}
if (providerRenamed && localRenamed) {
// Both sides renamed. Nothing here can pick the right one, and picking
// wrong loses a name somebody chose, so it goes to a person.
actions.push({ kind: 'conflict', itemId: it.id, remote: r, local: l });
add({ kind: 'conflict', itemId: it.id, remote: r, local: l });
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') actions.push({ kind: 'rename_local', itemId: it.id, fileId: l.fileId, name: r.name });
else if (direction === 'push') actions.push({ kind: 'rename_remote', itemId: it.id, remoteId: it.remoteId, name: l.name });
else actions.push({ kind: 'touch', itemId: it.id, remote: r });
if (direction === 'pull') add({ kind: 'rename_local', itemId: it.id, fileId: l.fileId, name: r.name });
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;
}
actions.push({ kind: 'touch', itemId: it.id, remote: r });
add({ kind: 'touch', itemId: it.id, remote: r });
}
// ── Upstream only ─────────────────────────────────────────────────────────
@@ -281,11 +321,11 @@ export function planReconcile(input: ReconcileInput): ReconcilePlan {
(i) => i.contentSha256 === r.contentHash && i.remoteId !== null && !remoteById.has(i.remoteId),
);
if (reuse) {
actions.push({ kind: 'touch', itemId: reuse.id, remote: r });
add({ kind: 'touch', itemId: reuse.id, remote: r });
continue;
}
}
actions.push({ kind: 'pull', remote: r, itemId: null });
add({ kind: 'pull', remote: r, itemId: null });
}
}
@@ -299,7 +339,7 @@ export function planReconcile(input: ReconcileInput): ReconcilePlan {
// "missing", not re-uploaded — otherwise a broken listing duplicates the
// entire trip upstream.
if (it?.remoteId) continue;
actions.push({ kind: 'push', local: l, itemId: it?.id ?? null, remoteId: null });
add({ kind: 'push', local: l, itemId: it?.id ?? null, remoteId: null });
}
}
@@ -307,7 +347,7 @@ export function planReconcile(input: ReconcileInput): ReconcilePlan {
if (!guardTripped && !remoteTruncated) {
for (const it of vanished) {
if (it.remoteMissingAt) continue;
actions.push({ kind: 'mark_remote_missing', itemId: it.id });
add({ kind: 'mark_remote_missing', itemId: it.id });
}
}
+63 -2
View File
@@ -162,6 +162,7 @@ export class DocSyncService {
direction: link.direction as 'both' | 'pull' | 'push',
remoteTruncated: listing.data.truncated,
stableRemoteIds: provider.capabilities(ref).stableId,
maxAttempts: ITEM_MAX_ATTEMPTS,
});
if (plan.massDeleteGuardTripped) {
@@ -440,13 +441,32 @@ export class DocSyncService {
return 'pushed';
}
/**
* Put the shelved rows of one binding back in the queue.
*
* A row that has used up its attempts stays put until a person says otherwise,
* 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.
*/
retryShelvedItems(linkId: number): void {
this.db.connection
.prepare(
`UPDATE document_sync_items
SET attempts = 0, next_attempt_at = NULL
WHERE link_id = ? AND state = 'error'`,
)
.run(linkId);
}
// ── State loading and writing ──────────────────────────────────────────────
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, remote_missing_at
pushed_sha256, state, attempts, next_attempt_at, remote_missing_at
FROM document_sync_items WHERE link_id = ?`,
)
.all(linkId) as Array<Record<string, unknown>>;
@@ -461,6 +481,7 @@ export class DocSyncService {
pushedSha256: r.pushed_sha256 === null ? null : String(r.pushed_sha256),
state: String(r.state),
attempts: Number(r.attempts ?? 0),
nextAttemptAt: r.next_attempt_at === null || r.next_attempt_at === undefined ? null : String(r.next_attempt_at),
remoteMissingAt: r.remote_missing_at === null ? null : String(r.remote_missing_at),
}));
}
@@ -694,8 +715,48 @@ export class DocSyncService {
const counts = this.db.connection
.prepare('SELECT state, COUNT(*) AS n FROM document_sync_items WHERE trip_id = ? GROUP BY state')
.all(tripId) as Array<{ state: string; n: number }>;
/**
* 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
* 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.
*/
const totalHere = this.db.connection
.prepare(
`SELECT COUNT(*) AS n FROM trip_files
WHERE trip_id = ? AND deleted_at IS NULL AND message_id IS NULL AND note_id IS NULL`,
)
.get(tripId) as { n: number };
const perLink = this.db.connection
.prepare(
`SELECT link_id,
SUM(CASE WHEN file_id IS NOT NULL AND remote_id IS NOT NULL AND state = 'synced' THEN 1 ELSE 0 END) AS paired,
SUM(CASE WHEN remote_id IS NOT NULL AND state != 'remote_missing' THEN 1 ELSE 0 END) AS atProvider,
SUM(CASE WHEN state = 'remote_missing' THEN 1 ELSE 0 END) AS missing
FROM document_sync_items
WHERE trip_id = ?
GROUP BY link_id`,
)
.all(tripId) as Array<{ link_id: number; paired: number; atProvider: number; missing: number }>;
const byLink = new Map(perLink.map((r) => [r.link_id, r]));
return {
links: links.map((l) => this.config.publicLink(l, null)),
links: links.map((l) => {
const row = byLink.get(l.id);
return {
...this.config.publicLink(l, null),
holdings: {
inTrek: totalHere.n,
atProvider: Number(row?.atProvider ?? 0),
paired: Number(row?.paired ?? 0),
missing: Number(row?.missing ?? 0),
},
};
}),
items: Object.fromEntries(counts.map((c) => [c.state, c.n])),
};
}
@@ -63,8 +63,9 @@ const TRASH_FOLDER = '.trek-trash';
/**
* The adapter's own ceiling for one transfer. Not a policy — the install's limit
* lives in `docsync_max_file_bytes` and is enforced by the core — just a bound
* so a wrong path or a truncated header cannot start an unbounded read.
* is the upload limit (`MAX_FILE_SIZE`), enforced by the core before and during
* every transfer — just a bound so a wrong path or a truncated header cannot
* start an unbounded read.
*/
const MAX_TRANSFER_BYTES = 2 * 1024 * 1024 * 1024;
+13 -2
View File
@@ -10,6 +10,7 @@
* (it replaced ADMIN-BR-001 when the old admin bridge died with the cron move).
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, afterAll } from 'vitest';
import { ADDON_IDS, MCP_GATED_ADDON_IDS } from '../../../src/addons';
// ── DB setup ──────────────────────────────────────────────────────────────────
@@ -502,9 +503,19 @@ describe('updateAddon', () => {
// real flip of an MCP-relevant addon → invalidate
expect((updateAddon('packing', { enabled: false }) as any).mcpAffected).toBe(true);
expect((updateAddon('packing', { enabled: true }) as any).mcpAffected).toBe(true);
// real flip of an addon with no MCP surface → sessions survive
// real flip of an addon with no MCP surface → sessions survive. Taken from
// the list rather than named, because an addon that grows MCP tools joins it
// and would otherwise turn this assertion false without changing anything
// it is actually about (documents did, when document sync landed).
const noMcp = Object.values(ADDON_IDS).find(id => !MCP_GATED_ADDON_IDS.includes(id));
if (noMcp) {
const flip = updateAddon(noMcp, { enabled: false }) as any;
if (!flip.error) expect(flip.mcpAffected).toBe(false);
}
// and the one this change put on the list carries the opposite verdict
const docsFlip = updateAddon('documents', { enabled: false }) as any;
if (!docsFlip.error) expect(docsFlip.mcpAffected).toBe(false);
if (!docsFlip.error) expect(docsFlip.mcpAffected).toBe(true);
});
it('ADMIN-SVC-087 — refuses to enable a photo provider while journey is off', () => {
@@ -1,4 +1,4 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import crypto from 'crypto';
import type { Request } from 'express';
@@ -27,8 +27,11 @@ import type { DocSyncService } from '../../../../src/nest/doc-sync/doc-sync.serv
* always the same, and whether a run was scheduled, which is the only place the
* decision is visible at all.
*
* No timers and no clock: the signature is computed here from a fixed id and
* timestamp, exactly as the provider would.
* The signature is computed here from a fixed id and timestamp, exactly as the
* provider would. Timers are faked, because the endpoint now collects a burst
* before it runs: providers fire once per document, and twenty files dropped
* into a watched folder used to be twenty runs — nineteen of which the service
* answered `busy` and threw away.
*/
const SECRET = 'M7dQ2vLp5rTn8kYw1xZc4bJh';
@@ -60,6 +63,9 @@ const link = (over: Partial<LinkRow> = {}): LinkRow => ({
const config = {
getLinkByToken: vi.fn((token: string) => (token === 'tok-live' ? link() : undefined)),
// Looked up again when the timer fires, so a binding switched off during the
// window does not get one last run out of a stale row.
getLink: vi.fn((id: number) => (id === 4 ? link() : undefined)),
webhookSecret: vi.fn(() => SECRET),
};
@@ -98,10 +104,22 @@ function papraReq(payload: string, signature: string, sent = payload): Request {
beforeEach(() => {
vi.clearAllMocks();
vi.useFakeTimers();
config.getLinkByToken.mockImplementation((token: string) => (token === 'tok-live' ? link() : undefined));
config.getLink.mockImplementation((id: number) => (id === 4 ? link() : undefined));
config.webhookSecret.mockReturnValue(SECRET);
});
afterEach(() => {
vi.runOnlyPendingTimers();
vi.useRealTimers();
});
/** Let the debounce window pass, so a scheduled run actually happens. */
function settle() {
vi.advanceTimersByTime(6000);
}
describe('an unknown token', () => {
it('answers as if it were known, so nobody can enumerate which tokens exist', () => {
expect(controller.nudge('tok-guessed', makeReq())).toEqual({ received: true });
@@ -124,7 +142,9 @@ describe('a binding whose sync is switched off', () => {
describe('a shared-secret header', () => {
it('triggers the run when it matches', () => {
controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET }));
settle();
expect(sync.syncLink).toHaveBeenCalledTimes(1);
settle();
expect(sync.syncLink.mock.calls[0][0]).toMatchObject({ id: 4 });
});
@@ -142,6 +162,7 @@ describe('a shared-secret header', () => {
it('is not demanded from a binding that carries no secret — the token alone authenticates there', () => {
config.webhookSecret.mockReturnValue('');
controller.nudge('tok-live', makeReq());
settle();
expect(sync.syncLink).toHaveBeenCalledTimes(1);
});
});
@@ -151,6 +172,7 @@ describe('a Papra standard-webhooks signature', () => {
it('triggers the run when it covers the bytes that arrived', () => {
controller.nudge('tok-live', papraReq(payload, sign(payload)));
settle();
expect(sync.syncLink).toHaveBeenCalledTimes(1);
});
@@ -184,6 +206,7 @@ describe('a Papra standard-webhooks signature', () => {
{ rawBody: Buffer.from(payload, 'utf8') },
);
controller.nudge('tok-live', req);
settle();
expect(sync.syncLink).toHaveBeenCalledTimes(1);
});
@@ -194,6 +217,7 @@ describe('a Papra standard-webhooks signature', () => {
{ body },
);
controller.nudge('tok-live', req);
settle();
expect(sync.syncLink).toHaveBeenCalledTimes(1);
});
@@ -224,3 +248,75 @@ describe('a credential of the wrong length', () => {
expect(sync.syncLink).not.toHaveBeenCalled();
});
});
/**
* A burst of calls is one run.
*
* Every provider here fires per document: a folder of twenty files is twenty
* calls within a second or two. Each used to start its own run — the service's
* in-flight guard then answered `busy` to nineteen of them, so nineteen
* announcements were thrown away and the one run that did start had begun
* before most of the changes landed.
*/
describe('a burst of nudges', () => {
it('runs once for twenty calls rather than twenty times', () => {
for (let i = 0; i < 20; i += 1) {
controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET }));
}
settle();
expect(sync.syncLink).toHaveBeenCalledTimes(1);
});
it('answers every one of them immediately, before any run happens', () => {
const answers = [];
for (let i = 0; i < 5; i += 1) {
answers.push(controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET })));
}
// Paperless gives the call five seconds before it counts it as failed.
expect(answers).toEqual(Array.from({ length: 5 }, () => ({ received: true })));
expect(sync.syncLink).not.toHaveBeenCalled();
});
it('runs again for a burst that arrives after the window closed', () => {
controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET }));
settle();
controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET }));
settle();
expect(sync.syncLink).toHaveBeenCalledTimes(2);
});
it('keeps one window per binding, so a busy trip cannot starve a quiet one', () => {
const other = link({ id: 9, webhook_token: 'tok-other' });
config.getLinkByToken.mockImplementation((t: string) =>
t === 'tok-live' ? link() : t === 'tok-other' ? other : undefined);
config.getLink.mockImplementation((id: number) => (id === 4 ? link() : id === 9 ? other : undefined));
controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET }));
controller.nudge('tok-other', makeReq({ 'x-trek-docsync-secret': SECRET }));
settle();
expect(sync.syncLink).toHaveBeenCalledTimes(2);
expect(sync.syncLink.mock.calls.map((c) => c[0].id).sort()).toEqual([4, 9]);
});
it('drops the run when the binding is switched off inside the window', () => {
controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET }));
config.getLink.mockReturnValue(link({ sync_enabled: 0 }));
settle();
expect(sync.syncLink).not.toHaveBeenCalled();
});
it('drops the run when the binding is deleted inside the window', () => {
controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET }));
config.getLink.mockReturnValue(undefined);
settle();
expect(sync.syncLink).not.toHaveBeenCalled();
});
it('forgets its pending timers when the module goes down', () => {
controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET }));
controller.onModuleDestroy();
settle();
expect(sync.syncLink).not.toHaveBeenCalled();
});
});
@@ -115,6 +115,7 @@ const nextcloud = fakeProvider('nextcloud');
const sync = {
syncLink: vi.fn(async (_link: LinkRow, _opts?: { full?: boolean }) => ({ state: 'ok', pulled: 0, pushed: 0, conflicts: 0, missing: 0 })),
retryShelvedItems: vi.fn((_linkId: number) => {}),
status: vi.fn(() => ({ links: [], items: {} })),
resolveConflict: vi.fn(async () => true),
};
@@ -665,3 +666,28 @@ describe('the callback origin a provider is given', () => {
expect(link.webhookUrl).toBeNull();
});
});
/**
* A manual run is also a request to try the given-up rows once more.
*
* The scheduler must never do this: the attempt limit exists precisely so a
* document a provider refuses is not re-uploaded on every tick. A person
* pressing the button is the signal that something changed.
*/
describe('a manual run and the shelved rows', () => {
it('clears the attempt counters of the link it runs', async () => {
const conn = await storedPaperless();
const created = (await controller.createLink(String(tripId), owner, linkBody(conn.id), makeReq())) as { id: number };
sync.retryShelvedItems.mockClear();
await controller.syncNow(String(tripId), String(created.id), { full: false });
expect(sync.retryShelvedItems).toHaveBeenCalledWith(created.id);
});
it('does not clear them for a link the caller does not own', async () => {
sync.retryShelvedItems.mockClear();
await expect(controller.syncNow(String(tripId), '999999', { full: false })).rejects.toThrow();
expect(sync.retryShelvedItems).not.toHaveBeenCalled();
});
});
@@ -52,6 +52,7 @@ const item = (over: Partial<SyncItemState> = {}): SyncItemState => ({
pushedSha256: null,
state: 'synced',
attempts: 0,
nextAttemptAt: null,
remoteMissingAt: null,
...over,
});
@@ -64,6 +65,7 @@ const plan = (over: Partial<Parameters<typeof planReconcile>[0]> = {}) =>
direction: 'both',
remoteTruncated: false,
stableRemoteIds: true,
maxAttempts: 6,
...over,
});
@@ -311,3 +313,89 @@ describe('backoffSeconds', () => {
expect(backoffSeconds([60, 300], 0)).toBe(60);
});
});
/**
* The retry limit, read back out.
*
* 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
* for as long as it existed.
*/
describe('planReconcile > shelving a row that keeps failing', () => {
const failing = (over: Partial<SyncItemState> = {}) =>
item({ id: 20, remoteId: null, remoteVersion: null, remoteName: null, contentSha256: null, state: 'error', ...over });
it('re-plans a failed push while attempts are left', () => {
const p = plan({
items: [failing({ attempts: 2 })],
local: [local({ fileId: 1 })],
maxAttempts: 6,
});
expect(p.actions.map(a => a.kind)).toContain('push');
});
it('stops re-planning once the attempts are used up', () => {
const p = plan({
items: [failing({ attempts: 6 })],
local: [local({ fileId: 1 })],
maxAttempts: 6,
});
expect(p.actions.map(a => a.kind)).not.toContain('push');
});
it('stops re-planning past the limit as well, not only exactly at it', () => {
const p = plan({
items: [failing({ attempts: 46 })],
local: [local({ fileId: 1 })],
maxAttempts: 6,
});
expect(p.actions).toEqual([]);
});
it('holds off while the backoff window is still open', () => {
const p = plan({
items: [failing({ attempts: 1, nextAttemptAt: '2030-01-01 00:00:00' })],
local: [local({ fileId: 1 })],
maxAttempts: 6,
now: '2026-01-01 00:00:00',
});
expect(p.actions).toEqual([]);
});
it('tries again once the backoff window has passed', () => {
const p = plan({
items: [failing({ attempts: 1, nextAttemptAt: '2026-01-01 00:00:00' })],
local: [local({ fileId: 1 })],
maxAttempts: 6,
now: '2026-06-01 00:00:00',
});
expect(p.actions.map(a => a.kind)).toContain('push');
});
it('leaves a healthy row alone however high its old attempt count is', () => {
// attempts is not reset on success, so a row that failed twice and then
// went through must not be mistaken for a shelved one.
const p = plan({
items: [item({ attempts: 9, state: 'synced' })],
remote: [remote({ remoteId: 'r1', remoteVersion: 'v2' })],
local: [local({ fileId: 1 })],
maxAttempts: 6,
});
expect(p.actions.map(a => a.kind)).toContain('pull_update');
});
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.
const p = plan({
items: [item({ id: 30, attempts: 99, state: 'error', remoteId: 'r-gone' })],
remote: [],
local: [],
maxAttempts: 6,
});
expect(p.actions).toEqual([]);
});
});