Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`)

Expand Down
9 changes: 5 additions & 4 deletions plans/001-sdk-adoption-campaign.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion scripts/generate-types.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ const SCALAR = {
Opaque: "string",
Guid: "string",
Duration: "string",
Rank: "string",
Rank: "number",
Date: "DateString",
Numeric: "number",
LongInt: "number",
Expand Down
8 changes: 5 additions & 3 deletions specs/000-layers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
91 changes: 73 additions & 18 deletions src/services/batch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,23 @@ export interface Described {
queries: { index: number; from: string }[];
updates: { oid: Oid; attributes: Record<string, CommandValue> }[];
executes: { oid: Oid; op: string; args: Record<string, CommandValue> }[];
/**
* 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<string, unknown> => aliased(c)?.body ?? (c as Record<string, unknown>);

/** 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) {
Expand Down Expand Up @@ -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<Oid>): Promise<Map<Oid, Named>> {
export async function namesOf(transport: Transport, schema: Schema, oids: Iterable<Oid>, onFailure?: NameLookupFailure): Promise<Map<Oid, Named>> {
const out = new Map<Oid, Named>();
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<Asset & { Name?: string; Number?: string }>(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;
Expand Down Expand Up @@ -157,6 +176,7 @@ export function reverseOf(described: Described, before: Record<Oid, Record<strin
else notes.push(`${e.oid}: ${e.op} has no known inverse (was ${String(before[e.oid]?.AssetState ?? "?")})`);
}
for (const oid of [...done.created].reverse()) commands.push({ from: momentless(oid), execute: "Delete" });
for (const d of described.deferred ?? []) notes.push(`command ${d.index} (${d.alias}): an aliased ${d.kind} of ${d.from}, sent as written with no snapshot; not reversed`);
if (commands.length) notes.unshift("restores the state recorded at snapshotAt; anything that changed between then and sentAt is not reflected, and a scalar restored to null is cleared, not unset");
return { commands, notes };
}
Expand All @@ -169,6 +189,8 @@ export interface Receipt {
previewedAt: string;
preview: Command[];
described: Described;
/** Numbers and names of the assets the preview touches, looked up before confirm. */
names: Record<Oid, Named>;
snapshotAt?: string;
/** Before values per touched asset, ChangeDate included: what the guard compared and the reverse restores. */
before: Record<Oid, Record<string, unknown>>;
Expand All @@ -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. */
Expand All @@ -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<boolean>;
}

Expand All @@ -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;
Expand All @@ -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;
}

Expand Down
2 changes: 1 addition & 1 deletion src/types/assets/BusinessRule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,5 @@ export interface BusinessRule extends Asset {
readonly Key?: string | null; // read-only
readonly Moment?: string | null; // read-only
readonly Now?: Ref<BusinessRule> | null; // read-only
Order?: string | null;
Order?: number | null;
}
2 changes: 1 addition & 1 deletion src/types/assets/Epic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export interface Epic extends Workitem {
readonly Moment?: string | null; // read-only
readonly MorphedFrom?: Ref<Story> | null; // read-only
readonly Now?: Ref<Epic> | null; // read-only
Order?: string | null;
Order?: number | null;
PlannedCompletionBy?: Ref<Milestone> | null;
PlannedEnd?: DateString | null;
PlannedRelease?: Ref<Release> | null;
Expand Down
2 changes: 1 addition & 1 deletion src/types/assets/ExternalAction.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export interface ExternalAction extends Asset {
readonly Moment?: string | null; // read-only
Name: string; // required
readonly Now?: Ref<ExternalAction> | null; // read-only
Order?: string | null;
Order?: number | null;
Payload: string; // required
readonly Prior?: Ref<ExternalAction> | null; // read-only
readonly RetireComment?: string | null; // read-only
Expand Down
2 changes: 1 addition & 1 deletion src/types/assets/Issue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export interface Issue extends BaseAsset {
readonly Moment?: string | null; // read-only
readonly Now?: Ref<Issue> | null; // read-only
readonly Number?: string | null; // read-only
Order?: string | null;
Order?: number | null;
Owner?: Ref<Member> | null;
PrimaryWorkitems: Ref<PrimaryWorkitem>[];
Priority?: Ref<IssuePriority> | null;
Expand Down
2 changes: 1 addition & 1 deletion src/types/assets/List.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export interface List extends Asset {
readonly Moment?: string | null; // read-only
Name: string; // required
readonly Now?: Ref<List> | null; // read-only
Order?: string | null;
Order?: number | null;
readonly Prior?: Ref<List> | null; // read-only
readonly RetireComment?: string | null; // read-only
readonly RetireDate?: DateString | null; // read-only
Expand Down
2 changes: 1 addition & 1 deletion src/types/assets/Okr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,5 @@ export interface Okr extends BaseAsset {
readonly Moment?: string | null; // read-only
readonly Now?: Ref<Okr> | null; // read-only
readonly Number?: string | null; // read-only
Order?: string | null;
Order?: number | null;
}
2 changes: 1 addition & 1 deletion src/types/assets/PrimaryWorkitem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ export interface PrimaryWorkitem extends Workitem {
readonly Moment?: string | null; // read-only
readonly Now?: Ref<PrimaryWorkitem> | null; // read-only
readonly OpenEstimate?: number | null; // read-only
Order?: string | null;
Order?: number | null;
Priority?: Ref<WorkitemPriority> | null;
Release?: Ref<Release> | null;
Requests: Ref<Request>[];
Expand Down
2 changes: 1 addition & 1 deletion src/types/assets/RegressionTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ export interface RegressionTest extends BaseAsset {
readonly Moment?: string | null; // read-only
readonly Now?: Ref<RegressionTest> | null; // read-only
readonly Number?: string | null; // read-only
Order?: string | null;
Order?: number | null;
Owners: Ref<Member>[];
Reference?: string | null;
RegressionSuites: Ref<RegressionSuite>[];
Expand Down
2 changes: 1 addition & 1 deletion src/types/assets/Request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export interface Request extends BaseAsset {
readonly Moment?: string | null; // read-only
readonly Now?: Ref<Request> | null; // read-only
readonly Number?: string | null; // read-only
Order?: string | null;
Order?: number | null;
Owner?: Ref<Member> | null;
PrimaryWorkitems: Ref<PrimaryWorkitem>[];
Priority?: Ref<RequestPriority> | null;
Expand Down
2 changes: 1 addition & 1 deletion src/types/assets/RetrospectivePrompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export interface RetrospectivePrompt extends Asset {
readonly Moment?: string | null; // read-only
Name: string; // required
readonly Now?: Ref<RetrospectivePrompt> | null; // read-only
Order?: string | null;
Order?: number | null;
readonly Prior?: Ref<RetrospectivePrompt> | null; // read-only
readonly RetireComment?: string | null; // read-only
readonly RetireDate?: DateString | null; // read-only
Expand Down
2 changes: 1 addition & 1 deletion src/types/assets/RetrospectiveTopic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export interface RetrospectiveTopic extends Asset {
readonly Key?: string | null; // read-only
readonly Moment?: string | null; // read-only
readonly Now?: Ref<RetrospectiveTopic> | null; // read-only
Order?: string | null;
Order?: number | null;
readonly Prior?: Ref<RetrospectiveTopic> | null; // read-only
readonly RetireComment?: string | null; // read-only
readonly RetireDate?: DateString | null; // read-only
Expand Down
Loading
Loading