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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ npm test
## Public API

- `AgilityApiClient` - top-level SDK client (`meta`, `data` for rest-1, `assets` for api/asset, `localize` for loc-2, `schema`), and the services on it:
- `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
Expand Down
43 changes: 23 additions & 20 deletions plans/001-sdk-adoption-campaign.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,8 +134,11 @@ Found by reading the code above against `src/`:
return failures typed (it does) and offer `throwOnFailure` producing an
`ApiError` subclass carrying `sourceCommandIndex` per failure.
5. **Missing wire modules** named in spec 000 but not built: `loc2` (both
consumers), `queryV1` (slack's main read path — or migrate slack to
api/asset queries instead; see B3), `attachment`/`EmbeddedImage` (rooms).
consumers), `queryV1` (slack's main read path), `attachment`/
`EmbeddedImage` (rooms). `queryV1` is built (2026-11-06) and kept on
purpose even though api/asset can run the same queries: query.v1 is
read-only by construction and answers with rows alone, so it is the safe,
simple place to explore a query (and its requests may be retried).
6. **rest-1 writes — not needed.** Settled from the docs
(apis/asset-bulk/updating-assets, creating-assets): api/asset carries a
change comment as `ChangeComment` inside an update's `update` node, or
Expand Down Expand Up @@ -166,11 +169,11 @@ moves.
- **A2 Command-failure ergonomics** — gap 4: `CommandFailureError`,
`send(..., { throwOnFailure })`, `sourceCommandIndex` preserved.
- **A3 loc2 wire module** — gap 5, smallest consumer-shared endpoint.
- **A4 Slack-shaped reads** — decide queryV1-module vs. api/asset-only by
porting `lib/queries.js` bodies into integration tests against the dev
instance; whichever reads translate cleanly wins. (No `rest1` writes:
gap 6 is settled.) If the bot's S2 lands first, this is only the
translation check.
- **A4 Slack-shaped reads** — done: a `queryV1` wire module and
`client.queryV1.query`/`queries`, rows translated through the same
schema as api/asset (the integration tier proves both endpoints give the
same rows). The bot keeps query.v1 for its reads. (No `rest1` writes:
gap 6 is settled.)
- **A5 Services port** — gap 7. `services/resolve` first (gh-v1's
`resolveAssets`; slack's `queryForAssetByNumber` needs it too), then
`services/guard` (`guarded()`), then `services/batch` (preview, describe,
Expand Down Expand Up @@ -200,12 +203,12 @@ bodies in `lib/queries.js`), api/asset (`bulk`, `createWithNumber`,

- **B0 Pre-SDK migrations in the bot's own session** (S1–S4 under
*Consumer migrations* below). They shrink what the SDK must support:
once S2 moves reads to api/asset, the SDK needs no `queryV1` module and
A4 collapses to "verify the bot's query bodies translate" — and A4's
rest-1-writes question is closed (gap 6).
once S2 pages every read, B1's reads move to `client.queryV1` as they
are; A4's rest-1-writes question is closed (gap 6).
- **B1 Client swap.** `createV1Client` keeps its surface; inside, one
`AgilityApiClient({ baseUrl: url, accessToken })`. `query`/`bulk`/`exec`
→ `client.assets.*`; `fetchLocalizations` → `loc2` (A3).
`AgilityApiClient({ baseUrl: url, accessToken })`. `query` →
`client.queryV1.queries`; `bulk`/`exec` → `client.assets.*`;
`fetchLocalizations` → `loc2` (A3).
`test/v1-service.test.js` stays the parity proof.
- **B2 Delete the decodes.** Rows arrive translated: `"True"` comparisons,
`IsKanban.toLowerCase()`, `toMomentless` and positional
Expand All @@ -225,8 +228,9 @@ instance: same requests out, same rows in.

- **C1 Seam swap.** Re-implement `lib/api.js`'s exported surface (`query`,
`queries`, `asset`, `attempt`, `rest`, `meta`, `localize`, `raw`, `blob`,
`permalink`) on `AgilityApiClient`, `require`d from the CJS build. Callers
unchanged; all 99 tests green. Needs A2, A3 (loc2), gap 9.
`permalink`) on the SDK, imported from a vendored copy of its ESM build
(gh installs extensions without npm; see plan 002, *Dependency*). Callers
unchanged; all tests green. Needs A2, A3 (loc2).
- **C2 Delete the decodes.** With rows translated by the schema, `isTrue`
and `oidOf` drop out of `meta.js`, `resolve.js`, `board.js`; `momentless`
comes from the SDK.
Expand Down Expand Up @@ -300,10 +304,9 @@ ESM" here means finishing the edges:
- **S1 Finish ESM tooling.** `jsconfig.json` still says `module: commonJS`,
`target: es6`; set `NodeNext`/`ES2022` and turn on `checkJs` so editors
and `tsc --noEmit` see the real module system (and, later, SDK types).
- **S2 query.v1 → api/asset reads.** Same query bodies; the endpoint is
`api/asset` and results come from `queryResult.results` (positional, one
array per query). Add an explicit `page` to every body. Removes the
SDK's need for a `queryV1` module.
- **S2 Page every read.** Reads stay on query.v1 (read-only, simplest
responses). Add an explicit `page` to every body in `lib/queries.js`,
as the SDK's `queryV1` refuses an unpaged query.
- **S3 Close Bolt Phase 2.** Drop `botkit`, `botbuilder-*`,
`botkit-plugin-cms` and `axios` from `package.json` once nothing imports
them; delete `lib/v1api.js` and the `plugins/` Botkit modules that
Expand Down Expand Up @@ -354,12 +357,12 @@ ESM" here means finishing the edges:
- [x] A1 transport: credentials, onUnauthorized (idempotent requests resent once; writes never), signal (`RequestOptions.signal`, `client.withSignal`)
- [x] A2 command-failure error (`CommandFailureError`, `throwOnFailure`, reads always throw)
- [x] A3 loc2 (`loc2.localize`, `client.localize`; GET, POST past 2000 encoded chars, culture via Accept-Language)
- [ ] A4 slack-shaped reads (queryV1 or api/asset)
- [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] C0 gh-v1 surveyed (2026-11-04)
- [ ] C1–C4 gh-v1 — brief: `plans/002-adopt-sdk-gh-v1.md`
- [ ] C1–C4 gh-v1 — brief: `plans/002-adopt-sdk-gh-v1.md`; C1 in versionone/gh-v1#33 (SDK vendored)
- [ ] D1–D4 agility-rooms
23 changes: 17 additions & 6 deletions plans/002-adopt-sdk-gh-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,27 @@ output stay the same; the code that duplicates the SDK goes.
in `lib/api.js`, `meta.js`, `resolve.js`, `board.js`, `batch.js`, and
`commands/apply.js`, `bulk.js`, `board.js`.

## Dependency
## Dependency: vendored

No registry package yet. Add the first runtime dependency as a pinned commit:
`"@versionone/sdk": "github:versionone/VersionOne.SDK.JavaScript#<merge sha>"`.
The SDK's `prepare` script builds `dist` on install. gh-v1 is ESM now, so use
`import` (the ESM build), not `require`.
gh installs a script extension with a plain `git clone` and never runs npm,
so gh-v1 cannot rely on `node_modules` at run time. The SDK is still a
runtime dependency, but its code ships inside the repo:

- `vendor/versionone-sdk/` holds the SDK's ESM build (`index.js`, which
imports nothing) and types (`index.d.ts`), plus `SOURCE.json` naming
the commit they came from. `lib/api.js` imports from there.
- `package.json` lists `@versionone/sdk` under `devDependencies`, pinned
to a full commit, so the lockfile records exactly what was vendored.
- To move the pin: change it in `package.json`, then
`npm install && npm run vendor:sdk`, and commit the copy.
`test/vendor.test.js` fails if the copy, `SOURCE.json` and the pin
disagree.
- Publishing the SDK to a registry (plan 001, *Decisions needed*) changes
only where the pin points; the copy stays.

## Steps, one PR each, on branch `sdk-adoption`

1. **C1 Seam swap** (`lib/api.js`). Keep `api(cfg)` and the `Api` typedef.
1. **C1 Seam swap** (`lib/api.js`) — done in versionone/gh-v1#33. Keep `api(cfg)` and the `Api` typedef.
Inside, build one `AgilityApiClient({ baseUrl: cfg.v1Url, accessToken })`.
Map: `query`/`queries` → `client.assets.query`/`queries`;
`asset` → `client.assets.send(cmds, { throwOnFailure: true })`
Expand Down
13 changes: 7 additions & 6 deletions plans/003-adopt-sdk-k6i-agility-slack-bot.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ hand-decoding and rest-1 write path go.
`fetchLocalizations` (loc-2), and `queryFor*` helpers built on
`lib/queries.js`. `test/v1-service.test.js` stubs global `fetch`.
- **S1–S4 are not done yet**: `jsconfig.json` still says `commonJS`/`es6`;
reads still post to `query.v1`; `axios`, `botkit*`, `botbuilder-*` are
query bodies have no explicit page; `axios`, `botkit*`, `botbuilder-*` are
still dependencies and `lib/v1api.js` still exists. These come first.

## Dependency
Expand All @@ -34,12 +34,12 @@ The SDK's `prepare` script builds `dist` on install. The bot is ESM, so
## Steps, one PR each

0. **S1–S4 first**, as written in `plans/handoffs/k6i-agility-slack-bot.md`.
B1 needs at least S2 (reads on api/asset) and S3 (axios and Botkit gone).
B1 needs at least S2 (every read paged) and S3 (axios and Botkit gone).
If the bot's own session already merged them, skip.
1. **B1 Client swap** (`services/v1.js`). Inside `createV1Client`, build one
`new AgilityApiClient({ baseUrl: url, accessToken })`. `query` →
`client.assets.queries` (positional; a failed query now throws instead
of shifting later results); `bulk`/`exec`/`createWithNumber` →
`client.queryV1.queries` (the bot's reads stay on query.v1: read-only,
simplest responses; a refused query throws with the server's message); `bulk`/`exec`/`createWithNumber` →
`client.assets.send`; `fetchLocalizations` → `client.localize`;
`findAssets` → `client.data.list("BaseAsset", { find, findin, where,
sort, page, deleted: false })`. Map `ApiError` with `status === 401`
Expand Down Expand Up @@ -72,8 +72,9 @@ The SDK's `prepare` script builds `dist` on install. The bot is ESM, so
## Done when

- `npm test` passes on each PR; S1's `npm run typecheck` passes.
- `services/v1.js` makes no `fetch` call of its own; nothing posts to
`query.v1` or writes through rest-1.
- `services/v1.js` makes no `fetch` call of its own; reads go through
`client.queryV1` (or `client.data` for `find`), and nothing writes through
rest-1.
- No string-boolean comparisons or local `toMomentless` remain.
- Sandbox-workspace smoke passes: asset mention, `/agility find`,
backlog-item status change, webhook notification.
Expand Down
16 changes: 6 additions & 10 deletions plans/handoffs/k6i-agility-slack-bot.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,12 @@ no `require`). `jsconfig.json` still declares `module: commonJS`,
`npm run typecheck` (`tsc -p jsconfig.json`; `typescript` is already a
devDependency). Start non-strict; fix or annotate what it finds.

**S2 — Reads from query.v1 to api/asset.** In `services/v1.js`, `query()`
posts to `query.v1`. Post the same bodies to `api/asset` instead and read
`queryResult.results` (one array per query, positional, an empty array
for a query that matched nothing) so callers still receive an array of
result arrays. Add an explicit `page: { size, start }` to every body in
`lib/queries.js` (api/asset reads should never be unbounded). Note that
api/asset returns every scalar as a string (`"True"`, `"5"`) and a
single relation selected with a sub-select as a one-element list — check
each call site's comparisons against a real response. Update
`test/v1-service.test.js`.
**S2 — Page every read.** Reads stay on `query.v1`: it is read-only and
answers with rows alone, and the SDK has a `queryV1` module for it. Add an
explicit `page: { size, start }` to every body in `lib/queries.js` (the
SDK refuses an unpaged query; no read should be unbounded), choosing sizes
that match what each caller actually shows. Update
`test/v1-service.test.js` to assert the pages sent.

**S3 — Close Bolt Phase 2.** AGENTS.md says axios is dropped in Phase 2.
Once nothing imports them, remove `axios`, `botkit`,
Expand Down
11 changes: 8 additions & 3 deletions specs/000-layers.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,9 +132,11 @@ expect-zero control beside every "it returned rows".

## 6. Politeness is in the layer

No read is unbounded: `assetApi.queries` refuses a query without a page,
No read is unbounded: `assetApi.queries` and `queryV1.queries` refuse a
query without a page,
and the rest-1 module will refuse a Data read without one. The transport
retries only idempotent requests (GET by default), only on a network
retries only idempotent requests (GET by default, and every query.v1
POST, since that endpoint cannot write), only on a network
failure or a 5xx, never a 4xx; a batch write is never retried unless the
caller has made it idempotent with a guard and says so.

Expand All @@ -156,7 +158,10 @@ always throw: a query the server rejected must not come back as `[]`.
The server drops a failed query's slot from `queryResult.results` (later
results shift up); `send` restores it as `null` by `sourceCommandIndex`, so
positions always match the queries sent, and refuses to guess when the
counts disagree.
counts disagree. If a failure carries no sourceCommandIndex it may be any
of the queries, so every query slot is null and the failures are the
answer (thrown for reads and with throwOnFailure); a disagreement with no
such failure is an error of its own.

The guard (`services/guard`) is what makes a write batch safe to resend:
each per-asset command of the server's projection is sent scoped to its
Expand Down
17 changes: 17 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type { Metamodel } from "./types/metamodel";
import * as assetApi from "./wire/assetApi";
import * as loc2 from "./wire/loc2";
import * as meta from "./wire/meta";
import * as queryV1 from "./wire/queryV1";
import * as rest1 from "./wire/rest1";

export interface ClientOptions {
Expand Down Expand Up @@ -113,6 +114,22 @@ export class AgilityApiClient {
history: <Row extends Asset = Asset>(target: string, params: rest1.ReadParams) => rest1.history<Row>(this.transport, target, params)
};

/**
* query.v1: reads only, the same query bodies as assets.query, rows
* translated the same way. The endpoint cannot write, so it is the safe
* place to explore a query; its requests may be retried like a GET.
*/
readonly queryV1 = {
query: async <Row extends Asset = Asset>(q: assetApi.QueryBody) => {
await this.schema.ensureFor([q]);
return queryV1.query<Row>(this.transport, q, { lookup: this.schema });
},
queries: async <Row extends Asset = Asset>(qs: assetApi.QueryBody[]) => {
await this.schema.ensureFor(qs);
return queryV1.queries<Row>(this.transport, qs, { lookup: this.schema });
}
};

/** api/asset: batches and queries, rows translated through the schema. */
readonly assets = {
send: async <Row extends Asset = Asset>(commands: assetApi.Command[], options?: Omit<assetApi.SendOptions, "lookup">) => {
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export type { AssetType, AttributeDefinition, AttributeLookup, AttributeType, At
export type * from "./types/assets/index";

// The wire layer: one module per endpoint.
export { assetApi, rest1, meta, loc2 } from "./wire";
export { assetApi, rest1, meta, loc2, queryV1 } from "./wire";
export * as select from "./wire/select";
export type { AssetApiResponse, Command, CommandBody, CommandFailureDetail, Outcome, CommandValue, CreateBody, ExecuteBody, QueryBody, UpdateBody, ReadOptions, RowContext, SendOptions } from "./wire/assetApi";
export { CommandFailureError } from "./wire/assetApi";
Expand Down
26 changes: 19 additions & 7 deletions src/wire/assetApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,12 +283,13 @@ function queriesIn(commands: Command[]): { body: QueryBody; index: number }[] {
* The server's results, one slot per query again. It sends a slot for every
* query that ran (an empty match included) and none for a query that
* failed; a failure names its command by sourceCommandIndex, so the failed
* queries are known and the rest fill in order. If the counts disagree the
* positions cannot be trusted, and that is an error rather than a guess.
* queries are known and the rest fill in order. Null when the counts
* disagree: the positions cannot be trusted, and the caller decides what
* that means rather than this guessing.
*/
function realign<T>(sent: { index: number }[], results: T[], failed: Set<number>): (T | null)[] {
function realign<T>(sent: { index: number }[], results: T[], failed: Set<number>): (T | null)[] | null {
const expected = sent.filter((q) => !failed.has(q.index)).length;
if (results.length !== expected) throw new Error(`api/asset returned ${results.length} query results for ${expected} queries that did not fail; cannot tell which result belongs to which query`);
if (results.length !== expected) return null;
let next = 0;
return sent.map((q) => (failed.has(q.index) ? null : results[next++]!));
}
Expand All @@ -311,13 +312,24 @@ export async function send<Row extends Asset = Asset>(transport: Transport, comm
// A preview answers with the projection, not an envelope.
if (options.previewOnly) return response;
const queries = queriesIn(commands);
const failed = new Set(failuresOf(response).flatMap((f) => (f.index === null ? [] : [f.index])));
const aligned = realign(queries, response.queryResult?.results ?? [], failed);
const failures = failuresOf(response);
const failed = new Set(failures.flatMap((f) => (f.index === null ? [] : [f.index])));
const results = response.queryResult?.results ?? [];
let aligned = realign(queries, results, failed);
if (!aligned) {
// A failure without a sourceCommandIndex may be one of the queries, so
// no row can be placed with confidence: every slot is null, never a
// misplaced row, and the failures themselves are the answer (thrown
// with throwOnFailure, as every read asks). With no such failure, the
// server's answer is inconsistent, and that is an error of its own.
if (!failures.some((f) => f.index === null)) throw new Error(`api/asset returned ${results.length} query results for ${queries.filter((q) => !failed.has(q.index)).length} queries that did not fail; cannot tell which result belongs to which query`);
aligned = queries.map(() => null);
}
const contextOf = (i: number): RowContext | undefined => {
const type = options.lookup ? fromTypeOf(queries[i]!.body.from, commands) : null;
return type ? { lookup: options.lookup!, type } : undefined;
};
const translated: AssetApiResponse<Row> = { ...response, queryResult: { ...response.queryResult, results: aligned.map((rows, i) => rows && rows.map((r) => fromWireRow<Row>(r as Record<string, unknown>, contextOf(i)))) } };
const translated: AssetApiResponse<Row> = { ...response, queryResult: { ...response.queryResult, results: aligned!.map((rows, i) => rows && rows.map((r) => fromWireRow<Row>(r as Record<string, unknown>, contextOf(i)))) } };
if (options.throwOnFailure && translated.commandFailures?.count) throw new CommandFailureError(translated);
return translated;
}
Expand Down
1 change: 1 addition & 0 deletions src/wire/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export * as assetApi from "./assetApi";
export * as rest1 from "./rest1";
export * as meta from "./meta";
export * as loc2 from "./loc2";
export * as queryV1 from "./queryV1";
export * as select from "./select";
export type { AssetDocument, AssetsDocument, ReadParams, ReadResult } from "./rest1";
export type { AssetApiResponse, Command, CommandBody, CommandValue, CreateBody, ExecuteBody, QueryBody, UpdateBody, CommandFailure, CommandError, OidTokens, SendOptions } from "./assetApi";
Expand Down
Loading
Loading