Files
wekan/server/lib/syncOperationIntent.js
Lauri Ojansivu 6ed28c41f7 Run manual and scheduled Sync through the durable journal, with replay.
The write-ahead Sync journal and its stored stages - History, activities,
rules, notifications, webhooks - were built and tested but never called:
manual and scheduled Sync wrote cards directly, so a restart mid-run lost
whatever effects had not happened yet. Maintainer decision of 2026-09-30:
finish the Scrum/Sync handoff by calling the stored stages from both.

A run now takes the durable path when it can do everything
(server/lib/listSyncSteps.js): the board enabled Sync effects (and the
instance enabled cron effects for a scheduled run), the list has a versioned
scope, and every rule action on the board - extra actions included - has a
durable adapter. Only sending email has one, so a board with any other rule
action keeps the direct writes, exactly as before; nothing that ran stops
running.

server/lib/listSyncApplication.js turns the reconcile plan into saved steps
from the stored cards, refusing a local edit made since the fetch as the
direct update would, and carrying custom fields only with a mapped estimate.
Effects are planned once with the shared planner and each step is applied
through the hooked card and activity adapters and durable delivery, as the
actor and trigger that started it. Both paths build the same new card.

A new run first finishes the list's unfinished operation, reusing the list
lease it holds instead of taking it twice. The wekan-list-sync-replay cron
job resumes interrupted operations every minute; intents now record their
trigger (version 2) so a replay runs under the same activation switches. An
operation still preparing has written nothing and is discarded - a replay has
no source data to rebuild it from.

Tests: eligibility for every condition, and step building including the
local-edit refusal (node); the direct-path suite now pins that it never calls
the durable one; a full-app run creates, updates and archives through the
journal, crashes at a card write, replays without duplicate cards or
activities, and falls back to the direct path for a rule action without an
adapter. 565 server tests pass.

Thanks to xet7 !
2026-09-30 11:29:44 +03:00

56 lines
3.4 KiB
JavaScript

'use strict';
const { canonical } = require('../../models/lib/changeHistoryIntegrity');
const { validateSyncOperationScope } = require('./syncOperationScope');
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const fail = code => { throw Object.assign(new Error(code), { code }); };
// Version 2 also records the trigger (manual or scheduled), so a replay after a
// restart runs under the same activation switches as the run that began it.
// Version 1 intents, written before, carry none and cannot be replayed alone.
const TRIGGERS = ['manual', 'scheduled'];
function intentIdentity({ intentId, actorId, scope, trigger }) {
if (typeof intentId !== 'string' || !UUID.test(intentId) || typeof actorId !== 'string' || !actorId ||
(trigger !== undefined && !TRIGGERS.includes(trigger))) fail('invalid-sync-operation-intent');
const base = { _id: intentId, actorId, scope: validateSyncOperationScope(scope) };
return trigger === undefined ? { ...base, version: 1 } : { ...base, trigger, version: 2 };
}
function verifyIntent(row, identity) {
const expectedKeys = identity.version === 2 ? '_id,actorId,createdAt,scope,trigger,version' : '_id,actorId,createdAt,scope,version';
if (!row || Object.keys(row).sort().join(',') !== expectedKeys ||
!(row.createdAt instanceof Date) || !Number.isFinite(row.createdAt.getTime())) fail('sync-operation-intent-unconfirmed');
const { createdAt, ...saved } = row;
if (canonical(saved) !== canonical(identity)) fail('sync-operation-intent-conflict');
return row;
}
async function readSyncOperationIntent({ intents, ...input }) {
const identity = intentIdentity(input);
return verifyIntent(await intents.findOne({ _id: identity._id }), identity);
}
async function ensureSyncOperationIntent({ intents, operations, completions, assertCurrent, now = () => new Date(), ...input }) {
const identity = intentIdentity(input);
if (typeof assertCurrent !== 'function') fail('sync-operation-access-required');
await assertCurrent();
let saved = await intents.findOne({ _id: identity._id }), error;
if (!saved) {
// An actor cannot be reconstructed from an old journal or completion.
// Missing intent evidence must not authorize a new actor on replay.
if (await operations.findOne({ intentId: identity._id }) || await completions.findOne({ _id: identity._id })) fail('sync-operation-intent-missing');
const row = { ...identity, createdAt: now() };
verifyIntent(row, identity);
await assertCurrent();
try { await intents.insertOne(row); } catch (failure) { error = failure; }
await assertCurrent();
try { saved = await intents.findOne({ _id: identity._id }); } catch (failure) { throw error || failure; }
}
if (!saved) throw error || new Error('sync-operation-intent-unconfirmed');
verifyIntent(saved, identity);
await assertCurrent();
return saved;
}
// The stored intent as it is, for a replay that knows only its id.
async function loadSyncOperationIntent({ intents, intentId }) {
const row = typeof intentId === 'string' && UUID.test(intentId) ? await intents.findOne({ _id: intentId }) : null;
if (!row) fail('sync-operation-intent-missing');
return verifyIntent(row, intentIdentity({ intentId: row._id, actorId: row.actorId, scope: row.scope, trigger: row.trigger }));
}
module.exports = { intentIdentity, ensureSyncOperationIntent, readSyncOperationIntent, loadSyncOperationIntent };