mirror of
https://github.com/wekan/wekan.git
synced 2026-11-05 01:20:07 -05:00
Tell a recreated Scrum record from the one History planned against.
Scrum History judged "the record has the expected values and revision" as proof of which record it was. One deleted and recreated under the same _id - a restore by someone else - passed that test, so a retried undo could delete a record from another lifetime. Maintainer decision of 2026-09-30: every Scrum sprint, release and event now carries a random incarnation per lifetime, set where it is created (the Scrum methods, a board transfer import's plan, and a Scrum History restore). A new checkpoint records the incarnation each target has and, for a record it creates, the one it will have, chosen before writing so a replay recognizes its own insert; the write-state check refuses any other lifetime. History content never carries an incarnation, so a restore always starts a new lifetime, and an update never unsets it. Checkpoints and records from before incarnations keep working as they did, and their completion identity is unchanged. Unit tests cover every state with and without incarnations and the planned lifetimes; a full-app test creates, undoes and redoes a sprint, then replaces it with an identical record of another lifetime mid-undo: the retry refuses and leaves it, where the previous code deleted it. 562 server tests pass. Thanks to xet7 !
This commit is contained in:
1 parent
d997439f79
commit
20275418d4
9 files changed
+206
-18
No files matched your search
@@ -6,7 +6,10 @@ function historyDocument(type, doc) {
|
||||
if (!doc) return null;
|
||||
if (METADATA_TYPES.has(type)) return { _id: doc._id, boardId: type === 'board' ? doc._id : doc.boardId, scrum: structuredClone(doc.scrum || {}) };
|
||||
if (!RECORD_TYPES.has(type)) throw new Error('Unsupported Scrum history record');
|
||||
const { revision, updatedAt, updatedBy, rolloverPending, ...content } = doc;
|
||||
// `incarnation` identifies one lifetime of the record (maintainer decision
|
||||
// of 2026-09-30), not its content: History must never carry an old one back
|
||||
// into a restored record, which gets a fresh one.
|
||||
const { revision, updatedAt, updatedBy, rolloverPending, incarnation, ...content } = doc;
|
||||
return structuredClone(content);
|
||||
}
|
||||
function historyRecords(changes) {
|
||||
|
||||
@@ -24,7 +24,7 @@ const collections = { board: Boards, card: Cards, list: Lists, swimlane: Swimlan
|
||||
const { recordScrumRestoreOnce } = require('./scrumHistoryRestoreWriter');
|
||||
const { finishScrumHistory, verifyScrumHistorySource } = require('./scrumHistoryFinalizer');
|
||||
const { ensureScrumHistoryOperation, claimScrumHistoryWorker, assertScrumHistoryWorker } = require('./scrumHistoryOwnership');
|
||||
const { scrumHistoryWriteState, inspectScrumHistoryWrites, verifyScrumHistoryWrites } = require('./scrumHistoryWriteState');
|
||||
const { scrumHistoryWriteState, inspectScrumHistoryWrites, verifyScrumHistoryWrites, plannedIncarnations } = require('./scrumHistoryWriteState');
|
||||
const { scrumHistoryWriteSelector } = require('./scrumHistoryWriteSelector');
|
||||
const { readScrumHistoryRequestCompletion } = require('./scrumHistoryCompletion');
|
||||
const batches = new AsyncLocalStorage();
|
||||
@@ -172,7 +172,10 @@ export async function applyScrumHistory(row, content, direction, request) {
|
||||
const expected = direction === 'undo' ? row.newContent : row.previousContent;
|
||||
if (direction !== 'restore' && !EJSON.equals(live, expected)) conflict();
|
||||
journal = { _id: row.boardId, rowId: row._id, direction, userId, operationId: operationId || Random.id(), content: EJSON.clone(content),
|
||||
before: live, revisions: current.map((doc,index) => doc ? (METADATA_TYPES.has(targets[index].type) ? doc.scrumRevision || 0 : doc.revision || 0) : null) };
|
||||
before: live, revisions: current.map((doc,index) => doc ? (METADATA_TYPES.has(targets[index].type) ? doc.scrumRevision || 0 : doc.revision || 0) : null),
|
||||
// Which lifetime of each record this plan was made against, and which
|
||||
// one a record it creates will have (scrumHistoryWriteState.js).
|
||||
incarnations: plannedIncarnations({ targets, current, newId: () => Random.id() }) };
|
||||
await ScrumHistoryPending.insertAsync(journal);
|
||||
} else {
|
||||
const current = await Promise.all(targets.map(entry => collections[entry.type].findOneAsync(entry.id)));
|
||||
@@ -187,8 +190,9 @@ export async function applyScrumHistory(row, content, direction, request) {
|
||||
await assertScrumHistoryWorker(ScrumHistoryPending, journal, worker);
|
||||
await assertSource();
|
||||
};
|
||||
const verifyWrites = async (entries = targets, before = journal.before.records, revisions = journal.revisions) => {
|
||||
try { await verifyScrumHistoryWrites({ targets: entries, before, revisions, assertCurrent,
|
||||
const verifyWrites = async (entries = targets, before = journal.before.records, revisions = journal.revisions,
|
||||
incarnations = journal.incarnations) => {
|
||||
try { await verifyScrumHistoryWrites({ targets: entries, before, revisions, incarnations, assertCurrent,
|
||||
read: entry => collections[entry.type].findOneAsync(entry.id) }); }
|
||||
catch (error) { conflict(); }
|
||||
};
|
||||
@@ -196,7 +200,7 @@ export async function applyScrumHistory(row, content, direction, request) {
|
||||
// advancing its first unfinished write. Per-write guards remain necessary
|
||||
// because this read-only preflight is not an atomic database snapshot.
|
||||
try { await inspectScrumHistoryWrites({ targets, before: journal.before.records,
|
||||
revisions: journal.revisions, assertCurrent,
|
||||
revisions: journal.revisions, incarnations: journal.incarnations, assertCurrent,
|
||||
read: entry => collections[entry.type].findOneAsync(entry.id) }); }
|
||||
catch (error) { conflict(); }
|
||||
await withoutRecording(async () => {
|
||||
@@ -208,8 +212,9 @@ export async function applyScrumHistory(row, content, direction, request) {
|
||||
if (before?.type !== entry.type || before?.id !== entry.id) conflict();
|
||||
const originalRevision = journal.revisions[index];
|
||||
let state;
|
||||
const incarnation = journal.incarnations?.[index];
|
||||
try { state = scrumHistoryWriteState({ type: entry.type, current,
|
||||
before: before.document, after: entry.document, revision: originalRevision }); }
|
||||
before: before.document, after: entry.document, revision: originalRevision, incarnation }); }
|
||||
catch (error) { conflict(); }
|
||||
if (state === 'applied') continue;
|
||||
const metadata = METADATA_TYPES.has(entry.type);
|
||||
@@ -220,15 +225,18 @@ export async function applyScrumHistory(row, content, direction, request) {
|
||||
} else if (metadata) {
|
||||
if (!await collection.updateAsync(selector, { $set: { scrum: EJSON.clone(entry.document.scrum), scrumRevision: originalRevision + 1 } })) conflict();
|
||||
} else if (!current) {
|
||||
await collection.insertAsync({ ...EJSON.clone(entry.document), revision: 1, updatedAt: new Date(), updatedBy: userId });
|
||||
await collection.insertAsync({ ...EJSON.clone(entry.document), revision: 1, updatedAt: new Date(), updatedBy: userId,
|
||||
...(incarnation?.after ? { incarnation: incarnation.after } : {}) });
|
||||
} else {
|
||||
const { _id, ...fields } = EJSON.clone(entry.document);
|
||||
const unset = Object.fromEntries(Object.keys(current).filter(key => !['_id','revision','updatedAt','updatedBy'].includes(key) && !(key in fields)).map(key => [key,'']));
|
||||
// `incarnation` is not History content (historyDocument leaves it
|
||||
// out) and must survive an update: it is the same record.
|
||||
const unset = Object.fromEntries(Object.keys(current).filter(key => !['_id','revision','updatedAt','updatedBy','incarnation'].includes(key) && !(key in fields)).map(key => [key,'']));
|
||||
const modifier = { $set: { ...fields, revision: originalRevision + 1, updatedAt: new Date(), updatedBy: userId } };
|
||||
if (Object.keys(unset).length) modifier.$unset = unset;
|
||||
if (!await collection.updateAsync(selector, modifier)) conflict();
|
||||
}
|
||||
await verifyWrites([entry], [before], [originalRevision]);
|
||||
await verifyWrites([entry], [before], [originalRevision], journal.incarnations ? [incarnation] : undefined);
|
||||
}
|
||||
});
|
||||
// Keep recovery durable until BOTH the timeline and the undo-stack flag
|
||||
|
||||
@@ -7,10 +7,16 @@ function scrumHistorySelector(journal) {
|
||||
!Array.isArray(journal.content?.records) || !Array.isArray(journal.before?.records) ||
|
||||
!Array.isArray(journal.revisions) || journal.content.records.length !== journal.before.records.length ||
|
||||
journal.content.records.length !== journal.revisions.length ||
|
||||
(Object.hasOwn(journal, 'operationId') && (typeof journal.operationId !== 'string' || !journal.operationId))) fail();
|
||||
(Object.hasOwn(journal, 'operationId') && (typeof journal.operationId !== 'string' || !journal.operationId)) ||
|
||||
(Object.hasOwn(journal, 'incarnations') && (!Array.isArray(journal.incarnations) ||
|
||||
journal.incarnations.length !== journal.revisions.length))) fail();
|
||||
// A checkpoint saved before incarnations existed has none, and gets no key
|
||||
// here: the completion's planHash digests this selector, so adding one would
|
||||
// change the identity of a checkpoint that was pending across the upgrade.
|
||||
return { _id: journal._id, rowId: journal.rowId, userId: journal.userId, direction: journal.direction,
|
||||
operationId: Object.hasOwn(journal, 'operationId') ? journal.operationId : { $exists: false },
|
||||
content: { $eq: journal.content }, before: { $eq: journal.before }, revisions: { $eq: journal.revisions } };
|
||||
content: { $eq: journal.content }, before: { $eq: journal.before }, revisions: { $eq: journal.revisions },
|
||||
...(Object.hasOwn(journal, 'incarnations') ? { incarnations: { $eq: journal.incarnations } } : {}) };
|
||||
}
|
||||
async function assertScrumHistoryOperation(pending, journal) {
|
||||
if (!journal.operationId || !await pending.findOneAsync(scrumHistorySelector(journal))) fail();
|
||||
|
||||
@@ -5,7 +5,20 @@ const fail = () => { throw new Error('Scrum History revision conflict'); };
|
||||
const validRevision = value => Number.isSafeInteger(value) && value >= 0;
|
||||
// Matching values alone do not prove this operation wrote them. A later edit
|
||||
// can return to the same content at a newer revision; retain the checkpoint.
|
||||
function scrumHistoryWriteState({ type, current, before, after, revision }) {
|
||||
//
|
||||
// Nor do matching values and revision prove it is the SAME record: one deleted
|
||||
// and recreated under the same _id (a restore by someone else) can match both.
|
||||
// A Scrum record carries a random `incarnation` per lifetime (2026-09-30), and
|
||||
// the checkpoint saves the one it expects before (`incarnation.before`) and
|
||||
// after (`incarnation.after`, fresh for a record this operation creates). A
|
||||
// checkpoint from before incarnations existed has none, and is judged as before.
|
||||
function incarnationOf(current) {
|
||||
return current && typeof current.incarnation === 'string' ? current.incarnation : null;
|
||||
}
|
||||
function scrumHistoryWriteState({ type, current, before, after, revision, incarnation }) {
|
||||
if (incarnation !== undefined && (!incarnation || typeof incarnation !== 'object' ||
|
||||
Object.keys(incarnation).sort().join(',') !== 'after,before' ||
|
||||
![incarnation.before, incarnation.after].every(value => value === null || (typeof value === 'string' && value)))) fail();
|
||||
if (!METADATA_TYPES.has(type) && !RECORD_TYPES.has(type)) fail();
|
||||
if (before === null ? revision !== null : !validRevision(revision)) fail();
|
||||
const live = historyDocument(type, current);
|
||||
@@ -15,15 +28,18 @@ function scrumHistoryWriteState({ type, current, before, after, revision }) {
|
||||
if (same(live, after)) {
|
||||
const expected = !current ? null : same(before, after) ? revision : before === null ? 1 : revision + 1;
|
||||
if (currentRevision !== expected || (current && !validRevision(expected))) fail();
|
||||
if (incarnation && current && incarnationOf(current) !== incarnation.after) fail();
|
||||
return 'applied';
|
||||
}
|
||||
if (!same(live, before) || currentRevision !== revision ||
|
||||
(current && !Number.isSafeInteger(revision + 1))) fail();
|
||||
if (incarnation && current && incarnationOf(current) !== incarnation.before) fail();
|
||||
return 'pending';
|
||||
}
|
||||
async function inspectScrumHistoryWrites({ targets, before, revisions, read, assertCurrent }) {
|
||||
async function inspectScrumHistoryWrites({ targets, before, revisions, incarnations, read, assertCurrent }) {
|
||||
if (!Array.isArray(targets) || !Array.isArray(before) || !Array.isArray(revisions) ||
|
||||
targets.length !== before.length || targets.length !== revisions.length ||
|
||||
(incarnations !== undefined && (!Array.isArray(incarnations) || incarnations.length !== targets.length)) ||
|
||||
typeof read !== 'function' || typeof assertCurrent !== 'function') fail();
|
||||
const states = [];
|
||||
for (let index = 0; index < targets.length; index++) {
|
||||
@@ -33,7 +49,7 @@ async function inspectScrumHistoryWrites({ targets, before, revisions, read, ass
|
||||
const current = await read(target);
|
||||
await assertCurrent();
|
||||
states.push(scrumHistoryWriteState({ type: target.type, current, before: previous.document,
|
||||
after: target.document, revision: revisions[index] }));
|
||||
after: target.document, revision: revisions[index], incarnation: incarnations?.[index] }));
|
||||
}
|
||||
await assertCurrent();
|
||||
return states;
|
||||
@@ -41,4 +57,18 @@ async function inspectScrumHistoryWrites({ targets, before, revisions, read, ass
|
||||
async function verifyScrumHistoryWrites(options) {
|
||||
if ((await inspectScrumHistoryWrites(options)).some(state => state !== 'applied')) fail();
|
||||
}
|
||||
module.exports = { scrumHistoryWriteState, inspectScrumHistoryWrites, verifyScrumHistoryWrites };
|
||||
// What a new checkpoint expects of each target: the live record's incarnation,
|
||||
// and after the write the same one - or, for a record this operation creates,
|
||||
// a fresh one chosen now so a replay can recognize its own insert. Metadata
|
||||
// targets (board, card, list, swimlane) are updated in place and have none.
|
||||
function plannedIncarnations({ targets, current, newId }) {
|
||||
if (!Array.isArray(targets) || !Array.isArray(current) || targets.length !== current.length ||
|
||||
typeof newId !== 'function') fail();
|
||||
return targets.map((target, index) => {
|
||||
if (!RECORD_TYPES.has(target.type)) return { before: null, after: null };
|
||||
const was = incarnationOf(current[index]);
|
||||
if (!target.document) return { before: was, after: null };
|
||||
return { before: was, after: current[index] ? was : newId() };
|
||||
});
|
||||
}
|
||||
module.exports = { scrumHistoryWriteState, inspectScrumHistoryWrites, verifyScrumHistoryWrites, plannedIncarnations };
|
||||
@@ -55,8 +55,10 @@ export async function importScrumTransfer(creator, source, boardId) {
|
||||
for (const record of transfer[key]) {
|
||||
const provenance = record.provenance || { system: 'wekan', recordId: sourceIds.get(record._id),
|
||||
...(typeof source._id === 'string' && source._id.length <= 500 ? { projectId: source._id } : {}) };
|
||||
// A fresh incarnation per imported record, chosen in the plan so a
|
||||
// resumed import writes the same one (never the source board's).
|
||||
steps.push({ kind: 'insert', collection: key, after: { ...record, provenance, boardId, revision: 1,
|
||||
...(key === 'sprints' ? { scrumImportPending: true } : {}) } });
|
||||
incarnation: Random.id(), ...(key === 'sprints' ? { scrumImportPending: true } : {}) } });
|
||||
}
|
||||
}
|
||||
for (const [key, collection] of [['cards',Cards],['lists',Lists],['swimlanes',Swimlanes]]) {
|
||||
|
||||
@@ -101,6 +101,7 @@ import './storedHistoryChain.tests';
|
||||
import './undoRedoClaim.tests';
|
||||
|
||||
import './scrumHistoryConfirmation.tests';
|
||||
import './scrumIncarnation.tests';
|
||||
|
||||
import "./cardListEntry.tests";
|
||||
import './autoArchiveCards.tests';
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { DDP } from 'meteor/ddp';
|
||||
import { Random } from 'meteor/random';
|
||||
import Boards from '/models/boards';
|
||||
import ChangeHistory from '/models/changeHistory';
|
||||
import ScrumSprints from '/models/scrumSprints';
|
||||
import ScrumHistoryPending, { ScrumHistoryCompletions } from '/server/lib/scrumHistoryPending';
|
||||
import { applyScrumHistory } from '/server/lib/scrumHistory';
|
||||
|
||||
// Incarnations (maintainer decision of 2026-09-30): a Scrum record deleted and
|
||||
// recreated under the same _id, with the same values and revision, is not the
|
||||
// record a Scrum History operation planned against, and a retry must not
|
||||
// delete it. Before incarnations, the retry below deleted the other record.
|
||||
describe('Scrum record incarnations', function () {
|
||||
this.timeout(20000);
|
||||
it('gives each lifetime its own incarnation and refuses a retry against another lifetime', async function () {
|
||||
if (!Meteor.isAppTest) this.skip();
|
||||
const userId = Random.id(), boardId = Random.id();
|
||||
const context = { userId, isSimulation: false, connection: null, setUserId() {}, unblock() {} };
|
||||
const actor = fn => DDP._CurrentMethodInvocation.withValue(context, fn);
|
||||
const invoke = (method, ...args) => actor(() => Meteor.server.method_handlers[method].apply(context, args));
|
||||
const originalRemove = ScrumSprints.removeAsync;
|
||||
try {
|
||||
await Meteor.users.rawCollection().insertOne({ _id: userId, username: `incarnation-${userId}`, profile: {} });
|
||||
await Boards.rawCollection().insertOne({ _id: boardId, title: 'Incarnations', permission: 'private', archived: false,
|
||||
members: [{ userId, isAdmin: true, isActive: true }] });
|
||||
const created = await invoke('scrum.saveSprint', boardId, null, { name: 'Sprint 1' });
|
||||
const first = await ScrumSprints.findOneAsync(created._id);
|
||||
assert.ok(typeof first.incarnation === 'string' && first.incarnation, 'a created record has an incarnation');
|
||||
const row = await ChangeHistory.findOneAsync({ boardId, entityType: 'scrum' }, { sort: { createdAt: -1 } });
|
||||
assert.equal(JSON.stringify(row.newContent).includes('incarnation'), false, 'History content never carries it');
|
||||
|
||||
// Undo deletes it; redo re-creates it in a NEW lifetime.
|
||||
await actor(() => applyScrumHistory(row, row.previousContent, 'undo'));
|
||||
assert.equal(await ScrumSprints.findOneAsync(created._id), undefined);
|
||||
const undone = await ChangeHistory.findOneAsync(row._id);
|
||||
await actor(() => applyScrumHistory(undone, undone.newContent, 'redo'));
|
||||
const second = await ScrumSprints.findOneAsync(created._id);
|
||||
assert.ok(second.incarnation && second.incarnation !== first.incarnation, 'a recreated record is a new lifetime');
|
||||
|
||||
// Undo again, but somebody replaces the sprint with an identical record of
|
||||
// another lifetime just before the delete. The conditional delete misses...
|
||||
ScrumSprints.removeAsync = async function (selector, ...args) {
|
||||
await ScrumSprints.rawCollection().updateOne({ _id: created._id }, { $set: { incarnation: 'someone-else' } });
|
||||
return originalRemove.call(this, selector, ...args);
|
||||
};
|
||||
const redone = await ChangeHistory.findOneAsync(row._id);
|
||||
await assert.rejects(actor(() => applyScrumHistory(redone, redone.previousContent, 'undo')), /scrum-conflict/);
|
||||
ScrumSprints.removeAsync = originalRemove;
|
||||
assert.ok(await ScrumHistoryPending.findOneAsync(boardId), 'the checkpoint is retained');
|
||||
// ...and the RETRY, which sees the same values and revision, must refuse too
|
||||
// (negative): the checkpoint planned against the second lifetime.
|
||||
await assert.rejects(actor(() => applyScrumHistory(redone, redone.previousContent, 'undo')), /scrum-conflict/);
|
||||
const survivor = await ScrumSprints.findOneAsync(created._id);
|
||||
assert.equal(survivor?.incarnation, 'someone-else', 'the other lifetime is not deleted');
|
||||
} finally {
|
||||
ScrumSprints.removeAsync = originalRemove;
|
||||
await ScrumSprints.rawCollection().deleteMany({ boardId });
|
||||
await ScrumHistoryPending.rawCollection().deleteMany({ _id: boardId });
|
||||
await ScrumHistoryCompletions.rawCollection().deleteMany({ boardId });
|
||||
await ChangeHistory.rawCollection().deleteMany({ boardId });
|
||||
await Boards.rawCollection().deleteOne({ _id: boardId });
|
||||
await Meteor.users.rawCollection().deleteOne({ _id: userId });
|
||||
}
|
||||
});
|
||||
});
|
||||
+7
-1
@@ -3,6 +3,7 @@ import { ScrumImportPending } from '/server/lib/scrumImportJournal';
|
||||
import { canEditCardOrLinkedCard } from '/server/lib/linkedCardPermission';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import { check, Match } from 'meteor/check';
|
||||
import { Random } from 'meteor/random';
|
||||
import { DDPRateLimiter } from 'meteor/ddp-rate-limiter';
|
||||
import Boards from '/models/boards';
|
||||
import Cards from '/models/cards';
|
||||
@@ -157,7 +158,12 @@ async function saveRecord(userId, kind, boardId, recordId, changes, expectedRevi
|
||||
}
|
||||
const now = new Date();
|
||||
after.revision = (before?.revision || 0) + 1; after.updatedAt = now; after.updatedBy = userId;
|
||||
if (!before) { after.createdAt = now; after.createdBy = userId; if (kind !== 'event') after.state = after.state || 'planned'; }
|
||||
if (!before) {
|
||||
after.createdAt = now; after.createdBy = userId; if (kind !== 'event') after.state = after.state || 'planned';
|
||||
// One lifetime of this record (maintainer decision of 2026-09-30): Scrum
|
||||
// History tells it from a record deleted and recreated under the same _id.
|
||||
after.incarnation = Random.id();
|
||||
}
|
||||
if (before) {
|
||||
const { _id, ...fields } = after;
|
||||
if (!(await collection.updateAsync({ _id: recordId, boardId, revision: before.revision }, { $set: fields }))) conflict();
|
||||
|
||||
@@ -70,3 +70,68 @@ test('read-only preflight accepts mixed pending/applied steps and rejects later
|
||||
assert.equal(f.rows[0].scrumRevision, 1);
|
||||
assert.deepEqual(f.rows[0].scrum, {});
|
||||
});
|
||||
|
||||
// Incarnations (maintainer decision of 2026-09-30): matching values and
|
||||
// revision do not prove a record is the one this operation planned against or
|
||||
// created - one deleted and recreated under the same _id can match both.
|
||||
const { plannedIncarnations } = require('../server/lib/scrumHistoryWriteState');
|
||||
test('a record recreated under the same _id is not taken for the original', () => {
|
||||
const current = { _id: 's1', boardId: 'b', name: 'Sprint', revision: 3, incarnation: 'life-1' };
|
||||
const before = historyDocument('scrum-sprint', current);
|
||||
assert.equal(before.incarnation, undefined, 'History content never carries an incarnation');
|
||||
// Deleting it: pending while the planned lifetime is there...
|
||||
assert.equal(state({ type: 'scrum-sprint', current, before, after: null, revision: 3,
|
||||
incarnation: { before: 'life-1', after: null } }), 'pending');
|
||||
// ...but a same-looking record from ANOTHER lifetime must not be deleted (negative).
|
||||
assert.throws(() => state({ type: 'scrum-sprint', current: { ...current, incarnation: 'life-2' }, before,
|
||||
after: null, revision: 3, incarnation: { before: 'life-1', after: null } }), /revision conflict/);
|
||||
// Creating it: only the incarnation this operation chose counts as its insert.
|
||||
const created = { ...current, revision: 1, incarnation: 'planned' };
|
||||
const plan = { type: 'scrum-sprint', before: null, after: before, revision: null,
|
||||
incarnation: { before: null, after: 'planned' } };
|
||||
assert.equal(state({ ...plan, current: null }), 'pending');
|
||||
assert.equal(state({ ...plan, current: created }), 'applied');
|
||||
assert.throws(() => state({ ...plan, current: { ...created, incarnation: 'someone-else' } }), /revision conflict/);
|
||||
// An update keeps the same lifetime.
|
||||
const updated = { ...current, name: 'Renamed', revision: 4 };
|
||||
const change = { type: 'scrum-sprint', before, after: historyDocument('scrum-sprint', updated), revision: 3,
|
||||
incarnation: { before: 'life-1', after: 'life-1' } };
|
||||
assert.equal(state({ ...change, current: updated }), 'applied');
|
||||
assert.throws(() => state({ ...change, current: { ...updated, incarnation: 'life-9' } }), /revision conflict/);
|
||||
});
|
||||
|
||||
test('checkpoints and records from before incarnations keep working; bad shapes are refused', () => {
|
||||
const legacy = { _id: 's1', boardId: 'b', name: 'Sprint', revision: 3 };
|
||||
const doc = historyDocument('scrum-sprint', legacy);
|
||||
assert.equal(state({ type: 'scrum-sprint', current: legacy, before: doc, after: null, revision: 3 }), 'pending',
|
||||
'a checkpoint without incarnations is judged as before');
|
||||
assert.equal(state({ type: 'scrum-sprint', current: legacy, before: doc, after: null, revision: 3,
|
||||
incarnation: { before: null, after: null } }), 'pending', 'a record without one is lifetime null');
|
||||
for (const incarnation of [null, {}, { before: 'x' }, { before: 1, after: null }, { before: '', after: null }]) {
|
||||
assert.throws(() => state({ type: 'scrum-sprint', current: legacy, before: doc, after: null, revision: 3, incarnation }), /revision conflict/);
|
||||
}
|
||||
});
|
||||
|
||||
test('a new checkpoint plans the lifetime each target has and will have', () => {
|
||||
let n = 0;
|
||||
const planned = plannedIncarnations({ newId: () => `new-${++n}`,
|
||||
targets: [{ type: 'scrum-sprint', document: { name: 'x' } }, { type: 'scrum-release', document: null },
|
||||
{ type: 'scrum-event', document: { name: 'y' } }, { type: 'card', document: { scrum: {} } }],
|
||||
current: [null, { incarnation: 'r-1' }, { incarnation: 'e-1' }, { _id: 'c' }] });
|
||||
assert.deepEqual(planned, [{ before: null, after: 'new-1' }, { before: 'r-1', after: null },
|
||||
{ before: 'e-1', after: 'e-1' }, { before: null, after: null }]);
|
||||
assert.throws(() => plannedIncarnations({ targets: [{}], current: [], newId: () => 'x' }), /revision conflict/);
|
||||
});
|
||||
|
||||
test('every place a Scrum record is created gives it a fresh incarnation', () => {
|
||||
const fs = require('node:fs'), path = require('node:path');
|
||||
const read = rel => fs.readFileSync(path.join(__dirname, '..', rel), 'utf8');
|
||||
assert.match(read('server/scrum.js'), /if \(!before\) \{[\s\S]*?after\.incarnation = Random\.id\(\);/);
|
||||
assert.match(read('server/lib/scrumTransferImport.js'), /revision: 1,\n\s*incarnation: Random\.id\(\),/);
|
||||
const history = read('server/lib/scrumHistory.js');
|
||||
assert.match(history, /incarnations: plannedIncarnations\(\{ targets, current, newId: \(\) => Random\.id\(\) \}\)/);
|
||||
assert.match(history, /\.\.\.\(incarnation\?\.after \? \{ incarnation: incarnation\.after \} : \{\}\)/);
|
||||
// Negative: an update never unsets it, although History content leaves it out.
|
||||
assert.match(history, /\['_id','revision','updatedAt','updatedBy','incarnation'\]\.includes\(key\)/);
|
||||
assert.doesNotMatch(read('models/lib/scrum.js'), /incarnation/, 'a client can not supply one');
|
||||
});
|
||||
Reference in new issue
Block a user