Skip to content

Commit 785b4c9

Browse files
authored
Merge pull request #44 from versionone/claude/admiring-ride-vsq4sa
Group rows, TypeCache, dependency audit; plan ticks and decisions
2 parents 207a2ac + 2742981 commit 785b4c9

13 files changed

Lines changed: 1313 additions & 679 deletions

File tree

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ npm test
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`
2828
- `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
29+
- `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
2930
- `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
3031
- `ApiError` - typed API error; `CommandFailureError` - an api/asset batch in which commands failed (`failures` by index, plus the `response`)
3132

‎package-lock.json‎

Lines changed: 1096 additions & 656 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,9 @@
4646
"devDependencies": {
4747
"@types/node": "^22.13.10",
4848
"rimraf": "^6.0.1",
49-
"tsup": "^8.4.0",
49+
"tsup": "^9.0.0",
5050
"typescript": "^5.8.2",
51-
"vitest": "^3.0.8"
51+
"vitest": "^5.1.0"
5252
},
5353
"volta": {
5454
"node": "24.14.0"

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

Lines changed: 53 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ tests green) to a package that three surfaces depend on:
1111
| Messaging | `digital-ai/k6i-agility-slack-bot` | Slack app (Node 24, plain ESM JS) | first |
1212
| Rooms | `digital-ai/agility-rooms` | React/Vite SPA, same-origin cookie auth | slow, later |
1313

14+
The SDK's goal is one abstraction around every part of changing an asset —
15+
its attributes, its relations, its operations, and the files attached to
16+
it (attachments, embedded images) — so no consumer talks to an endpoint
17+
directly for any of them. gh-v1 (`gh v1 attach`) and agility-rooms both
18+
handle attachments today, each on its own.
19+
1420
Specs (`specs/`) hold invariants; plans (`plans/`) hold sequencing and are
1521
allowed to go stale once done.
1622

@@ -184,6 +190,28 @@ moves.
184190
publish prereleases (`0.x`, `next` tag) to GitHub Packages under the
185191
`@versionone` scope (or npmjs — decision below). Until then consumers pin a
186192
git SHA or a tarball from `npm pack`.
193+
- **A7 Attachments and embedded images** — gap 5's `attachment`/
194+
`EmbeddedImage`, as part of the asset-update abstraction, not a side
195+
channel. Two consumers to serve: gh-v1's `gh v1 attach` (list, get, add,
196+
put) and agility-rooms' attachment handling (survey it first: where it
197+
uploads, which `*.mvc` routes it uses, how rich text embeds images).
198+
Shape, from the docs and gh-v1's working code:
199+
- wire: `attachment.img/<id>` (and the embedded-image endpoint) — bytes
200+
in and out, addressed by the numeric id, not the OID token; a body as
201+
`Uint8Array`, `Blob` or `FormData` so it works in Node and the browser;
202+
the endpoint's statuses (404 missing, 413 too large) as typed errors.
203+
- service: `client.attachments.list(asset)` (metadata only — never
204+
select `Content` across many: it Base64s every blob into one
205+
response), `get(id)` → bytes + content type, `add(asset, bytes,
206+
{ filename, contentType, name, description })` as the docs prescribe
207+
(create the Attachment with empty `Content`, then POST the bytes; on a
208+
failed upload, report the created-but-empty asset rather than hide it),
209+
`put(id, bytes)` to replace.
210+
- batches: an Attachment create inside `assets.send`/`batch.run` stays an
211+
ordinary create; the bytes are a second step the service owns, and a
212+
receipt's reverse deletes the asset.
213+
Integration tests on v1sdktesting with `sdk-integration` fixtures,
214+
deleted by OID.
187215

188216
## Track B — k6i-agility-slack-bot (fast, on `bolt-migration`)
189217

@@ -244,8 +272,9 @@ instance: same requests out, same rows in.
244272
`guard: false` (gh-v1 sent it as written with a note — keep that UX by
245273
checking `unguardable()` first), and its number roots are wider than
246274
gh-v1's list.
247-
- **C4 Passthrough.** `raw`/`blob` stay CLI-local unless an `attachment`
248-
wire module (gap 5) is built for rooms — then `gh v1 attach` uses it.
275+
- **C4 Attachments on the SDK.** Once A7 lands, `gh v1 attach` moves onto
276+
`client.attachments` and `blob` leaves `lib/api.js`. `raw` stays CLI-local:
277+
it is the `gh v1 api` passthrough.
249278

250279
`lib/guard.js` stays in gh-v1: it is policy for a terminal passthrough, not
251280
SDK behaviour.
@@ -330,7 +359,8 @@ ESM" here means finishing the edges:
330359
(proposed: `sdk-adoption`), never on their default branches, PRs only on
331360
request.
332361
- **Order here:** A2 → A3 → C1 → C2 → B1 → B2 → A5 → C3 → B3/B4 → A6 → A1 →
333-
D1 → D2+. gh-v1 first: no framework migration, and stubbed-fetch tests
362+
A7 → C4 → D1 → D2+. A7 starts with the rooms attachment survey, so
363+
rooms' needs shape the module before gh-v1 moves onto it. gh-v1 first: no framework migration, and stubbed-fetch tests
334364
prove parity offline. A1 (browser credentials, onUnauthorized) waits for
335365
rooms; the bot maps `ApiError.status === 401` itself.
336366
- **In parallel, elsewhere:** G0–G3 in the gh-v1 session, S1–S4 in the
@@ -344,9 +374,18 @@ ESM" here means finishing the edges:
344374

345375
## Decisions needed
346376

347-
- Publish target: GitHub Packages (`@versionone` scope, org auth required
348-
for consumers in CI) vs. npmjs public. Rooms and slack both build in GitHub
349-
Actions under `digital-ai`, so cross-org package read access matters.
377+
- ~~Publish target~~ — decided 2026-11-07: public npmjs, as
378+
`@versionone/sdk` (the `@versionone` org already publishes there), starting
379+
at `1.0.0-alpha.0` on the `alpha` dist-tag so `latest` is never a
380+
prerelease. Not `v1sdk` 3.x: the 2.x package (`v1sdk`, last published
381+
2022) stays as it is, because a new major of the same name would imply a
382+
compatibility this SDK does not aim for. Publishing needs an `@versionone`
383+
org owner to set up a token or trusted publisher.
384+
- Dependency audit before the first publish — done 2026-11-07: no runtime
385+
dependencies, and the built bundle imports nothing. `npm audit` found 13
386+
issues, all in dev tooling (vitest/vite, tinypool, tsup/esbuild,
387+
rimraf/glob); fixed by in-range updates plus vitest 3 → 5 and tsup 8 → 9.
388+
`npm audit` is clean; unit and integration tiers pass on the new tools.
350389
- ~~Live instance for the integration tier from a cloud session~~ — done
351390
2026-11-04: `V1_URL`/`V1_ACCESS_TOKEN` point at the API-testing instance
352391
`https://www14.v1host.com/v1sdktesting`; `npm run test:integration` is
@@ -359,11 +398,16 @@ ESM" here means finishing the edges:
359398
- [x] A3 loc2 (`loc2.localize`, `client.localize`; GET, POST past 2000 encoded chars, culture via Accept-Language)
360399
- [x] A4 slack-shaped reads — `queryV1` wire module, `client.queryV1.query`/`queries` (read-only, idempotent, same row translation as api/asset)
361400
- [x] A5 services port from gh-v1 — `resolve` (`client.resolve.numbers`/`.ref`), `guard` (`client.assets.sendGuarded`), `batch` (`client.batch.run`/`.reversible`)
362-
- [ ] 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
401+
- [ ] A7 attachments and embedded images (`attachment` wire module, `client.attachments`; survey rooms first)
402+
- [ ] A6 CI + prerelease publish — CI (`.github/workflows/ci.yml`: verify on Node 20/22/24, `npm pack` artifact), git-SHA installs (`prepare`) and the dependency audit done; publish `@versionone/sdk@1.0.0-alpha.0` to npmjs with `--tag alpha` next (needs an org owner's token or trusted publisher)
363403
- [x] G0–G3 gh-v1 migrations — versionone/gh-v1#29–#32
364404
- [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)
405+
- [x] B1 slack bot client swap — digital-ai/k6i-agility-slack-bot#29
406+
- [x] B2 slack bot decodes deleted; reads through `client.queryV1` with a schema — digital-ai/k6i-agility-slack-bot#30
407+
- [ ] B3–B4 slack bot writes on api/asset, number resolution — brief: `docs/handoffs/slack-bot-sdk-b3-b4.md` in the bot repo
366408
- [x] C0 gh-v1 surveyed (2026-11-04)
367409
- [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)
410+
- [x] C2 gh-v1 rows translated through the schema; `isTrue`/`oidOf` gone — versionone/gh-v1#36
411+
- [x] C3 gh-v1 resolve and batch on `client.resolve`/`client.batch` — versionone/gh-v1#37 (with the SDK fixes from #43)
412+
- [ ] C4 gh-v1 attachments on `client.attachments` (after A7); `raw` stays CLI-local
369413
- [ ] D1–D4 agility-rooms

‎plans/002-adopt-sdk-gh-v1.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,11 @@ runtime dependency, but its code ships inside the repo:
6666
`force`). Keep the CLI's UX for an unguardable batch by checking
6767
`unguardable()` first, then sending with `guard: false` and the existing
6868
note. Prompting, rendering, permalinks and receipt files stay in gh-v1.
69-
4. **C4 Passthrough.** `raw`/`blob` stay as they are. `lib/guard.js` stays:
70-
it is CLI policy, not SDK behaviour.
69+
4. **C4 Attachments on the SDK** (after plan 001's A7). `gh v1 attach`
70+
(`list`, `get`, `add`, `put`) moves onto `client.attachments`; `blob`
71+
leaves `lib/api.js`. File I/O, `--out`, the content-type guess and the
72+
CLI's messages stay in gh-v1. `raw` (the `gh v1 api` passthrough) and
73+
`lib/guard.js` stay: they are CLI policy, not SDK behaviour.
7174

7275
## Rules
7376

‎specs/000-layers.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,10 @@ So the `assetApi` wire module translates a row against an `AttributeLookup`
6464
to the attribute it lands on; Numeric, LongInt and Rank become numbers, Boolean
6565
booleans, a single relation one Ref or null (the endpoint sends an unset
6666
one as `{ _oid: "NULL" }`), an aggregate a number, and a
67-
sub-select's rows are translated by their own type. A key the schema does
67+
sub-select's rows are translated by their own type. A `group` query's
68+
rows are groups (`_oid` the group key, `null` for the unset group; rows
69+
under `_children`), and the children translate by the query's own type,
70+
recursively for nested groups. A key the schema does
6871
not know is left as sent. Without a lookup the row keeps the endpoint's
6972
spelling, OIDs normalised and a `NULL` relation null — never silently
7073
guessed.

‎src/client.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import type { Transport } from "./core/transport";
44
import * as batch from "./services/batch";
55
import * as guard from "./services/guard";
66
import * as resolve from "./services/resolve";
7-
import { Schema } from "./services/schema";
7+
import { Schema, type TypeCache } from "./services/schema";
88
import type { Asset } from "./types/asset";
99
import type { Metamodel } from "./types/metamodel";
1010
import * as assetApi from "./wire/assetApi";
@@ -25,6 +25,13 @@ export interface ClientOptions {
2525
metamodel?: Metamodel;
2626
/** Share an existing schema (and its cache) instead of starting a new one. */
2727
schema?: Schema;
28+
/**
29+
* Share loaded type definitions with other clients of the same instance,
30+
* whatever their credentials: this client's schema keeps its types in
31+
* `types` and loads anything missing through this client's transport.
32+
* A server that builds a client per user keeps one TypeCache per instance.
33+
*/
34+
types?: TypeCache;
2835
}
2936

3037
/**
@@ -53,7 +60,7 @@ export class AgilityApiClient {
5360
unauthorizedStatuses: config.unauthorizedStatuses
5461
});
5562
this.config = config;
56-
this.schema = opts.schema ?? new Schema(this.transport, opts.metamodel);
63+
this.schema = opts.schema ?? new Schema(this.transport, opts.metamodel, opts.types);
5764
}
5865

5966
private readonly config: ClientConfig;

‎src/index.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export type { AssetDocument, AssetsDocument, ReadParams, ReadResult } from "./wi
2323
export type { Localizations, LocalizeOptions } from "./wire/loc2";
2424

2525
// Services: what to do.
26-
export { Schema } from "./services/schema";
26+
export { Schema, TypeCache } from "./services/schema";
2727
export { CHUNK as RESOLVE_CHUNK, NUMBER_ROOTS, isAssetNumber, resolveNumbers, resolveRef } from "./services/resolve";
2828
export type { Resolution, ResolvedAsset } from "./services/resolve";
2929
export { UNGUARDABLE_TYPES, UnguardableError, guard, sendGuarded, stampsOf, touchedBy, unguardable } from "./services/guard";

‎src/services/schema.ts‎

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,42 @@ import type { AssetType, AssetTypeToken, AttributeDefinition, AttributeLookup, M
1717
import * as meta from "../wire/meta";
1818
import { attributeOf, fromTypeOf, typesNeeded, type SelectShape } from "../wire/select";
1919

20-
export class Schema implements AttributeLookup {
20+
/**
21+
* The type definitions loaded from one instance, shareable by any number of
22+
* schemas. A definition does not depend on who read it, so a server that
23+
* builds a client per user (each with that user's token) can give them all
24+
* one cache per instance: each type is fetched once, through whichever
25+
* client needed it first, and concurrent loads of a type share one request.
26+
*/
27+
export class TypeCache {
2128
/** Loaded types; null records that the instance has no such type, so it is not asked for again. */
22-
private readonly types = new Map<AssetTypeToken, AssetType | null>();
23-
private readonly loading = new Map<AssetTypeToken, Promise<AssetType | null>>();
29+
readonly types = new Map<AssetTypeToken, AssetType | null>();
30+
readonly loading = new Map<AssetTypeToken, Promise<AssetType | null>>();
31+
32+
/** A cache seeded from a metamodel document. */
33+
static fromMetamodel(metamodel: Metamodel): TypeCache {
34+
const cache = new TypeCache();
35+
for (const [token, type] of Object.entries(metamodel.AssetTypes)) cache.types.set(token, type);
36+
return cache;
37+
}
38+
}
39+
40+
export class Schema implements AttributeLookup {
41+
private readonly types: Map<AssetTypeToken, AssetType | null>;
42+
private readonly loading: Map<AssetTypeToken, Promise<AssetType | null>>;
43+
/** Where this schema keeps what it loads; pass it to another schema to share it. */
44+
readonly cache: TypeCache;
45+
46+
constructor(private readonly transport?: Transport, seed?: Metamodel, cache: TypeCache = new TypeCache()) {
47+
this.cache = cache;
48+
this.types = cache.types;
49+
this.loading = cache.loading;
50+
if (seed) for (const [token, type] of Object.entries(seed.AssetTypes)) if (!this.types.has(token)) this.types.set(token, type);
51+
}
2452

25-
constructor(private readonly transport?: Transport, seed?: Metamodel) {
26-
if (seed) for (const [token, type] of Object.entries(seed.AssetTypes)) this.types.set(token, type);
53+
/** The same cache, loading anything missing through another transport (another user's credentials). */
54+
withTransport(transport: Transport): Schema {
55+
return new Schema(transport, undefined, this.cache);
2756
}
2857

2958
/** A complete schema from a metamodel document; nothing is ever fetched. */

‎src/wire/assetApi.ts‎

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ export interface QueryBody {
5050
find?: string;
5151
findin?: string[];
5252
asof?: string;
53+
/** Group the rows by a relation: each result row is then a group, its rows under `_children` (translated like any row of `from`). */
54+
group?: (string | { from: string; [key: string]: unknown })[];
5355
}
5456
export interface UpdateBody { from: string; filter?: string[]; where?: Record<string, string>; with?: Record<string, CommandValue>; update: Record<string, CommandValue> }
5557
export interface ExecuteBody { from: string; filter?: string[]; where?: Record<string, string>; with?: Record<string, CommandValue>; execute: string | { op: string; args?: Record<string, CommandValue>; list?: Record<string, CommandValue>[] } }
@@ -128,8 +130,17 @@ export interface RowContext { lookup: AttributeLookup; type: AssetTypeToken }
128130
*/
129131
export function fromWireRow<Row extends Asset = Asset>(row: Record<string, unknown>, context?: RowContext): Row {
130132
const out: Record<string, unknown> = {};
133+
// A `group` query answers with group rows: the group's key as `_oid` and
134+
// the grouped rows under `_children`. The children are rows of the query's
135+
// own type (or group rows again, for nested groups), so they translate
136+
// with the same context. No attribute is called _children, so its presence
137+
// is what marks a group row; a "NULL" key is the group of rows with the
138+
// relation unset, and reads as null like any unset relation.
139+
const grouped = Array.isArray(row._children);
131140
for (const [k, v] of Object.entries(row)) {
132-
if (k === "_oid" && typeof v === "string") out[k] = momentless(v);
141+
if (grouped && k === "_children") out[k] = (v as unknown[]).map((child) => (child && typeof child === "object" ? fromWireRow(child as Record<string, unknown>, context) : child));
142+
else if (grouped && k === "_oid" && v === "NULL") out[k] = null;
143+
else if (k === "_oid" && typeof v === "string") out[k] = momentless(v);
133144
else out[k] = context ? translate(v, resolvePath(context.lookup, context.type, k), context.lookup) : fromWireValue(v);
134145
}
135146
return out as Row;

0 commit comments

Comments
 (0)