diff --git a/README.md b/README.md index 1d72302..a7ad12a 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,7 @@ npm test - `queryV1.query` / `queryV1.queries` - reads through query.v1: the same query bodies as `assets.query`, rows translated the same way, but the endpoint is read-only and answers with rows alone, so it is the safe place to explore a query - `resolve.numbers` / `resolve.ref` - asset numbers and OID tokens to assets, in one request - `assets.sendGuarded` - a batch as a compare-and-set on each asset's ChangeDate; changed assets come back `skipped` - - `batch.run` / `batch.reversible` - preview, snapshot, (confirm,) send guarded, and a receipt with a reverse batch + - `batch.run` / `batch.reversible` - preview, snapshot, (confirm,) send guarded, and a receipt with a reverse batch. `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 - `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/plans/001-sdk-adoption-campaign.md b/plans/001-sdk-adoption-campaign.md index 829ea14..c1770f5 100644 --- a/plans/001-sdk-adoption-campaign.md +++ b/plans/001-sdk-adoption-campaign.md @@ -360,9 +360,10 @@ ESM" here means finishing the edges: - [x] A4 slack-shaped reads — `queryV1` wire module, `client.queryV1.query`/`queries` (read-only, idempotent, same row translation as api/asset) - [x] A5 services port from gh-v1 — `resolve` (`client.resolve.numbers`/`.ref`), `guard` (`client.assets.sendGuarded`), `batch` (`client.batch.run`/`.reversible`) - [ ] A6 CI + prerelease publish — CI (`.github/workflows/ci.yml`: verify on Node 20/22/24, `npm pack` artifact) and git-SHA installs (`prepare`) done; publish waits on the registry decision -- [ ] G0–G3 gh-v1 migrations (gh-v1 session) -- [ ] S1–S4 slack-bot migrations (slack-bot session, `bolt-migration`) -- [ ] B1–B4 slack bot SDK swap — brief: `plans/003-adopt-sdk-k6i-agility-slack-bot.md` +- [x] G0–G3 gh-v1 migrations — versionone/gh-v1#29–#32 +- [x] S1–S4 slack-bot migrations — digital-ai/k6i-agility-slack-bot#21–#24 (on `bolt-migration`) +- [ ] B1–B4 slack bot SDK swap — brief: `plans/003-adopt-sdk-k6i-agility-slack-bot.md` (unblocked: S2, S3 and SDK #42 merged) - [x] C0 gh-v1 surveyed (2026-11-04) -- [ ] C1–C4 gh-v1 — brief: `plans/002-adopt-sdk-gh-v1.md`; C1 in versionone/gh-v1#33 (SDK vendored) +- [x] C1 gh-v1 seam swap — versionone/gh-v1#33, with the vendored SDK's hashes, CI and Node 22 floor in #35 +- [ ] C2–C4 gh-v1 — brief: `plans/002-adopt-sdk-gh-v1.md` (C2–C3 in progress in a gh-v1 session) - [ ] D1–D4 agility-rooms diff --git a/scripts/generate-types.mjs b/scripts/generate-types.mjs index 6ce4846..9697147 100644 --- a/scripts/generate-types.mjs +++ b/scripts/generate-types.mjs @@ -41,7 +41,7 @@ const SCALAR = { Opaque: "string", Guid: "string", Duration: "string", - Rank: "string", + Rank: "number", Date: "DateString", Numeric: "number", LongInt: "number", diff --git a/specs/000-layers.md b/specs/000-layers.md index 9df040c..f08332e 100644 --- a/specs/000-layers.md +++ b/specs/000-layers.md @@ -61,11 +61,13 @@ one, is not decidable from the value: it takes the attribute's definition. So the `assetApi` wire module translates a row against an `AttributeLookup` (`src/types/metamodel.ts`): each key is a select path resolved hop by hop (`src/wire/select.ts` — dotted paths, `:Downcast`, `[filter]`, `@Count`) -to the attribute it lands on; Numeric and LongInt become numbers, Boolean -booleans, a single relation one Ref or null, an aggregate a number, and a +to the attribute it lands on; Numeric, LongInt and Rank become numbers, Boolean +booleans, a single relation one Ref or null (the endpoint sends an unset +one as `{ _oid: "NULL" }`), an aggregate a number, and a sub-select's rows are translated by their own type. A key the schema does not know is left as sent. Without a lookup the row keeps the endpoint's -spelling, OIDs normalised — never silently guessed. +spelling, OIDs normalised and a `NULL` relation null — never silently +guessed. The lookup is the `schema` service: the instance's types, loaded from meta.v1 one type at a time as queries need them (in-flight requests shared, diff --git a/src/index.ts b/src/index.ts index 0bf0ebf..7da241f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,5 +28,5 @@ export { CHUNK as RESOLVE_CHUNK, NUMBER_ROOTS, isAssetNumber, resolveNumbers, re export type { Resolution, ResolvedAsset } from "./services/resolve"; export { UNGUARDABLE_TYPES, UnguardableError, guard, sendGuarded, stampsOf, touchedBy, unguardable } from "./services/guard"; export type { GuardedResult, Unguardable } from "./services/guard"; -export { BatchSendError, INVERSE, describe as describeBatch, namesOf, reverseOf, reversible, runBatch, snapshot } from "./services/batch"; -export type { BatchOptions, Described, Named, Receipt, Reverse } from "./services/batch"; +export { BatchRecordError, BatchSendError, INVERSE, describe as describeBatch, namesOf, reverseOf, reversible, runBatch, snapshot } from "./services/batch"; +export type { BatchOptions, Described, NameLookupFailure, Named, Receipt, Reverse } from "./services/batch"; diff --git a/src/services/batch.ts b/src/services/batch.ts index 9db8393..7b809d5 100644 --- a/src/services/batch.ts +++ b/src/services/batch.ts @@ -35,16 +35,23 @@ export interface Described { queries: { index: number; from: string }[]; updates: { oid: Oid; attributes: Record }[]; executes: { oid: Oid; op: string; args: Record }[]; + /** + * Aliased updates and executes: preview does not expand them, so they + * have no per-asset entry above, no snapshot and no reverse. They are + * work all the same, sent as written (only with guard: false). + */ + deferred: { index: number; alias: string; from: string; kind: "update" | "execute" }[]; } const bodyOf = (c: Command): Record => aliased(c)?.body ?? (c as Record); /** What a batch does: creates and queries read off the request; updates and executes, per asset, off the projection. */ export function describe(commands: Command[], projected: Command[]): Described { - const out: Described = { creates: [], queries: [], updates: [], executes: [] }; + const out: Described = { creates: [], queries: [], updates: [], executes: [], deferred: [] }; commands.forEach((c, index) => { const a = aliased(c), body = bodyOf(c); - if (typeof body.AssetType === "string") out.creates.push({ index, alias: a?.alias ?? null, type: body.AssetType, name: typeof body.Name === "string" ? body.Name : null }); + if (a && typeof body.from === "string" && ("update" in body || "execute" in body)) out.deferred.push({ index, alias: a.alias, from: body.from, kind: "update" in body ? "update" : "execute" }); + else if (typeof body.AssetType === "string") out.creates.push({ index, alias: a?.alias ?? null, type: body.AssetType, name: typeof body.Name === "string" ? body.Name : null }); else if (typeof body.from === "string" && !("update" in body) && !("execute" in body)) out.queries.push({ index, from: body.from }); }); for (const p of projected) { @@ -74,23 +81,35 @@ const plain = (v: unknown): unknown => export interface Named { number: string | null; name: string | null } +/** Told about a name lookup that failed: the type, the assets left unnamed, and why. */ +export type NameLookupFailure = (failure: { type: AssetTypeToken; oids: Oid[]; error: unknown }) => void; + /** * Numbers and names, one bounded query per type and chunk, asking each type * only for what the schema says it has (a Conversation has neither, and - * asking fails the query). Cosmetic: a lookup the server refuses leaves - * those assets unnamed rather than failing a batch that may already be sent. + * asking fails the query). Cosmetic: a lookup that fails leaves those assets + * unnamed rather than failing a batch that may already be sent, and is + * reported to `onFailure` so a caller can say so. */ -export async function namesOf(transport: Transport, schema: Schema, oids: Iterable): Promise> { +export async function namesOf(transport: Transport, schema: Schema, oids: Iterable, onFailure?: NameLookupFailure): Promise> { const out = new Map(); for (const [type, list] of byType(oids)) { - await schema.load(type); - const select = ["Name", "Number"].filter((a) => schema.attribute(type, a)); + let select: string[]; + try { + await schema.load(type); + select = ["Name", "Number"].filter((a) => schema.attribute(type, a)); + } catch (error) { + onFailure?.({ type, oids: list, error }); + continue; + } if (!select.length) continue; for (const part of chunks(list)) { try { const rows = await assetApi.query(transport, { from: type, select, filter: ["ID=$oids"], with: { $oids: part }, page: { size: part.length, start: 0 } }); for (const r of rows) out.set(momentless(r._oid), { number: r.Number ?? null, name: r.Name ?? null }); - } catch { /* cosmetic: leave these unnamed */ } + } catch (error) { + onFailure?.({ type, oids: part, error }); + } } } return out; @@ -157,6 +176,7 @@ export function reverseOf(described: Described, before: Record; snapshotAt?: string; /** Before values per touched asset, ChangeDate included: what the guard compared and the reverse restores. */ before: Record>; @@ -187,6 +209,24 @@ export interface Receipt { /** Touched assets the guard left alone: changed or gone since the snapshot. */ skipped: Oid[]; reverse: Reverse; + /** What the record could not do and why: a name lookup that failed, deferred commands sent unsnapshotted. */ + notes: string[]; +} + +/** + * The batch was sent and the server's outcome is on the receipt + * (`receipt.sent`, `receipt.outcome`, `receipt.reverse`), but reading the + * assets back afterwards failed, so `receipt.after` is incomplete: keep the + * receipt, and know that a reverse built from it cannot check every asset + * for changes made since. + */ +export class BatchRecordError extends Error { + readonly receipt: Receipt; + constructor(cause: unknown, receipt: Receipt) { + super(`the batch was sent and its outcome recorded, but reading the assets back afterwards failed, so receipt.after is incomplete: ${cause instanceof Error ? cause.message : String(cause)}`, { cause }); + this.name = "BatchRecordError"; + this.receipt = receipt; + } } /** The send itself failed (a timeout, a 5xx): whether the server applied it is unknown. The receipt holds everything up to the attempt. */ @@ -204,7 +244,7 @@ export interface BatchOptions { guard?: boolean; /** Stop after the snapshot and send nothing: the receipt says what would happen. */ dryRun?: boolean; - /** Asked after the snapshot, before the send; false sends nothing. */ + /** Asked after the snapshot, before the send, with the touched assets' names on the receipt; false sends nothing. */ confirm?: (receipt: Receipt) => boolean | Promise; } @@ -218,14 +258,18 @@ export async function runBatch(transport: Transport, schema: Schema, commands: C const fatal = refused.filter((r) => r.reason.includes("rejected by the server")); if (fatal.length || (useGuard && refused.length)) throw new UnguardableError(useGuard ? refused : fatal); + const notes: string[] = []; + const noteFailure: NameLookupFailure = ({ type, oids, error }) => + notes.push(`could not look up Name for ${oids.length} ${type} asset${oids.length === 1 ? "" : "s"}: ${error instanceof Error ? error.message : String(error)}`); const previewedAt = now(); const projected = await assetApi.preview(transport, commands); const described = describe(commands, projected); const touched = touchedBy(projected); - const names = await namesOf(transport, schema, touched); - const receipt: Receipt = { receipt: 1, request: commands, previewedAt, preview: projected, described, before: {}, sent: false, guarded: false, created: [], touched: [], after: {}, skipped: [], reverse: { commands: [], notes: [] } }; - const nothing = !described.creates.length && !described.updates.length && !described.executes.length && !described.queries.length; + 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 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); if (options.dryRun) return receipt; @@ -248,15 +292,26 @@ export async function runBatch(transport: Transport, schema: Schema, commands: C receipt.sent = true; receipt.outcome = done; - // Everything below decorates a response already recorded; a failed lookup cannot cost the record. - const createdNames = await namesOf(transport, schema, done.created); - receipt.created = done.created.map((oid, i) => ({ alias: described.creates[i]?.alias ?? null, type: described.creates[i]?.type ?? typeOfOid(oid), oid, ...(createdNames.get(oid) ?? { number: null, name: null }) })); + // The record, from the response alone: nothing below this block can lose it. const hit = [...new Set([...done.modified, ...done.operated])]; - receipt.touched = hit.map((oid) => ({ oid, ...(names.get(oid) ?? { number: null, name: null }) })); - const all = [...done.created, ...hit]; - receipt.after = all.length ? await stampsOf(transport, all) : {}; + 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); + + // 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 { + 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]; + receipt.after = all.length ? await stampsOf(transport, all) : {}; + } catch (error) { + throw new BatchRecordError(error, receipt); + } return receipt; } diff --git a/src/types/assets/BusinessRule.ts b/src/types/assets/BusinessRule.ts index fa4e2d7..4211a25 100644 --- a/src/types/assets/BusinessRule.ts +++ b/src/types/assets/BusinessRule.ts @@ -10,5 +10,5 @@ export interface BusinessRule extends Asset { readonly Key?: string | null; // read-only readonly Moment?: string | null; // read-only readonly Now?: Ref | null; // read-only - Order?: string | null; + Order?: number | null; } diff --git a/src/types/assets/Epic.ts b/src/types/assets/Epic.ts index 8e7784f..d22bb0d 100644 --- a/src/types/assets/Epic.ts +++ b/src/types/assets/Epic.ts @@ -41,7 +41,7 @@ export interface Epic extends Workitem { readonly Moment?: string | null; // read-only readonly MorphedFrom?: Ref | null; // read-only readonly Now?: Ref | null; // read-only - Order?: string | null; + Order?: number | null; PlannedCompletionBy?: Ref | null; PlannedEnd?: DateString | null; PlannedRelease?: Ref | null; diff --git a/src/types/assets/ExternalAction.ts b/src/types/assets/ExternalAction.ts index ba9e557..8f000c3 100644 --- a/src/types/assets/ExternalAction.ts +++ b/src/types/assets/ExternalAction.ts @@ -31,7 +31,7 @@ export interface ExternalAction extends Asset { readonly Moment?: string | null; // read-only Name: string; // required readonly Now?: Ref | null; // read-only - Order?: string | null; + Order?: number | null; Payload: string; // required readonly Prior?: Ref | null; // read-only readonly RetireComment?: string | null; // read-only diff --git a/src/types/assets/Issue.ts b/src/types/assets/Issue.ts index 0a861b3..d542183 100644 --- a/src/types/assets/Issue.ts +++ b/src/types/assets/Issue.ts @@ -35,7 +35,7 @@ export interface Issue extends BaseAsset { readonly Moment?: string | null; // read-only readonly Now?: Ref | null; // read-only readonly Number?: string | null; // read-only - Order?: string | null; + Order?: number | null; Owner?: Ref | null; PrimaryWorkitems: Ref[]; Priority?: Ref | null; diff --git a/src/types/assets/List.ts b/src/types/assets/List.ts index ae99896..fb6b890 100644 --- a/src/types/assets/List.ts +++ b/src/types/assets/List.ts @@ -35,7 +35,7 @@ export interface List extends Asset { readonly Moment?: string | null; // read-only Name: string; // required readonly Now?: Ref | null; // read-only - Order?: string | null; + Order?: number | null; readonly Prior?: Ref | null; // read-only readonly RetireComment?: string | null; // read-only readonly RetireDate?: DateString | null; // read-only diff --git a/src/types/assets/Okr.ts b/src/types/assets/Okr.ts index 76c52a3..5303486 100644 --- a/src/types/assets/Okr.ts +++ b/src/types/assets/Okr.ts @@ -19,5 +19,5 @@ export interface Okr extends BaseAsset { readonly Moment?: string | null; // read-only readonly Now?: Ref | null; // read-only readonly Number?: string | null; // read-only - Order?: string | null; + Order?: number | null; } diff --git a/src/types/assets/PrimaryWorkitem.ts b/src/types/assets/PrimaryWorkitem.ts index e57ffe8..49e73f8 100644 --- a/src/types/assets/PrimaryWorkitem.ts +++ b/src/types/assets/PrimaryWorkitem.ts @@ -43,7 +43,7 @@ export interface PrimaryWorkitem extends Workitem { readonly Moment?: string | null; // read-only readonly Now?: Ref | null; // read-only readonly OpenEstimate?: number | null; // read-only - Order?: string | null; + Order?: number | null; Priority?: Ref | null; Release?: Ref | null; Requests: Ref[]; diff --git a/src/types/assets/RegressionTest.ts b/src/types/assets/RegressionTest.ts index eac153d..5d49411 100644 --- a/src/types/assets/RegressionTest.ts +++ b/src/types/assets/RegressionTest.ts @@ -30,7 +30,7 @@ export interface RegressionTest extends BaseAsset { readonly Moment?: string | null; // read-only readonly Now?: Ref | null; // read-only readonly Number?: string | null; // read-only - Order?: string | null; + Order?: number | null; Owners: Ref[]; Reference?: string | null; RegressionSuites: Ref[]; diff --git a/src/types/assets/Request.ts b/src/types/assets/Request.ts index 3148481..b30c641 100644 --- a/src/types/assets/Request.ts +++ b/src/types/assets/Request.ts @@ -32,7 +32,7 @@ export interface Request extends BaseAsset { readonly Moment?: string | null; // read-only readonly Now?: Ref | null; // read-only readonly Number?: string | null; // read-only - Order?: string | null; + Order?: number | null; Owner?: Ref | null; PrimaryWorkitems: Ref[]; Priority?: Ref | null; diff --git a/src/types/assets/RetrospectivePrompt.ts b/src/types/assets/RetrospectivePrompt.ts index eedce46..a6f01c3 100644 --- a/src/types/assets/RetrospectivePrompt.ts +++ b/src/types/assets/RetrospectivePrompt.ts @@ -28,7 +28,7 @@ export interface RetrospectivePrompt extends Asset { readonly Moment?: string | null; // read-only Name: string; // required readonly Now?: Ref | null; // read-only - Order?: string | null; + Order?: number | null; readonly Prior?: Ref | null; // read-only readonly RetireComment?: string | null; // read-only readonly RetireDate?: DateString | null; // read-only diff --git a/src/types/assets/RetrospectiveTopic.ts b/src/types/assets/RetrospectiveTopic.ts index ed97e0a..dce37ca 100644 --- a/src/types/assets/RetrospectiveTopic.ts +++ b/src/types/assets/RetrospectiveTopic.ts @@ -31,7 +31,7 @@ export interface RetrospectiveTopic extends Asset { readonly Key?: string | null; // read-only readonly Moment?: string | null; // read-only readonly Now?: Ref | null; // read-only - Order?: string | null; + Order?: number | null; readonly Prior?: Ref | null; // read-only readonly RetireComment?: string | null; // read-only readonly RetireDate?: DateString | null; // read-only diff --git a/src/types/assets/Role.ts b/src/types/assets/Role.ts index 35817f6..2d060ba 100644 --- a/src/types/assets/Role.ts +++ b/src/types/assets/Role.ts @@ -26,7 +26,7 @@ export interface Role extends Asset { readonly Moment?: string | null; // read-only readonly Name?: string | null; // read-only readonly Now?: Ref | null; // read-only - Order?: string | null; + Order?: number | null; readonly Prior?: Ref | null; // read-only readonly PrivilegesMask?: number | null; // read-only readonly RetireComment?: string | null; // read-only diff --git a/src/types/assets/StrategicTheme.ts b/src/types/assets/StrategicTheme.ts index af2c8d1..6613c67 100644 --- a/src/types/assets/StrategicTheme.ts +++ b/src/types/assets/StrategicTheme.ts @@ -25,7 +25,7 @@ export interface StrategicTheme extends BaseAsset { readonly Moment?: string | null; // read-only readonly Now?: Ref | null; // read-only readonly Number?: string | null; // read-only - Order?: string | null; + Order?: number | null; readonly Rooms: Ref[]; // read-only Scope: Ref; // required } diff --git a/src/types/assets/SyntheticAttributeDefinition.ts b/src/types/assets/SyntheticAttributeDefinition.ts index c4e43ff..eb44d10 100644 --- a/src/types/assets/SyntheticAttributeDefinition.ts +++ b/src/types/assets/SyntheticAttributeDefinition.ts @@ -11,5 +11,5 @@ export interface SyntheticAttributeDefinition extends Asset { readonly Key?: string | null; // read-only readonly Moment?: string | null; // read-only readonly Now?: Ref | null; // read-only - Order?: string | null; + Order?: number | null; } diff --git a/src/types/assets/Task.ts b/src/types/assets/Task.ts index 104de62..ec62f48 100644 --- a/src/types/assets/Task.ts +++ b/src/types/assets/Task.ts @@ -26,7 +26,7 @@ export interface Task extends Workitem { LastVersion?: string | null; readonly Moment?: string | null; // read-only readonly Now?: Ref | null; // read-only - Order?: string | null; + Order?: number | null; Source?: Ref | null; Status?: Ref | null; } diff --git a/src/types/assets/Test.ts b/src/types/assets/Test.ts index 40e788f..ecdc02a 100644 --- a/src/types/assets/Test.ts +++ b/src/types/assets/Test.ts @@ -28,7 +28,7 @@ export interface Test extends Workitem { readonly Key?: string | null; // read-only readonly Moment?: string | null; // read-only readonly Now?: Ref | null; // read-only - Order?: string | null; + Order?: number | null; Setup?: string | null; Status?: Ref | null; Steps?: string | null; diff --git a/src/types/assets/Theme.ts b/src/types/assets/Theme.ts index 5ac7a65..bf727c1 100644 --- a/src/types/assets/Theme.ts +++ b/src/types/assets/Theme.ts @@ -25,7 +25,7 @@ export interface Theme extends Workitem { LastVersion?: string | null; readonly Moment?: string | null; // read-only readonly Now?: Ref | null; // read-only - Order?: string | null; + Order?: number | null; Priority?: Ref | null; Risk?: Ref | null; Source?: Ref | null; diff --git a/src/wire/assetApi.ts b/src/wire/assetApi.ts index 814f1ee..fd0effc 100644 --- a/src/wire/assetApi.ts +++ b/src/wire/assetApi.ts @@ -119,8 +119,9 @@ export interface RowContext { lookup: AttributeLookup; type: AssetTypeToken } * A query row as the model carries it. The endpoint spells every scalar as a * string ("5", "False") and a single relation selected with a sub-select as * a one-element list ([{ _oid }], or [] when unset); with a schema each key - * is resolved to its attribute and translated — Numeric and LongInt to - * number, Boolean to boolean, [x] to x and [] to null on a single relation, + * is resolved to its attribute and translated — Numeric, LongInt and Rank + * to number, Boolean to boolean, [x] to x and [] to null on a single + * relation, { _oid: "NULL" } (an unset single relation) to null, * an @Count to number, and a sub-select's rows recursively by their own * type. Everywhere, OIDs lose their moment. A key the schema cannot resolve * is left as sent. @@ -133,7 +134,15 @@ export function fromWireRow(row: Record isRef(value) && value._oid === "NULL"; + function fromWireValue(value: unknown): unknown { + if (isNullRef(value)) return null; if (isRef(value)) return { ...value, _oid: momentless(value._oid) }; if (Array.isArray(value)) return value.map(fromWireValue); return value; @@ -145,8 +154,8 @@ function translate(value: unknown, resolution: PathResolution, lookup: Attribute const definition = resolution.definition!; if (definition.AttributeType === "Relation") { const context = resolution.valueType ? { lookup, type: resolution.valueType } : undefined; - const one = (x: unknown): unknown => (isRef(x) ? fromWireRow(x as Record, context) : fromWireValue(x)); - if (resolution.multi) return Array.isArray(value) ? value.map(one) : value == null ? [] : [one(value)]; + const one = (x: unknown): unknown => (isNullRef(x) ? null : isRef(x) ? fromWireRow(x as Record, context) : fromWireValue(x)); + if (resolution.multi) return (Array.isArray(value) ? value : value == null ? [] : [value]).filter((x) => !isNullRef(x)).map(one); if (Array.isArray(value)) return value.length ? one(value[0]) : null; return value == null ? null : one(value); } @@ -160,6 +169,7 @@ function scalar(value: unknown, type: AttributeType): unknown { switch (type) { case "Numeric": case "LongInt": + case "Rank": // a 32-bit integer sent as a string ("-2000000000"); sorts as a number return toNumber(value); case "Boolean": return value === true || value === "True" || value === "true" ? true : value === false || value === "False" || value === "false" ? false : value; diff --git a/test/integration/batch.test.ts b/test/integration/batch.test.ts index 6f71665..05b926d 100644 --- a/test/integration/batch.test.ts +++ b/test/integration/batch.test.ts @@ -65,4 +65,18 @@ describeInstance("batch (live)", () => { expect(commands).toEqual([]); expect((await client.batch.reversible(receipt, { force: true })).commands).toEqual([{ from: b, update: { Estimate: null } }]); }); + it("a batch of only an aliased execute, sent with guard off, really runs, and its receipt names the asset (gh-v1 apply's case)", async () => { + // Its own Story, so the round trip leaves a and b alone. + const res = await assetApi.send(t, [{ AssetType: "Story", Name: fixtureName("batch aliased"), Scope: { _oid: "Scope:0" } }], { throwOnFailure: true }); + const story = assetApi.momentless(res.assetsCreated.oidTokens[0]!); + created.push(story); + const receipt = await client.batch.run([{ "@close": { from: story, execute: "QuickClose" } }], { guard: false }); + expect(receipt.sent).toBe(true); + expect(receipt.outcome!.failures).toEqual([]); + expect(receipt.described.deferred).toEqual([{ index: 0, alias: "@close", from: story, kind: "execute" }]); + expect(receipt.touched).toEqual([expect.objectContaining({ oid: story, name: expect.stringContaining("batch aliased") })]); + expect(receipt.after[story]).toMatch(/^\d{4}-/); + const [row] = await assetApi.query<{ _oid: string; AssetState: string }>(t, { from: story, select: ["AssetState"], page: { size: 1, start: 0 } }); + expect(row!.AssetState).toBe("Closed"); + }); }); diff --git a/test/unit/batch.test.ts b/test/unit/batch.test.ts index c94d042..6b363ff 100644 --- a/test/unit/batch.test.ts +++ b/test/unit/batch.test.ts @@ -2,7 +2,7 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import { describe as group, expect, it, vi } from "vitest"; import type { RequestOptions, Transport } from "../../src/core/transport"; -import { BatchSendError, describe, reverseOf, reversible, runBatch, type Receipt } from "../../src/services/batch"; +import { BatchRecordError, BatchSendError, describe, reverseOf, reversible, runBatch, type Receipt } from "../../src/services/batch"; import { UnguardableError } from "../../src/services/guard"; import { Schema } from "../../src/services/schema"; import { toMetamodel, type MetamodelDocument } from "../../src/wire/meta"; @@ -118,6 +118,51 @@ group("batch", () => { expect(e.receipt.sent).toBe(false); }); + it("a batch of only aliased updates and executes is work: with guard off it is sent as written, noted as unsnapshotted, and left out of the reverse", async () => { + // gh-v1's `apply` hit this: preview echoes aliased writes unexpanded, so nothing reads as per-asset work. + const close: Command[] = [{ "@close": { from: "Story:20", execute: "QuickClose" } }]; + let sent: Command[] | undefined; + const t = server({ preview: close, lookup: (q) => q.with!.$oids!.map((oid) => (q.select!.includes("Name") ? { _oid: oid, Name: "Twenty", Number: "S-20" } : { _oid: oid, ChangeDate: "2026-11-06T10:00:00.1" })), send: (body) => { sent = body; return envelope({ assetsOperatedOn: { oidTokens: ["Story:20:900"], count: 1 } }); } }); + const r = await runBatch(t, schema, close, { guard: false }); + expect(sent).toEqual(close); + expect(r.sent).toBe(true); + expect(r.described.deferred).toEqual([{ index: 0, alias: "@close", from: "Story:20", kind: "execute" }]); + expect(r.touched).toEqual([{ oid: "Story:20", number: "S-20", name: "Twenty" }]); // named after the send: preview never listed it + expect(r.after).toEqual({ "Story:20": "2026-11-06T10:00:00.1" }); + expect(r.notes.join("\n")).toMatch(/@close.*aliased execute.*not reversible/); + expect(r.reverse.commands).toEqual([]); + expect(r.reverse.notes.join("\n")).toMatch(/@close.*not reversed/); + }); + + it("confirm sees the touched assets' names on the receipt, looked up once; a failed name lookup is a note, not a silence", async () => { + const t = server({ preview: PREVIEW, lookup, send: () => envelope({ assetsCreated: { oidTokens: ["Task:50"], count: 1 }, assetsModified: { oidTokens: ["Story:1"], count: 1 }, assetsOperatedOn: { oidTokens: ["Epic:9"], count: 1 } }) }); + let seen: Receipt["names"] | undefined; + const r = await runBatch(t, schema, REQUEST, { confirm: (receipt) => { seen = receipt.names; return true; } }); + expect(seen).toEqual({ "Story:1": { number: "S-1", name: "One" }, "Story:2": { number: "S-2", name: "Two" }, "Epic:9": { number: "E-9", name: "Nine" } }); + const nameQueries = t.calls.filter((c) => (c.body as Q[])[0]?.select?.includes("Name") && (c.body as Q[])[0]?.with?.$oids?.some((o) => o.startsWith("Story"))); + expect(nameQueries).toHaveLength(1); // not again after the send + expect(r.touched.map((x) => x.name)).toEqual(["One", "Nine"]); + expect(r.notes).toEqual([]); + + const refusing = server({ preview: PREVIEW, lookup: (q) => { if (q.select!.includes("Name") && q.with!.$oids![0]!.startsWith("Epic")) throw new Error("Unknown AttributeDefinition: Epic.Name"); return lookup(q); }, send: () => envelope({ assetsOperatedOn: { oidTokens: ["Epic:9"], count: 1 } }) }); + const lookupFailed = (await runBatch(refusing, schema, REQUEST).catch((e: unknown) => e)) as Receipt; + expect(lookupFailed.sent).toBe(true); + expect(lookupFailed.notes.some((n) => /could not look up Name for 1 Epic asset: .*Epic\.Name/.test(n))).toBe(true); + }); + + it("a read-back that fails after the send throws BatchRecordError with the receipt: sent, outcome and reverse kept, after incomplete", async () => { + const afterFails = (q: Q) => { if (q.select!.length === 1 && q.select![0] === "ChangeDate") throw new Error("socket hang up"); return lookup(q); }; + const t = server({ preview: PREVIEW, lookup: afterFails, send: () => envelope({ assetsCreated: { oidTokens: ["Task:50"], count: 1 }, assetsModified: { oidTokens: ["Story:1"], count: 1 }, assetsOperatedOn: { oidTokens: ["Epic:9"], count: 1 } }) }); + const e = (await runBatch(t, schema, REQUEST).catch((x: unknown) => x)) as BatchRecordError; + expect(e).toBeInstanceOf(BatchRecordError); + expect(e.message).toMatch(/sent and its outcome recorded.*receipt\.after is incomplete.*socket hang up/); + expect(e.receipt.sent).toBe(true); + expect(e.receipt.outcome?.created).toEqual(["Task:50"]); + expect(e.receipt.created).toEqual([{ alias: "@t", type: "Task", oid: "Task:50", number: "TK-50", name: "new task" }]); + expect(e.receipt.reverse.commands.length).toBeGreaterThan(0); + expect(e.receipt.after).toEqual({}); + }); + it("reversible drops assets changed since the batch (their ChangeDate is not the one recorded after it) unless forced", async () => { const receipt = { after: { "Story:1": "2026-11-04T17:00:00.1", "Epic:9": "2026-11-04T17:00:00.3" }, reverse: { notes: [], commands: [{ from: "Story:1", update: { Estimate: "3" } }, { from: "Epic:9", execute: "Reactivate" }] as Command[] } }; const now: Record = { "Story:1": "2026-11-04T18:00:00.9", "Epic:9": "2026-11-04T17:00:00.3" }; diff --git a/test/unit/translate.test.ts b/test/unit/translate.test.ts index cce7e40..d9729eb 100644 --- a/test/unit/translate.test.ts +++ b/test/unit/translate.test.ts @@ -43,6 +43,15 @@ const liveRow = { const emptyRow = { _oid: "Story:2356", Name: "Assign help tickets to Tags", Estimate: null, IsReadOnly: "False", Status: [], Super: [], Scope: { _oid: "Scope:0" }, Owners: [], "Owners.Name": [], "Owners.@Count": "0", Custom_Flag: "True", Unknown: "x" }; describe("translate: a row into the model, by schema", () => { + it("an unset single relation, which the endpoint sends as { _oid: \"NULL\" }, is null — with a schema and without (seen live on TeamRoom.Team, 2026-11-06)", () => { + const wire = { _oid: "TeamRoom:17033", Name: "Team Room1", Team: { _oid: "NULL" } }; + expect(fromWireRow(wire, { lookup: schema, type: "TeamRoom" })).toEqual({ _oid: "TeamRoom:17033", Name: "Team Room1", Team: null }); + expect(fromWireRow(wire)).toEqual({ _oid: "TeamRoom:17033", Name: "Team Room1", Team: null }); + // A set one is still a Ref; a multi-value relation never carries a NULL entry. + expect((fromWireRow({ _oid: "TeamRoom:57394", Team: { _oid: "Team:4094" } }, { lookup: schema, type: "TeamRoom" }) as unknown as Record).Team).toEqual({ _oid: "Team:4094" }); + expect((fromWireRow({ _oid: "Story:1", Owners: [{ _oid: "NULL" }, { _oid: "Member:20" }] }, { lookup: schema, type: "Story" }) as unknown as Record).Owners).toEqual([{ _oid: "Member:20" }]); + }); + it("numbers become numbers, booleans booleans, a sub-selected single relation one Ref, a bare one stays a Ref, aggregates numbers, dates and states as sent", () => { const row = fromWireRow(liveRow, { lookup: schema, type: "Story" }); expect(row._oid).toBe("Story:1226"); @@ -53,8 +62,8 @@ describe("translate: a row into the model, by schema", () => { expect(row.IsClosed).toBe(true); expect(row.AssetState).toBe("Closed"); expect(row.CreateDate).toBe("2026-06-27T12:28:29.137"); - expect(row.Order).toBe("-1989738965"); // Rank is opaque: a string in the model - expect(row.Status).toEqual({ _oid: "StoryStatus:2191", Name: "Accepted", Order: "3" }); // [x] -> x; the nested row is translated by StoryStatus (Order is Rank there too) + expect(row.Order).toBe(-1989738965); // Rank: a 32-bit integer sent as a string, a number in the model + expect(row.Status).toEqual({ _oid: "StoryStatus:2191", Name: "Accepted", Order: 3 }); // [x] -> x; the nested row is translated by StoryStatus (Order is Rank there too) expect(row.Super).toEqual({ _oid: "Epic:1185", Name: "Order Entry" }); expect(row.Scope).toEqual({ _oid: "Scope:1308" }); expect(row.Owners).toEqual([{ _oid: "Member:1039", Name: "Danny Developer", IsInactive: false }]); // nested rows translated by Member @@ -125,7 +134,7 @@ describe("translate: through send and the client", () => { const [row] = await client.assets.query({ from: "Story", select: ["Estimate", { from: "Status", select: ["Name"] }], page: { size: 1, start: 0 } }); expect(t.calls.map((c) => c.path)).toEqual(["/meta.v1/Story", "/meta.v1/StoryStatus", "/api/asset"]); expect(row!.Estimate).toBe(5); - expect(row!.Status).toEqual({ _oid: "StoryStatus:2191", Name: "Accepted", Order: "3" }); + expect(row!.Status).toEqual({ _oid: "StoryStatus:2191", Name: "Accepted", Order: 3 }); // A seeded client fetches no meta at all. const seeded = new AgilityApiClient({ baseUrl: "http://x" }, { transport: fakeTransport(envelope([[liveRow]])), metamodel }); const [again] = await seeded.assets.query({ from: "Story", select: ["Estimate"], page: { size: 1, start: 0 } });