fix(docsync): stop a push from stealing another file's pairing

Papra answers a push of bytes it already holds with the document that has
them. Two trip files with the same content therefore pushed to one document,
and the unique index on (link_id, remote_id) aborted that run and every run
after it with a constraint error. The pairing now stays with the file that
owns it and the second file is recorded as a conflict.

Also in this pass:
- Paperless no longer adopts a same-bytes document that lacks the scope's tag
  when a consume outlives the poll budget; that document belongs to someone
  else's filing and delete-through would have trashed it.
- The pull path writes the file row, the retirement of the copy it replaces
  and the pairing in one transaction, and announces them only once committed.
- PATCH links stops accepting remoteRootId/remoteRootPath, which it read and
  silently dropped.
- The MCP sync tool clears shelved items before running, as the REST route
  does.
This commit is contained in:
Maurice
2026-09-19 00:44:43 +02:00
parent 7237a1881d
commit e6a5ca1521
11 changed files with 267 additions and 39 deletions
@@ -371,3 +371,40 @@ describe('the admin switches', () => {
expect(sync.syncLink).toHaveBeenCalledTimes(1);
});
});
/**
* A nudge that lands on a run already in flight.
*
* `syncLink` answers `busy` and returns, so the nudge was thrown away — and the
* changes it was about may well have landed after the running pass read the
* folder, which means waiting out a whole poll interval for them. Asked again
* once, and only once, so two clients cannot keep each other going.
*/
describe('a nudge that arrives mid-run', () => {
it('asks again once when the run was busy', async () => {
sync.syncLink.mockResolvedValueOnce({ state: 'busy', pulled: 0, pushed: 0, conflicts: 0, missing: 0 });
controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET }));
settle();
await Promise.resolve();
settle();
expect(sync.syncLink).toHaveBeenCalledTimes(2);
});
it('gives up after that one retry rather than chasing itself', async () => {
sync.syncLink.mockResolvedValue({ state: 'busy', pulled: 0, pushed: 0, conflicts: 0, missing: 0 });
controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET }));
for (let i = 0; i < 5; i += 1) { settle(); await Promise.resolve(); }
expect(sync.syncLink).toHaveBeenCalledTimes(2);
});
it('does not retry a run that worked', async () => {
// Set explicitly: clearAllMocks drops the calls but keeps the
// implementation the previous case installed.
sync.syncLink.mockResolvedValue({ state: 'ok', pulled: 0, pushed: 0, conflicts: 0, missing: 0 });
controller.nudge('tok-live', makeReq({ 'x-trek-docsync-secret': SECRET }));
settle();
await Promise.resolve();
settle();
expect(sync.syncLink).toHaveBeenCalledTimes(1);
});
});