From 70d0d9867932e2109f2a8d6b6dabf5beb200ee9d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 02:57:08 +0000 Subject: [PATCH 1/2] batch: reverse with the type's operations and the close cascade - reverseOf takes the type's Operations from meta. Schema carries them now (AssetType.Operations, Schema.canOperate). A created asset whose type has no Delete (Conversation lists only Join and Leave) gets a note instead of a command the server refuses. An inverse operation the type lacks is handled the same way. (versionone/gh-v1#21) - An asset whose command the server reports as failed goes in receipt.failed and is no longer also listed in skipped as "changed since the snapshot". (versionone/gh-v1#21) - Undoing Inactivate or QuickClose now reopens what the close cascaded to. Before the send, the receipt records each closing target's open descendants, top down through Children (openDescendants). After it, the receipt records the ones now closed (cascaded), and the reverse reactivates the parent and then those, in that order. Their after-stamps are recorded too, so batch.reversible checks them for drift. If they can't be read back, the reverse notes what it did not reopen. QuickClose gets Reactivate as its inverse. (versionone/gh-v1#48) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016NSWj8mSZ8LrMj16R2cozL --- README.md | 2 +- scripts/generate-types.mjs | 2 +- src/services/batch.ts | 137 +++++++++++++++++++++++++++++++++---- src/services/schema.ts | 10 +++ src/types/metamodel.ts | 2 + src/wire/meta.ts | 3 +- test/unit/batch.test.ts | 72 ++++++++++++++++++- 7 files changed, 211 insertions(+), 17 deletions(-) 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/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 () => { From 1b52c578f0f8b2b1994b2a4859102a6805c98746 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 02:58:36 +0000 Subject: [PATCH 2/2] Live tests for the cascade-aware reverse and the Conversation note Run against v1sdktesting. Inactivate and QuickClose on a Story close its open Task as well. The receipt records that Task as cascaded, and the reverse reopens the Story and then the Task. A Task that was closed before the batch stays closed. A created Conversation is left out of the reverse with a note, and undoing its Expression reports no failure. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_016NSWj8mSZ8LrMj16R2cozL --- test/integration/batch.test.ts | 49 ++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) 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([]); + }); });