Skip to content

Commit 207a2ac

Browse files
authored
Merge pull request #43 from versionone/claude/admiring-ride-vsq4sa
Fix six SDK gaps from gh-v1's C2–C3; tick plan 001
2 parents 79b5b48 + cbe3c5f commit 207a2ac

27 files changed

Lines changed: 190 additions & 54 deletions

‎README.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ npm test
2525
- `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
2626
- `resolve.numbers` / `resolve.ref` - asset numbers and OID tokens to assets, in one request
2727
- `assets.sendGuarded` - a batch as a compare-and-set on each asset's ChangeDate; changed assets come back `skipped`
28-
- `batch.run` / `batch.reversible` - preview, snapshot, (confirm,) send guarded, and a receipt with a reverse batch
28+
- `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
2929
- `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
3030
- `ApiError` - typed API error; `CommandFailureError` - an api/asset batch in which commands failed (`failures` by index, plus the `response`)
3131

‎plans/001-sdk-adoption-campaign.md‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -360,9 +360,10 @@ ESM" here means finishing the edges:
360360
- [x] A4 slack-shaped reads — `queryV1` wire module, `client.queryV1.query`/`queries` (read-only, idempotent, same row translation as api/asset)
361361
- [x] A5 services port from gh-v1 — `resolve` (`client.resolve.numbers`/`.ref`), `guard` (`client.assets.sendGuarded`), `batch` (`client.batch.run`/`.reversible`)
362362
- [ ] 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
363-
- [ ] G0–G3 gh-v1 migrations (gh-v1 session)
364-
- [ ] S1–S4 slack-bot migrations (slack-bot session, `bolt-migration`)
365-
- [ ] B1–B4 slack bot SDK swap — brief: `plans/003-adopt-sdk-k6i-agility-slack-bot.md`
363+
- [x] G0–G3 gh-v1 migrations — versionone/gh-v1#29–#32
364+
- [x] S1–S4 slack-bot migrations — digital-ai/k6i-agility-slack-bot#21–#24 (on `bolt-migration`)
365+
- [ ] B1–B4 slack bot SDK swap — brief: `plans/003-adopt-sdk-k6i-agility-slack-bot.md` (unblocked: S2, S3 and SDK #42 merged)
366366
- [x] C0 gh-v1 surveyed (2026-11-04)
367-
- [ ] C1–C4 gh-v1 — brief: `plans/002-adopt-sdk-gh-v1.md`; C1 in versionone/gh-v1#33 (SDK vendored)
367+
- [x] C1 gh-v1 seam swap — versionone/gh-v1#33, with the vendored SDK's hashes, CI and Node 22 floor in #35
368+
- [ ] C2–C4 gh-v1 — brief: `plans/002-adopt-sdk-gh-v1.md` (C2–C3 in progress in a gh-v1 session)
368369
- [ ] D1–D4 agility-rooms

‎scripts/generate-types.mjs‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ const SCALAR = {
4141
Opaque: "string",
4242
Guid: "string",
4343
Duration: "string",
44-
Rank: "string",
44+
Rank: "number",
4545
Date: "DateString",
4646
Numeric: "number",
4747
LongInt: "number",

‎specs/000-layers.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,11 +61,13 @@ one, is not decidable from the value: it takes the attribute's definition.
6161
So the `assetApi` wire module translates a row against an `AttributeLookup`
6262
(`src/types/metamodel.ts`): each key is a select path resolved hop by hop
6363
(`src/wire/select.ts` — dotted paths, `:Downcast`, `[filter]`, `@Count`)
64-
to the attribute it lands on; Numeric and LongInt become numbers, Boolean
65-
booleans, a single relation one Ref or null, an aggregate a number, and a
64+
to the attribute it lands on; Numeric, LongInt and Rank become numbers, Boolean
65+
booleans, a single relation one Ref or null (the endpoint sends an unset
66+
one as `{ _oid: "NULL" }`), an aggregate a number, and a
6667
sub-select's rows are translated by their own type. A key the schema does
6768
not know is left as sent. Without a lookup the row keeps the endpoint's
68-
spelling, OIDs normalised — never silently guessed.
69+
spelling, OIDs normalised and a `NULL` relation null — never silently
70+
guessed.
6971

7072
The lookup is the `schema` service: the instance's types, loaded from
7173
meta.v1 one type at a time as queries need them (in-flight requests shared,

‎src/index.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,5 +28,5 @@ export { CHUNK as RESOLVE_CHUNK, NUMBER_ROOTS, isAssetNumber, resolveNumbers, re
2828
export type { Resolution, ResolvedAsset } from "./services/resolve";
2929
export { UNGUARDABLE_TYPES, UnguardableError, guard, sendGuarded, stampsOf, touchedBy, unguardable } from "./services/guard";
3030
export type { GuardedResult, Unguardable } from "./services/guard";
31-
export { BatchSendError, INVERSE, describe as describeBatch, namesOf, reverseOf, reversible, runBatch, snapshot } from "./services/batch";
32-
export type { BatchOptions, Described, Named, Receipt, Reverse } from "./services/batch";
31+
export { BatchRecordError, BatchSendError, INVERSE, describe as describeBatch, namesOf, reverseOf, reversible, runBatch, snapshot } from "./services/batch";
32+
export type { BatchOptions, Described, NameLookupFailure, Named, Receipt, Reverse } from "./services/batch";

‎src/services/batch.ts‎

Lines changed: 73 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -35,16 +35,23 @@ export interface Described {
3535
queries: { index: number; from: string }[];
3636
updates: { oid: Oid; attributes: Record<string, CommandValue> }[];
3737
executes: { oid: Oid; op: string; args: Record<string, CommandValue> }[];
38+
/**
39+
* Aliased updates and executes: preview does not expand them, so they
40+
* have no per-asset entry above, no snapshot and no reverse. They are
41+
* work all the same, sent as written (only with guard: false).
42+
*/
43+
deferred: { index: number; alias: string; from: string; kind: "update" | "execute" }[];
3844
}
3945

4046
const bodyOf = (c: Command): Record<string, unknown> => aliased(c)?.body ?? (c as Record<string, unknown>);
4147

4248
/** What a batch does: creates and queries read off the request; updates and executes, per asset, off the projection. */
4349
export function describe(commands: Command[], projected: Command[]): Described {
44-
const out: Described = { creates: [], queries: [], updates: [], executes: [] };
50+
const out: Described = { creates: [], queries: [], updates: [], executes: [], deferred: [] };
4551
commands.forEach((c, index) => {
4652
const a = aliased(c), body = bodyOf(c);
47-
if (typeof body.AssetType === "string") out.creates.push({ index, alias: a?.alias ?? null, type: body.AssetType, name: typeof body.Name === "string" ? body.Name : null });
53+
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" });
54+
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 });
4855
else if (typeof body.from === "string" && !("update" in body) && !("execute" in body)) out.queries.push({ index, from: body.from });
4956
});
5057
for (const p of projected) {
@@ -74,23 +81,35 @@ const plain = (v: unknown): unknown =>
7481

7582
export interface Named { number: string | null; name: string | null }
7683

84+
/** Told about a name lookup that failed: the type, the assets left unnamed, and why. */
85+
export type NameLookupFailure = (failure: { type: AssetTypeToken; oids: Oid[]; error: unknown }) => void;
86+
7787
/**
7888
* Numbers and names, one bounded query per type and chunk, asking each type
7989
* only for what the schema says it has (a Conversation has neither, and
80-
* asking fails the query). Cosmetic: a lookup the server refuses leaves
81-
* those assets unnamed rather than failing a batch that may already be sent.
90+
* asking fails the query). Cosmetic: a lookup that fails leaves those assets
91+
* unnamed rather than failing a batch that may already be sent, and is
92+
* reported to `onFailure` so a caller can say so.
8293
*/
83-
export async function namesOf(transport: Transport, schema: Schema, oids: Iterable<Oid>): Promise<Map<Oid, Named>> {
94+
export async function namesOf(transport: Transport, schema: Schema, oids: Iterable<Oid>, onFailure?: NameLookupFailure): Promise<Map<Oid, Named>> {
8495
const out = new Map<Oid, Named>();
8596
for (const [type, list] of byType(oids)) {
86-
await schema.load(type);
87-
const select = ["Name", "Number"].filter((a) => schema.attribute(type, a));
97+
let select: string[];
98+
try {
99+
await schema.load(type);
100+
select = ["Name", "Number"].filter((a) => schema.attribute(type, a));
101+
} catch (error) {
102+
onFailure?.({ type, oids: list, error });
103+
continue;
104+
}
88105
if (!select.length) continue;
89106
for (const part of chunks(list)) {
90107
try {
91108
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 } });
92109
for (const r of rows) out.set(momentless(r._oid), { number: r.Number ?? null, name: r.Name ?? null });
93-
} catch { /* cosmetic: leave these unnamed */ }
110+
} catch (error) {
111+
onFailure?.({ type, oids: part, error });
112+
}
94113
}
95114
}
96115
return out;
@@ -157,6 +176,7 @@ export function reverseOf(described: Described, before: Record<Oid, Record<strin
157176
else notes.push(`${e.oid}: ${e.op} has no known inverse (was ${String(before[e.oid]?.AssetState ?? "?")})`);
158177
}
159178
for (const oid of [...done.created].reverse()) commands.push({ from: momentless(oid), execute: "Delete" });
179+
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`);
160180
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");
161181
return { commands, notes };
162182
}
@@ -169,6 +189,8 @@ export interface Receipt {
169189
previewedAt: string;
170190
preview: Command[];
171191
described: Described;
192+
/** Numbers and names of the assets the preview touches, looked up before confirm. */
193+
names: Record<Oid, Named>;
172194
snapshotAt?: string;
173195
/** Before values per touched asset, ChangeDate included: what the guard compared and the reverse restores. */
174196
before: Record<Oid, Record<string, unknown>>;
@@ -187,6 +209,24 @@ export interface Receipt {
187209
/** Touched assets the guard left alone: changed or gone since the snapshot. */
188210
skipped: Oid[];
189211
reverse: Reverse;
212+
/** What the record could not do and why: a name lookup that failed, deferred commands sent unsnapshotted. */
213+
notes: string[];
214+
}
215+
216+
/**
217+
* The batch was sent and the server's outcome is on the receipt
218+
* (`receipt.sent`, `receipt.outcome`, `receipt.reverse`), but reading the
219+
* assets back afterwards failed, so `receipt.after` is incomplete: keep the
220+
* receipt, and know that a reverse built from it cannot check every asset
221+
* for changes made since.
222+
*/
223+
export class BatchRecordError extends Error {
224+
readonly receipt: Receipt;
225+
constructor(cause: unknown, receipt: Receipt) {
226+
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 });
227+
this.name = "BatchRecordError";
228+
this.receipt = receipt;
229+
}
190230
}
191231

192232
/** 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 {
204244
guard?: boolean;
205245
/** Stop after the snapshot and send nothing: the receipt says what would happen. */
206246
dryRun?: boolean;
207-
/** Asked after the snapshot, before the send; false sends nothing. */
247+
/** Asked after the snapshot, before the send, with the touched assets' names on the receipt; false sends nothing. */
208248
confirm?: (receipt: Receipt) => boolean | Promise<boolean>;
209249
}
210250

@@ -218,14 +258,18 @@ export async function runBatch(transport: Transport, schema: Schema, commands: C
218258
const fatal = refused.filter((r) => r.reason.includes("rejected by the server"));
219259
if (fatal.length || (useGuard && refused.length)) throw new UnguardableError(useGuard ? refused : fatal);
220260

261+
const notes: string[] = [];
262+
const noteFailure: NameLookupFailure = ({ type, oids, error }) =>
263+
notes.push(`could not look up Name for ${oids.length} ${type} asset${oids.length === 1 ? "" : "s"}: ${error instanceof Error ? error.message : String(error)}`);
221264
const previewedAt = now();
222265
const projected = await assetApi.preview(transport, commands);
223266
const described = describe(commands, projected);
224267
const touched = touchedBy(projected);
225-
const names = await namesOf(transport, schema, touched);
226-
const receipt: Receipt = { receipt: 1, request: commands, previewedAt, preview: projected, described, before: {}, sent: false, guarded: false, created: [], touched: [], after: {}, skipped: [], reverse: { commands: [], notes: [] } };
227-
const nothing = !described.creates.length && !described.updates.length && !described.executes.length && !described.queries.length;
268+
const names = await namesOf(transport, schema, touched, noteFailure);
269+
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 };
270+
const nothing = !described.creates.length && !described.updates.length && !described.executes.length && !described.queries.length && !described.deferred.length;
228271
if (nothing) return receipt;
272+
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`);
229273
receipt.snapshotAt = now();
230274
receipt.before = await snapshot(transport, described);
231275
if (options.dryRun) return receipt;
@@ -248,15 +292,26 @@ export async function runBatch(transport: Transport, schema: Schema, commands: C
248292
receipt.sent = true;
249293
receipt.outcome = done;
250294

251-
// Everything below decorates a response already recorded; a failed lookup cannot cost the record.
252-
const createdNames = await namesOf(transport, schema, done.created);
253-
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 }) }));
295+
// The record, from the response alone: nothing below this block can lose it.
254296
const hit = [...new Set([...done.modified, ...done.operated])];
255-
receipt.touched = hit.map((oid) => ({ oid, ...(names.get(oid) ?? { number: null, name: null }) }));
256-
const all = [...done.created, ...hit];
257-
receipt.after = all.length ? await stampsOf(transport, all) : {};
297+
const unnamed = { number: null, name: null };
298+
receipt.created = done.created.map((oid, i) => ({ alias: described.creates[i]?.alias ?? null, type: described.creates[i]?.type ?? typeOfOid(oid), oid, ...unnamed }));
299+
receipt.touched = hit.map((oid) => ({ oid, ...(names.get(oid) ?? unnamed) }));
258300
receipt.skipped = useGuard ? [...new Set([...unstamped, ...touched.filter((oid) => !hit.includes(oid))])] : [];
259301
receipt.reverse = reverseOf(described, receipt.before, done);
302+
303+
// Decoration, read back from the server. Names are cosmetic (a failure is
304+
// a note); the after stamps are not, so a failure there throws with the
305+
// receipt, outcome and all.
306+
try {
307+
const more = await namesOf(transport, schema, [...done.created, ...hit.filter((oid) => !names.has(oid))], noteFailure);
308+
receipt.created = receipt.created.map((c) => ({ ...c, ...(more.get(c.oid) ?? unnamed) }));
309+
receipt.touched = receipt.touched.map((t) => (names.has(t.oid) ? t : { ...t, ...(more.get(t.oid) ?? unnamed) }));
310+
const all = [...done.created, ...hit];
311+
receipt.after = all.length ? await stampsOf(transport, all) : {};
312+
} catch (error) {
313+
throw new BatchRecordError(error, receipt);
314+
}
260315
return receipt;
261316
}
262317

‎src/types/assets/BusinessRule.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,5 +10,5 @@ export interface BusinessRule extends Asset {
1010
readonly Key?: string | null; // read-only
1111
readonly Moment?: string | null; // read-only
1212
readonly Now?: Ref<BusinessRule> | null; // read-only
13-
Order?: string | null;
13+
Order?: number | null;
1414
}

‎src/types/assets/Epic.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ export interface Epic extends Workitem {
4141
readonly Moment?: string | null; // read-only
4242
readonly MorphedFrom?: Ref<Story> | null; // read-only
4343
readonly Now?: Ref<Epic> | null; // read-only
44-
Order?: string | null;
44+
Order?: number | null;
4545
PlannedCompletionBy?: Ref<Milestone> | null;
4646
PlannedEnd?: DateString | null;
4747
PlannedRelease?: Ref<Release> | null;

‎src/types/assets/ExternalAction.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export interface ExternalAction extends Asset {
3131
readonly Moment?: string | null; // read-only
3232
Name: string; // required
3333
readonly Now?: Ref<ExternalAction> | null; // read-only
34-
Order?: string | null;
34+
Order?: number | null;
3535
Payload: string; // required
3636
readonly Prior?: Ref<ExternalAction> | null; // read-only
3737
readonly RetireComment?: string | null; // read-only

‎src/types/assets/Issue.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ export interface Issue extends BaseAsset {
3535
readonly Moment?: string | null; // read-only
3636
readonly Now?: Ref<Issue> | null; // read-only
3737
readonly Number?: string | null; // read-only
38-
Order?: string | null;
38+
Order?: number | null;
3939
Owner?: Ref<Member> | null;
4040
PrimaryWorkitems: Ref<PrimaryWorkitem>[];
4141
Priority?: Ref<IssuePriority> | null;

0 commit comments

Comments
 (0)