Files
wekan/server/lib/syncRuleArchiveEffects.js
Lauri Ojansivu acb2c0786a Resolve a rule's later move, sort, move-all and archive on the board its card went to.
As decided on 2026-10-03, after a rule moved its card to another board, a
later action that names the rule's own board works where the card is now,
in the ordinary engine and in durable Sync alike:

- RulesHelper.ruleBoards gives the board the card is on and the board an
  action works on. A move to the top or bottom, a sort and a move-all
  resolve their list and swimlane names there; the write-access check
  covers that board. Before this, a move to "*" after a move to another
  board took the card's new list and swimlane but the old board, leaving
  the card split across two boards.
- The durable move, sort, move-all and archive commands find the card
  through ruleCardNow and save the board it is on as `onBoard`, written
  only when it is not the plan's, so every command saved before still
  validates; the archive's cascade takes the children on that board.
- These actions may now follow a move to another board, so such a plan
  stays durable. An email still may not, nor a second move to yet another
  board.

The archive reservation and follower guards follow the decision; the
cross-board move test now expects sorting and archiving to follow and
refuses an email instead, and a new server test runs the whole chain
durably and with the ordinary engine on a twin card, with the same result.

Thanks to xet7 !
2026-10-03 09:44:45 +03:00

117 lines
6.7 KiB
JavaScript

'use strict';
const { reuseWithinEvaluation } = require('./syncGuardWindow');
const { EJSON, calculateObjectSize } = require('bson');
const { canonical, sha256 } = require('../../models/lib/changeHistoryIntegrity');
const { validateRuleArchiveCommand } = require('./syncRuleArchiveCommand');
const { archiveUnits, applyRuleArchiveCommand } = require('./syncRuleArchiveApply');
const { prepareSyncFieldHistory } = require('./syncHistoryBatch');
const { prepareSyncUpdateActivities } = require('./syncUpdateActivities');
const { validateSyncEffects, persistSyncEffects } = require('./syncEffects');
const { validateSyncEffectPolicy, assertSyncEffectPolicy } = require('./syncEffectPolicy');
const copy = value => EJSON.parse(EJSON.stringify(value), { relaxed: true });
const fail = () => { throw new Error('sync-rule-archive-effects-invalid'); };
function stepFor(unit) {
// parentId is part of the mutation predicate, not a changed History field.
const clean = card => { const result = copy(card); delete result.parentId; return result; };
return { kind: 'archive', cardId: unit.cardId, before: clean(unit.before), after: clean(unit.after) };
}
function validateRuleArchiveEffects(saved, command, context) {
command = validateRuleArchiveCommand(command, context);
const units = archiveUnits(command, context);
// Version 2: History rows are content, linked to the chain when appended
// (maintainer decision of 2026-09-30), so the saved effects carry no chain
// link and need no History reservation between planning and writing.
if (!saved || Object.keys(saved).sort().join(',') !== '_id,checksum,commandHash,rows,version' ||
saved._id !== command._id || saved.version !== 2 || saved.commandHash !== command.checksum ||
!Array.isArray(saved.rows) || saved.rows.length !== units.length || calculateObjectSize(saved) > 14 * 1024 * 1024) fail();
let changed = false;
for (let i = 0; i < units.length; i++) {
const effects = saved.rows[i], unit = units[i];
validateSyncEffects(effects, stepFor(unit), unit.effectId);
if (effects.version !== 2 || effects.history.userId !== command.actorId ||
(i && canonical(effects.policy) !== canonical(saved.rows[0].policy))) fail();
const history = effects.history;
if (history.rows.length) {
// The redo stack is cleared once, by the first card that records History.
if (changed && history.redo.length) fail();
changed = true;
}
if (history.rows.some(row => row.createdAt.getTime() !== command.createdAt.getTime()) ||
(effects.activities && effects.activities.context.createdAt.getTime() !== command.createdAt.getTime())) fail();
}
const { checksum, ...payload } = saved;
if (checksum !== sha256(canonical(payload))) fail();
return copy(saved);
}
function prepareRuleArchiveEffects({ command, plan, activity, effectId, index, username, lists,
redoRows = [], policy }) {
const context = { plan, activity, effectId, index };
command = validateRuleArchiveCommand(command, context);
policy = validateSyncEffectPolicy(policy);
if (typeof username !== 'string' || !Array.isArray(lists) || lists.length > 1000) fail();
const byId = new Map();
for (const list of lists) {
if (!list || typeof list._id !== 'string' || !list._id || list.boardId !== (command.onBoard || command.boardId) ||
typeof list.title !== 'string' || byId.has(list._id)) fail();
byId.set(list._id, list);
}
const saved = { _id: command._id, version: 2, commandHash: command.checksum, rows: [] };
let redo = redoRows, bytes = 0;
for (const unit of archiveUnits(command, context)) {
const step = stepFor(unit);
const options = { step, effectId: unit.effectId, userId: command.actorId, createdAt: command.createdAt };
const history = prepareSyncFieldHistory({ ...options, redoRows: redo });
const activities = policy.activities ? prepareSyncUpdateActivities({ ...options, username,
list: byId.get(unit.after.listId) }) : null;
const row = { version: 2, policy: { ...policy }, history, activities };
bytes += calculateObjectSize(row) + 32;
if (bytes > 14 * 1024 * 1024) fail();
saved.rows.push(row);
if (history.rows.length) redo = [];
}
saved.checksum = sha256(canonical(saved));
return validateRuleArchiveEffects(saved, command, context);
}
async function ensureRuleArchiveEffects({ effects, command, build, assertCurrent, ...context }) {
command = validateRuleArchiveCommand(command, context);
if (typeof build !== 'function' || typeof assertCurrent !== 'function' ||
!['findOne', 'insertOne'].every(key => typeof effects?.[key] === 'function')) fail();
const read = async () => {
await assertCurrent(); const row = await effects.findOne({ _id: command._id }); await assertCurrent();
return row ? validateRuleArchiveEffects(row, command, context) : null;
};
let saved = await read();
if (!saved) {
const candidate = validateRuleArchiveEffects(await build(copy(command)), command, context);
await assertCurrent(); let failure;
try { await effects.insertOne(copy(candidate)); } catch (error) { failure = error; }
saved = await read();
if (!saved) throw failure || new Error('sync-rule-archive-effects-unconfirmed');
}
return saved;
}
async function applyRuleArchiveEffects(options) {
const { command, history, activities, completeDelivery, readPolicy, assertCurrent } = options;
const saved = validateRuleArchiveEffects(options.effects, command, options);
if (typeof assertCurrent !== 'function' || typeof readPolicy !== 'function' ||
!['findOneAsync', 'insertAsync', 'updateAsync'].every(key => typeof history?.[key] === 'function')) fail();
const units = archiveUnits(command, options);
const guard = reuseWithinEvaluation(async () => {
await assertCurrent();
if (saved.rows.length) await assertSyncEffectPolicy(saved.rows[0].policy, readPolicy);
await assertCurrent();
});
const rows = new Map(units.map((unit, i) => [unit.effectId, { unit, effects: saved.rows[i] }]));
if (saved.rows.some(row => row.policy.activities) && (typeof completeDelivery !== 'function' ||
!['findOneAsync', 'insertAsync'].every(key => typeof activities?.[key] === 'function'))) fail();
return applyRuleArchiveCommand({ ...options, assertCurrent: guard,
preflightEffects: async () => { await guard(); },
completeEffects: async ({ unit, assertCurrent: checkCard }) => {
const row = rows.get(unit.effectId);
if (!row || canonical(unit) !== canonical(row.unit)) fail();
return persistSyncEffects({ history, activities, plan: row.effects, step: stepFor(unit),
effectId: unit.effectId, assertCurrent: checkCard, completeDelivery, readPolicy });
} });
}
module.exports = { archiveEffectStep: stepFor, prepareRuleArchiveEffects, validateRuleArchiveEffects, ensureRuleArchiveEffects, applyRuleArchiveEffects };