diff --git a/README.md b/README.md index cc398bc..866a99e 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ npm test - `assets.sendGuarded` - a batch as a compare-and-set on each asset's ChangeDate; changed assets come back `skipped` - `attachments.list` / `page` / `info` / `get` / `add` / `put` / `remove` / `url` - files attached to an asset: metadata on the Attachment asset, bytes through attachment.img; `page` adds the total and whether more pages follow; `get`'s `contentType` is the type the server sent the bytes with; `add` sends a small file inline (Base64, one request) and a large one in two steps (create, then upload), and a failed upload throws `AttachmentUploadError` naming the empty attachment it left - `embeddedImages.upload` / `url` / `normalize` - images in rich text: upload returns the `` URL; `normalize` rewrites `embedded.img` references to this instance's URL. They cannot be deleted - - `batch.run` / `batch.reversible` - preview, snapshot, (confirm,) send guarded, and a receipt with a reverse batch. Undo (`batch.reversible`) reactivates a closed asset before deleting it, and a second command on one asset is sent as a second guarded pass. `confirm` sees the touched assets' names; aliased updates/executes are sent as written with `guard: false`; a failure after the send throws `BatchRecordError` carrying the receipt + - `batch.run` / `batch.reversible` - preview, snapshot, (confirm,) send guarded, and a receipt with a reverse batch. Undo (`batch.reversible`) reactivates a closed asset before deleting it, and a second command on one asset is sent as a second guarded pass. The reverse uses only operations the type has in meta (a created Conversation, which has no `Delete`, is a note), and undoing an `Inactivate` or `QuickClose` reopens the descendants that close cascaded to (`receipt.openDescendants`, `receipt.cascaded`), parent first. A command the server reports as failed lands in `receipt.failed`, not `skipped`. `confirm` sees the touched assets' names; aliased updates/executes are sent as written with `guard: false`; a failure after the send throws `BatchRecordError` carrying the receipt - `TypeCache` - one instance's loaded type definitions, shared by many clients (`new AgilityApiClient(config, { types })`): a server that builds a client per user fetches each type once - `createFetchTransport` - configurable HTTP transport: `credentials` for same-origin cookie auth, `onUnauthorized` to re-authenticate on a 401 (resends idempotent requests once), and a per-request `signal`; `client.withSignal(signal)` ties a whole client to one - `ApiError` - typed API error; `CommandFailureError` - an api/asset batch in which commands failed (`failures` by index, plus the `response`) diff --git a/scripts/generate-types.mjs b/scripts/generate-types.mjs index 9697147..8cf40d6 100644 --- a/scripts/generate-types.mjs +++ b/scripts/generate-types.mjs @@ -75,7 +75,7 @@ export const META_TYPES = new Set(["AssetType", "AttributeDefinition", "Relation * hand-written type omits (unmodelled: information callers cannot reach). * Keys that are deliberately not modelled are listed in IGNORED. */ -const IGNORED = new Set(["HREF", "Version", "Operations", "Rules", "Description", "Name"]); +const IGNORED = new Set(["HREF", "Version", "Rules", "Description", "Name"]); export function checkHandwritten(metamodel, metamodelTs) { const types = metamodel.AssetTypes; const delivered = { AssetType: new Set(), AttributeDefinition: new Set() }; diff --git a/src/services/batch.ts b/src/services/batch.ts index ff81f9f..4362e3e 100644 --- a/src/services/batch.ts +++ b/src/services/batch.ts @@ -13,6 +13,12 @@ * which were skipped by the guard; and a reverse batch that undoes what * succeeded, best effort, with a note for anything it cannot. * + * Closing a workitem cascades: Core's Inactivate and QuickClose also close + * its open descendants, and Reactivate reopens only the asset it names. So + * before a batch that closes, the receipt records each closing target's + * open descendants; after it, the ones the close actually closed; and the + * reverse reopens the parent, then those, top down. + * * The snapshot keeps the endpoint's spelling (strings, relations as OID * tokens) on purpose: it is what a reverse batch writes back. */ @@ -138,19 +144,79 @@ export async function snapshot(transport: Transport, described: Described): Prom // ---- undoing it -------------------------------------------------------------------- /** The known inverse operations. Anything else is reported, not guessed. */ -export const INVERSE: Readonly> = { Inactivate: "Reactivate", Reactivate: "Inactivate", Delete: "Undelete", Undelete: "Delete" }; +export const INVERSE: Readonly> = { Inactivate: "Reactivate", QuickClose: "Reactivate", Reactivate: "Inactivate", Delete: "Undelete", Undelete: "Delete" }; + +/** Operations that close an asset's open descendants along with it (Core's close cascade). */ +export const CASCADING: ReadonlySet = new Set(["Inactivate", "QuickClose"]); + +/** How deep a cascade is followed: Epic → sub-Epic → Story → Task is four. */ +const CASCADE_DEPTH = 8; + +/** + * Each root's open descendants, top down (children, then grandchildren, …), + * through the Children relation of the types that have one. One bounded + * query per type and chunk for each level's children, and one for their + * states. A root whose type has no Children has none. + */ +export async function openDescendantsOf(transport: Transport, schema: Schema, roots: Oid[]): Promise> { + const out: Record = {}; + const rootOf = new Map(roots.map((r) => [momentless(r), momentless(r)])); + const seen = new Set(rootOf.keys()); + let frontier = [...rootOf.keys()]; + for (let depth = 0; depth < CASCADE_DEPTH && frontier.length; depth += 1) { + const children = new Map(); // child → its root + for (const [type, list] of byType(frontier)) { + await schema.load(type); + if (!schema.attribute(type, "Children")) continue; + for (const part of chunks(list)) { + const rows = await assetApi.query(transport, { from: type, select: ["Children"], filter: ["ID=$oids"], with: { $oids: part }, page: { size: part.length, start: 0 } }); + for (const r of rows) { + const parent = momentless(r._oid); + for (const c of ([] as unknown[]).concat(plain(r.Children) ?? [])) { + const child = momentless(String(c)); + if (seen.has(child)) continue; + seen.add(child); + children.set(child, rootOf.get(parent)!); + } + } + } + } + const states = children.size ? await statesOf(transport, [...children.keys()]) : {}; + frontier = []; + for (const [child, root] of children) { + if (states[child] === undefined || isClosed(states[child])) continue; // gone, or closed already: not the cascade's + rootOf.set(child, root); + (out[root] ??= []).push(child); + frontier.push(child); + } + } + return out; +} export interface Reverse { commands: Command[]; notes: string[] } +/** What reverseOf can know beyond the response, each optional. */ +export interface ReverseContext { + /** Per closing target, its open descendants before the batch (Receipt.openDescendants). */ + openDescendants?: Record; + /** Per closing target, the descendants the close closed (Receipt.cascaded); undefined when they could not be read back. */ + cascaded?: Record; + /** Can this type do this operation? Undefined is "don't know", and the command is kept. */ + canOperate?: (type: AssetTypeToken, op: string) => boolean | undefined; +} + /** * A batch that undoes what the response says succeeded — best effort. Only * what the response confirms is reversed (membership in it, not command * index, decides): updated attributes put back from the snapshot, the - * inverse operation where one is known, created assets deleted (newest - * first). + * inverse operation where one is known (and, for a close, the descendants + * it closed reopened after it, top down), created assets deleted (newest + * first). An operation a type does not have, by `context.canOperate`, is + * a note rather than a command the server would refuse. */ -export function reverseOf(described: Described, before: Record>, done: { created: Oid[]; modified: Oid[]; operated: Oid[] }): Reverse { +export function reverseOf(described: Described, before: Record>, done: { created: Oid[]; modified: Oid[]; operated: Oid[] }, context: ReverseContext = {}): Reverse { const commands: Command[] = [], notes: string[] = []; + const cannot = (oid: Oid, op: string) => context.canOperate?.(typeOfOid(oid), op) === false; const modified = new Set(done.modified.map(momentless)), operated = new Set(done.operated.map(momentless)); for (const u of described.updates) { if (!modified.has(u.oid)) continue; @@ -172,10 +238,26 @@ export function reverseOf(described: Described, before: Record; - /** Touched assets the guard left alone: changed or gone since the snapshot. */ + /** Touched assets the guard left alone: changed or gone since the snapshot. Not the ones whose command failed: those are in `failed`. */ skipped: Oid[]; + /** Touched assets whose command the server reports as failed (outcome.failures says why). */ + failed: Oid[]; + /** Per asset the batch closes (Inactivate, QuickClose): its open descendants at the snapshot, top down. */ + openDescendants: Record; + /** Per asset the batch closed: the descendants the close closed with it, read back after the send. */ + cascaded: Record; reverse: Reverse; /** What the record could not do and why: a name lookup that failed, deferred commands sent unsnapshotted. */ notes: string[]; @@ -266,12 +354,14 @@ export async function runBatch(transport: Transport, schema: Schema, commands: C const described = describe(commands, projected); const touched = touchedBy(projected); const names = await namesOf(transport, schema, touched, noteFailure); - const receipt: Receipt = { receipt: 1, request: commands, previewedAt, preview: projected, described, names: Object.fromEntries(names), before: {}, sent: false, guarded: false, created: [], touched: [], after: {}, skipped: [], reverse: { commands: [], notes: [] }, notes }; + const receipt: Receipt = { receipt: 1, request: commands, previewedAt, preview: projected, described, names: Object.fromEntries(names), before: {}, sent: false, guarded: false, created: [], touched: [], after: {}, skipped: [], failed: [], openDescendants: {}, cascaded: {}, reverse: { commands: [], notes: [] }, notes }; const nothing = !described.creates.length && !described.updates.length && !described.executes.length && !described.queries.length && !described.deferred.length; if (nothing) return receipt; for (const d of described.deferred) notes.push(`command ${d.index} (${d.alias}): an aliased ${d.kind}, which preview does not expand; sent as written, not snapshotted, not reversible`); receipt.snapshotAt = now(); receipt.before = await snapshot(transport, described); + const closing = described.executes.filter((e) => CASCADING.has(e.op)).map((e) => e.oid); + if (closing.length) receipt.openDescendants = await openDescendantsOf(transport, schema, closing); if (options.dryRun) return receipt; if (options.confirm && !(await options.confirm(receipt))) return receipt; @@ -309,17 +399,40 @@ export async function runBatch(transport: Transport, schema: Schema, commands: C const unnamed = { number: null, name: null }; receipt.created = done.created.map((oid, i) => ({ alias: described.creates[i]?.alias ?? null, type: described.creates[i]?.type ?? typeOfOid(oid), oid, ...unnamed })); receipt.touched = hit.map((oid) => ({ oid, ...(names.get(oid) ?? unnamed) })); - receipt.skipped = useGuard ? [...new Set([...unstamped, ...touched.filter((oid) => !hit.includes(oid))])] : []; - receipt.reverse = reverseOf(described, receipt.before, done); + // A failed command names its asset in what was sent: the guard's + // with.$oid, or the from of a per-asset command sent as written. + const sentAs = receipt.sentCommands ?? []; + const oidOfSent = (i: number | null): Oid | null => { + const body = i === null ? undefined : (sentAs[i] ? bodyOf(sentAs[i]!) : undefined); + const id = (body?.with as { $oid?: unknown } | undefined)?.$oid ?? body?.from; + return typeof id === "string" && id.includes(":") ? momentless(id) : null; + }; + receipt.failed = [...new Set(done.failures.map((f) => oidOfSent(f.index)).filter((oid): oid is Oid => !!oid && touched.includes(oid) && !hit.includes(oid)))]; + receipt.skipped = useGuard ? [...new Set([...unstamped, ...touched.filter((oid) => !hit.includes(oid) && !receipt.failed.includes(oid))])] : []; + // What each type can do, from the definitions already loaded (or loadable): + // an unknown answer keeps the command, as before. + for (const type of new Set([...done.created, ...hit].map((oid) => typeOfOid(momentless(oid))))) await schema.load(type).catch(() => null); + const canOperate = (type: AssetTypeToken, op: string) => schema.canOperate(type, op); + const operatedNow = new Set(done.operated.map(momentless)); + const closed = Object.keys(receipt.openDescendants).filter((oid) => operatedNow.has(oid) && receipt.openDescendants[oid]!.length); + // Until the cascade is read back, the reverse says what it could not reopen. + receipt.reverse = reverseOf(described, receipt.before, done, { openDescendants: receipt.openDescendants, cascaded: closed.length ? undefined : {}, canOperate }); // Decoration, read back from the server. Names are cosmetic (a failure is // a note); the after stamps are not, so a failure there throws with the // receipt, outcome and all. try { + if (closed.length) { + // The descendants the close closed: open at the snapshot, closed now. + const states = await statesOf(transport, closed.flatMap((oid) => receipt.openDescendants[oid]!)); + receipt.cascaded = Object.fromEntries(closed.map((oid) => [oid, receipt.openDescendants[oid]!.filter((d) => isClosed(states[d]))])); + receipt.reverse = reverseOf(described, receipt.before, done, { openDescendants: receipt.openDescendants, cascaded: receipt.cascaded, canOperate }); + } const more = await namesOf(transport, schema, [...done.created, ...hit.filter((oid) => !names.has(oid))], noteFailure); receipt.created = receipt.created.map((c) => ({ ...c, ...(more.get(c.oid) ?? unnamed) })); receipt.touched = receipt.touched.map((t) => (names.has(t.oid) ? t : { ...t, ...(more.get(t.oid) ?? unnamed) })); - const all = [...done.created, ...hit]; + // The cascade's descendants too: a later reverse reopens them, and checks them for changes first. + const all = [...new Set([...done.created, ...hit, ...Object.values(receipt.cascaded).flat()])]; receipt.after = all.length ? await stampsOf(transport, all) : {}; } catch (error) { throw new BatchRecordError(error, receipt); diff --git a/src/services/schema.ts b/src/services/schema.ts index 9f174de..11628e2 100644 --- a/src/services/schema.ts +++ b/src/services/schema.ts @@ -70,6 +70,16 @@ export class Schema implements AttributeLookup { return attributeOf(this, type, name); } + /** + * Can the type do this operation (Delete, Reactivate, …)? Undefined when + * the type is not loaded or its definition carries no operations, so a + * caller can tell "no" from "don't know". + */ + canOperate(type: AssetTypeToken, op: string): boolean | undefined { + const t = this.assetType(type); + return t?.Operations ? t.Operations.includes(op) : undefined; + } + /** The tokens loaded so far. */ get loaded(): AssetTypeToken[] { return [...this.types.entries()].filter(([, t]) => t !== null).map(([token]) => token); diff --git a/src/types/metamodel.ts b/src/types/metamodel.ts index 5c32c41..d315258 100644 --- a/src/types/metamodel.ts +++ b/src/types/metamodel.ts @@ -60,6 +60,8 @@ export interface AssetType { /** The attribute that carries the short name (Number on workitems). */ ShortName?: AttributeTokenRef; Attributes: Record; + /** The operations the type supports, by bare name (Delete, Inactivate, …); delivered keyed by token (Story.Delete). */ + Operations?: string[]; } export type AssetTypeMap = Record; diff --git a/src/wire/meta.ts b/src/wire/meta.ts index f12820b..27c8e6b 100644 --- a/src/wire/meta.ts +++ b/src/wire/meta.ts @@ -103,7 +103,8 @@ export function toAssetType(doc: AssetTypeDocument): AssetType { ...(doc.DefaultOrderBy ? { DefaultOrderBy: doc.DefaultOrderBy.tokenref } : {}), ...(doc.DefaultDisplayBy ? { DefaultDisplayBy: doc.DefaultDisplayBy.tokenref } : {}), ...(doc.ShortName ? { ShortName: doc.ShortName.tokenref } : {}), - Attributes: Object.fromEntries(Object.entries(doc.Attributes ?? {}).map(([key, value]) => [key, toAttributeDefinition(value)])) + Attributes: Object.fromEntries(Object.entries(doc.Attributes ?? {}).map(([key, value]) => [key, toAttributeDefinition(value)])), + ...(doc.Operations ? { Operations: Object.keys(doc.Operations).map((token) => token.slice(token.indexOf(".") + 1)) } : {}) }; } diff --git a/test/integration/batch.test.ts b/test/integration/batch.test.ts index 9b6c646..8d2f79f 100644 --- a/test/integration/batch.test.ts +++ b/test/integration/batch.test.ts @@ -93,4 +93,53 @@ describeInstance("batch (live)", () => { expect(reversed.skipped).toEqual([]); expect(await assetApi.query(t, { from: "Story", select: ["Name"], filter: ["ID=$s"], with: { $s: story }, page: { size: 1, start: 0 } })).toEqual([]); }); + + const stateOf = async (oid: string) => (await assetApi.query<{ _oid: string; AssetState: string }>(t, { from: oid, select: ["AssetState"], page: { size: 1, start: 0 } }))[0]?.AssetState; + + for (const op of ["Inactivate", "QuickClose"] as const) { + it(`undoing ${op} on a Story reopens the Story and the open Tasks its close cascaded to, and leaves a Task that was closed before alone (gh-v1#48)`, async () => { + const res = await assetApi.send(t, [{ AssetType: "Story", Name: fixtureName(`cascade ${op}`), Scope: { _oid: "Scope:0" } }], { throwOnFailure: true }); + const story = assetApi.momentless(res.assetsCreated.oidTokens[0]!); + created.push(story); + const tasks = await assetApi.send(t, [ + { AssetType: "Task", Name: fixtureName("cascade open"), Parent: { _oid: story } }, + { AssetType: "Task", Name: fixtureName("cascade closed before"), Parent: { _oid: story } } + ], { throwOnFailure: true }); + const [open, closedBefore] = tasks.assetsCreated.oidTokens.map(assetApi.momentless) as [string, string]; + created.push(open, closedBefore); + await assetApi.send(t, [{ from: closedBefore, execute: "QuickClose" }], { throwOnFailure: true }); + + const receipt = await client.batch.run([{ from: story, execute: op }]); + expect(receipt.outcome!.failures).toEqual([]); + expect(receipt.openDescendants).toEqual({ [story]: [open] }); + expect(await stateOf(open)).toBe("Closed"); // the server's cascade, which the receipt must undo + expect(receipt.cascaded).toEqual({ [story]: [open] }); + expect(receipt.reverse.commands).toEqual([{ from: story, execute: "Reactivate" }, { from: open, execute: "Reactivate" }]); + + const { commands, drifted } = await client.batch.reversible(receipt); + expect(drifted).toEqual([]); + const undone = await client.batch.run(commands); + expect(undone.outcome!.failures).toEqual([]); + expect(undone.skipped).toEqual([]); + expect(await stateOf(story)).toBe("Active"); + expect(await stateOf(open)).toBe("Active"); + expect(await stateOf(closedBefore)).toBe("Closed"); + }); + } + + it("a created Conversation is left out of the reverse with a note, and undoing the batch deletes its Expression without a failure (gh-v1#21)", async () => { + const receipt = await client.batch.run([ + { "@c": { AssetType: "Conversation" } }, + { "@e": { AssetType: "Expression", BelongsTo: "@c", Content: fixtureName("reverse conversation") } } + ]); + expect(receipt.outcome!.failures).toEqual([]); + const [conversation, expression] = [receipt.created[0]!.oid, receipt.created[1]!.oid]; + expect(conversation).toMatch(/^Conversation:/); + expect(receipt.reverse.commands).toEqual([{ from: expression, execute: "Delete" }]); + expect(receipt.reverse.notes.join("\n")).toMatch(new RegExp(`${conversation}: Conversation has no Delete operation`)); + const undone = await client.batch.run((await client.batch.reversible(receipt)).commands); + expect(undone.outcome!.failures).toEqual([]); + expect(undone.failed).toEqual([]); + expect(await assetApi.query(t, { from: "Expression", select: ["Content"], filter: ["ID=$e"], with: { $e: expression }, page: { size: 1, start: 0 } })).toEqual([]); + }); }); diff --git a/test/unit/batch.test.ts b/test/unit/batch.test.ts index 65963b5..6519ef9 100644 --- a/test/unit/batch.test.ts +++ b/test/unit/batch.test.ts @@ -89,12 +89,80 @@ group("batch", () => { it("reverseOf restores only what the response confirms: a null scalar is restored as null, a value already present is not removed, an unknown operation is a note", () => { const d = describe([], [ { "@a": { oid: "Story:2", update: { Estimate: { add: ["5"], remove: [] }, Owners: { add: ["Member:20"], remove: [] } } } }, - { "@b": { oid: "Epic:9", execute: { op: "QuickClose", args: {} } } }, + { "@b": { oid: "Epic:9", execute: { op: "Split", args: {} } } }, { "@c": { oid: "Story:3", update: { Name: { add: ["x"], remove: [] } } } } ] as unknown as Command[]); const r = reverseOf(d, { "Story:2": { Estimate: null, Owners: ["Member:20"] }, "Epic:9": { AssetState: "Active" } }, { created: [], modified: ["Story:2"], operated: ["Epic:9"] }); expect(r.commands).toEqual([{ from: "Story:2", update: { Estimate: null } }]); - expect(r.notes.slice(1)).toEqual(["Epic:9: QuickClose has no known inverse (was Active)"]); + expect(r.notes.slice(1)).toEqual(["Epic:9: Split has no known inverse (was Active)"]); + }); + + it("an asset whose command failed is failed, not also skipped as changed since the snapshot (gh-v1#21)", async () => { + const preview = [{ "@x": { oid: "Conversation:12", execute: { op: "Delete", args: {} } } }, { "@y": { oid: "Epic:9", execute: { op: "Inactivate", args: {} } } }] as unknown as Command[]; + const t = server({ + preview, + lookup: (q) => q.with!.$oids!.map((oid) => (q.select!.includes("Name") ? { _oid: oid } : q.select!.includes("Children") ? { _oid: oid, Children: [] } : { _oid: oid, AssetState: "Active", ChangeDate: "2026-11-11T09:00:00.1" })), + send: () => envelope({ assetsOperatedOn: { oidTokens: [], count: 0 }, commandFailures: { commands: [{ error: { sourceCommandIndex: 0, message: "Unknown Operation 'Conversation.Delete'" } }], count: 1 } }) + }); + const r = await runBatch(t, schema, [{ from: "Conversation:12", execute: "Delete" }, { from: "Epic:9", execute: "Inactivate" }]); + expect(r.failed).toEqual(["Conversation:12"]); + expect(r.skipped).toEqual(["Epic:9"]); // matched nothing: changed since the snapshot + }); + + it("reverseOf leaves out an operation the type does not have, by meta's Operations, and says so: a created Conversation is not deleted (gh-v1#21)", () => { + const d = describe([{ "@c": { AssetType: "Conversation" } }, { "@e": { AssetType: "Expression", BelongsTo: "@c", Content: "x" } }], []); + const can = (type: string, op: string) => schema.canOperate(type, op); + expect(can("Conversation", "Delete")).toBe(false); + expect(can("Expression", "Delete")).toBe(true); + expect(can("NoSuchType", "Delete")).toBeUndefined(); // not loaded: don't know, keep the command + const r = reverseOf(d, {}, { created: ["Conversation:12:300", "Expression:13:301"], modified: [], operated: [] }, { canOperate: can }); + expect(r.commands).toEqual([{ from: "Expression:13", execute: "Delete" }]); + expect(r.notes.join("\n")).toMatch(/Conversation:12: Conversation has no Delete operation, so it is not deleted \(the server removes a Conversation once its last Expression is deleted\)/); + // Without the schema's answer, the old behaviour: delete everything created. + expect(reverseOf(d, {}, { created: ["Conversation:12", "Expression:13"], modified: [], operated: [] }).commands).toHaveLength(2); + }); + + it("reverseOf reopens what a close cascaded to, after the parent and top down; unread, it says what it could not reopen (gh-v1#48)", () => { + const d = describe([], [{ "@x": { oid: "Story:1", execute: { op: "QuickClose", args: {} } } }] as unknown as Command[]); + const done = { created: [], modified: [], operated: ["Story:1"] }; + const open = { "Story:1": ["Task:5", "Test:7", "Task:6"] }; + const r = reverseOf(d, { "Story:1": { AssetState: "Active" } }, done, { openDescendants: open, cascaded: { "Story:1": ["Task:5", "Test:7"] } }); + expect(r.commands).toEqual([{ from: "Story:1", execute: "Reactivate" }, { from: "Task:5", execute: "Reactivate" }, { from: "Test:7", execute: "Reactivate" }]); + expect(r.notes.join("\n")).toMatch(/Story:1: QuickClose also closed Task:5, Test:7; reopened after it/); + const unread = reverseOf(d, {}, done, { openDescendants: open }); + expect(unread.commands).toEqual([{ from: "Story:1", execute: "Reactivate" }]); + expect(unread.notes.join("\n")).toMatch(/Story:1: QuickClose may also have closed 3 open descendants \(Task:5, Test:7, Task:6\); they could not be read back/); + }); + + it("runBatch on a close: records the open descendants top down before, the ones the close closed after, stamps them, and reverses parent first (gh-v1#48)", async () => { + // Epic:9 → Story:1 → Task:5 (open) and Task:6 (closed already); Inactivate on the Epic closes Story:1 and Task:5. + const children: Record = { "Epic:9": ["Story:1"], "Story:1": ["Task:5", "Task:6"], "Task:5": [], "Task:6": [] }; + let state: Record = { "Epic:9": "Active", "Story:1": "Active", "Task:5": "Active", "Task:6": "Closed" }; + const preview = [{ "@x": { oid: "Epic:9", execute: { op: "Inactivate", args: {} } } }] as unknown as Command[]; + const t = server({ + preview, + lookup: (q) => q.with!.$oids!.map((oid) => { + const sel = q.select!; + if (sel.length === 1 && sel[0] === "Children") return { _oid: oid, Children: children[oid]!.map((c) => ({ _oid: c })) }; + if (sel.length === 1 && sel[0] === "AssetState") return { _oid: oid, AssetState: state[oid] }; + if (sel.length === 1 && sel[0] === "ChangeDate") return { _oid: oid, ChangeDate: `2026-11-11T10:00:00.${oid.length}` }; + if (sel.includes("Name")) return { _oid: oid, Name: oid, Number: oid }; + return { _oid: oid, AssetState: state[oid], ChangeDate: "2026-11-11T09:00:00.1" }; + }), + send: () => { state = { ...state, "Epic:9": "Closed", "Story:1": "Closed", "Task:5": "Closed" }; return envelope({ assetsOperatedOn: { oidTokens: ["Epic:9"], count: 1 } }); } + }); + const r = await runBatch(t, schema, [{ from: "Epic:9", execute: "Inactivate" }]); + expect(r.openDescendants).toEqual({ "Epic:9": ["Story:1", "Task:5"] }); // Task:6 was closed already: not the cascade's + expect(r.cascaded).toEqual({ "Epic:9": ["Story:1", "Task:5"] }); + expect(Object.keys(r.after).sort()).toEqual(["Epic:9", "Story:1", "Task:5"]); // so a later reverse can check them for drift + expect(r.reverse.commands).toEqual([ + { from: "Epic:9", execute: "Reactivate" }, + { from: "Story:1", execute: "Reactivate" }, + { from: "Task:5", execute: "Reactivate" } + ]); + // A preview records the open descendants too, and sends nothing. + state = { "Epic:9": "Active", "Story:1": "Active", "Task:5": "Active", "Task:6": "Closed" }; + expect((await runBatch(t, schema, [{ from: "Epic:9", execute: "Inactivate" }], { dryRun: true })).openDescendants).toEqual({ "Epic:9": ["Story:1", "Task:5"] }); }); it("dryRun and a declined confirm send nothing; an unguardable batch is refused before any request unless guard is off", async () => {