Files
wekan/server/lib/syncRuleCreateCardCommand.js
Lauri Ojansivu d2629161fe Run the createCard rule action through durable Sync.
A createCard rule now has a saved command. Its list, swimlane and title
are resolved once by RulesHelper.createCardTarget - which the ordinary
action uses too, so the two cannot drift - and the new card's id is
derived from the rule invocation, so a replay inserts it once. The
creation hook's createCard activity is known before the insert and is
saved with the command, then delivered durably, which runs the new
card's own rules. A target that does not exist is refused, as the
ordinary insert fails at its activity.

Thanks to xet7 !
2026-10-02 16:58:24 +03:00

79 lines
4.8 KiB
JavaScript

'use strict';
// Durable rule createCard (the structural rule actions of TODO Later). The
// ordinary action (server/rulesHelper.js) inserts { title, listId, swimlaneId,
// sort: 0, boardId } at the target RulesHelper.createCardTarget resolves, and
// the insert hook writes the createCard activity
// (models/lib/cardCreationActivity.js) - which in turn runs the new card's own
// rules. No History row is written for a new card.
//
// One saved command per rule invocation: the target and title are resolved
// once, the new card's id is derived from the invocation so a replay inserts
// it once, and the activity - fully known before the insert - is saved with
// the command. Replays apply the saved command, never the rule's current
// configuration. Pure: tested by tests/syncRuleCreateCardCommand.test.cjs.
const { EJSON } = require('bson');
const { canonical, sha256 } = require('../../models/lib/changeHistoryIntegrity');
const { validateRulePlan, planId } = require('./syncRulePlan');
const { cardCreationActivity } = require('../../models/lib/cardCreationActivity');
const copy = value => EJSON.parse(EJSON.stringify(value), { relaxed: true });
const fail = code => { throw new Error(`sync-rule-create-card-${code}`); };
const text = value => typeof value === 'string' && value.length > 0;
const commandId = invocationId => sha256(canonical(['sync-rule-create-card', invocationId]));
const newCardIdFor = id => sha256(canonical([id, 'card'])).slice(0, 24);
function identity({ plan, activity, effectId, index }) {
validateRulePlan(plan, activity, effectId);
const invocation = plan.actions[index];
if (!Number.isSafeInteger(index) || index < 0 || invocation?.action?.actionType !== 'createCard') fail('invalid');
return { _id: commandId(invocation.id), version: 1, invocationId: invocation.id, planId: planId(effectId, activity._id),
planHash: sha256(canonical(plan)), actorId: plan.actorId, boardId: plan.boardId, cardId: plan.cardId,
actionType: 'createCard' };
}
function creation(base, target, list, swimlane, createdAt) {
const card = { _id: newCardIdFor(base._id), boardId: base.boardId, listId: target.listId,
swimlaneId: target.swimlaneId, title: target.title };
const receiptId = sha256(canonical([base._id, 'activity']));
const activity = { _id: `sync-rule-create-card-${receiptId}`,
...cardCreationActivity(base.actorId, card, list, swimlane), createdAt: new Date(createdAt),
modifiedAt: new Date(createdAt) };
return { card, receiptId, activity };
}
// Capture. `target` is RulesHelper.createCardTarget's; `list` and `swimlane`
// the documents it named. Without both the ordinary insert fails at its
// activity, so the command refuses rather than create a card nothing lists.
function prepareRuleCreateCardCommand({ plan, activity, effectId, index, target, list, swimlane, createdAt }) {
const base = identity({ plan, activity, effectId, index });
if (!target || target.boardId !== base.boardId || typeof target.title !== 'string' ||
!(createdAt instanceof Date) || !Number.isFinite(createdAt.getTime())) fail('invalid');
if (!text(target.listId) || !text(target.swimlaneId) || !list || !swimlane || list._id !== target.listId ||
swimlane._id !== target.swimlaneId || list.boardId !== base.boardId || swimlane.boardId !== base.boardId) {
fail('target-missing');
}
const command = { ...base, createdAt: new Date(createdAt),
...creation(base, target, { title: list.title || '' }, { title: swimlane.title || '' }, createdAt) };
command.checksum = sha256(canonical(command));
return validateRuleCreateCardCommand(command, { plan, activity, effectId, index });
}
function validateRuleCreateCardCommand(row, context) {
const base = identity(context);
const keys = [...Object.keys(base), 'createdAt', 'card', 'receiptId', 'activity', 'checksum'].sort().join(',');
if (!row || Object.keys(row).sort().join(',') !== keys ||
Object.entries(base).some(([key, value]) => canonical(row[key]) !== canonical(value)) ||
!(row.createdAt instanceof Date) || !row.card || !row.activity) fail('command-invalid');
const { checksum, ...content } = row;
if (checksum !== sha256(canonical(content))) fail('command-invalid');
// The saved card and activity are what that target makes, exactly.
const expected = creation(base, { boardId: base.boardId, listId: row.card.listId, swimlaneId: row.card.swimlaneId,
title: row.card.title }, { title: row.activity.listName }, { title: row.activity.swimlaneName }, row.createdAt);
if (canonical(expected) !== canonical({ card: row.card, receiptId: row.receiptId, activity: row.activity }) ||
!text(row.card.listId) || !text(row.card.swimlaneId) || typeof row.activity.listName !== 'string' ||
typeof row.activity.swimlaneName !== 'string') fail('command-invalid');
return copy(row);
}
module.exports = { commandId, newCardIdFor, prepareRuleCreateCardCommand, validateRuleCreateCardCommand };