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