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 () => {