mirror of
https://github.com/wekan/wekan.git
synced 2026-11-07 11:02:33 -05:00
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 !
56 lines
3.4 KiB
JavaScript
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 };
|