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. `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
- `batch.run` / `batch.reversible` - preview, snapshot, (confirm,) send guarded, and a receipt with a reverse batch. Undo (`batch.reversible`) reactivates a closed asset before deleting it, and a second command on one asset is sent as a second guarded pass. `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
- `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
- `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
67 changes: 66 additions & 1 deletion plans/001-sdk-adoption-campaign.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,13 @@ Findings from the testing instance worth carrying into later milestones:
applies.
- A StoryStatus create requires `RollupState` (`"Active"` and `64` both
accepted).
- An api/asset update that gives a multi-value relation as a plain list
**adds** to it (like rest-1's `act: "add"`); it does not replace the list
(slack bot, 2026-11-07).
- **Agility refuses to Delete a closed asset**
(`Violation'AssetCannotDeleteClosed`); reactivate first. The batch
service's reverse does this (`reversible` reports `reactivated`), and the
guard sends a second command on one asset as a second pass.

## What the consumers do today (surveyed 2026-11-04)

Expand All @@ -91,6 +98,37 @@ hand-decodes string booleans in 48 places (including `getApiResults`
patching `IsReadOnly`), and `src/metaAssetStore/` keeps its own per-type
field knowledge that overlaps the SDK's `schema` service.

Re-surveyed 2026-11-07 at `main` `0874590` (read-only):

- **HTTP.** One default axios instance (`src/utils/axios.tsx`) plus
`getAxiosInstance(...)` variants, a bare `meta` instance and a plain
`axios.create` in `src/navigation/routes.tsx`; several skip the
auth-refresh and snackbar interceptors. 88 production files import axios.
Cookie auth only. On 401/403 an SSO popup (`Account.mvc/Login`), then
replay. Cancellation by `AbortController` in a few places; no retries.
The commandFailures snackbar runs only when the URL is exactly
`/api/asset`; 9 calls written `api/asset` (no slash) bypass it.
- **Endpoints.** api/asset (111 call sites, 57 files), query.v1 (16),
rest-1.v1 reads (4) **and writes** (5 `Data/{T}/{id}` posts with
`{ Attributes: { X: { value, act } } }`, plus 2 `?op=Copy`), meta.v1,
loc-2.v1, `loc.v1`, `config.v1`, `attachment.img`, `embedded.img`,
`EmbeddedImage.mvc/Upload`, about 15 other `*.mvc` routes and about 20
custom `api/*` routes (reports, tags, watch, rooms2config, LLM).
- **Writes.** `metaAssetSlice` updates multi-value fields as
`{ X: { [value]: "add" | "remove" } }`, sends a cleared relation as
`"NULL"`, updates optimistically and reverts on error. Execute ops with
and without `args` (Convert). No change comments, no ChangeDate checks.
- **Reads.** 34 explicit pages, but some board and `ID=$Oids` reads are
unbounded. Positional `results[n]` with index constants
(`metaAssetStore/constants.ts`). No `group` queries.
- **Decoding.** 46 `"True"`/`"False"` comparisons; `NULL_OID` referenced
118 times; `massageApiAssetResponseObject` unwraps single relations by
meta. meta.v1 is loaded by `store/metaDefinition/useMetaDefinitionStore.ts`
(per type, in-flight dedupe, ETag revalidation), not by `metaAssetStore/`.
- **Tests.** Jest (222 files) mocks modules; no MSW. The AI domain's
injected `IHttpClient` / `FakeHttpClient` is the cleanest seam for a swap.
Playwright (79 specs) runs against a live server, including attachment CRUD.

**gh-v1** (surveyed 2026-11-04 at `41fe333`). A `gh` extension in plain
CommonJS Node (≥20), no runtime dependencies, `node --test` with 99 passing
tests. One seam, `lib/api.js` (native `fetch`, `Authorization` header from
Expand Down Expand Up @@ -213,6 +251,25 @@ moves.
Integration tests on v1sdktesting with `sdk-integration` fixtures,
deleted by OID.

What rooms does today (survey 2026-11-07), which A7 must cover:
- **Upload** is one api/asset create with the bytes inline: `{ AssetType:
"Attachment", Asset, ContentType, Filename, Name, Content: <Base64> }`
(`MultiValueRelations/Attachments.tsx`). The UI caps a file at 4 MB and
has no 413 handling. So `add` needs both modes: inline Base64 for small
files (one request, what rooms does) and create-then-POST-bytes for
large ones (what the docs and gh-v1 do), chosen by size.
- **Preview** of an image re-reads `Content` for one row and builds a
`data:` URL; **download** opens `attachment.img/<id>` from the app root.
So `get` returns bytes and type, and the service exposes the
`attachment.img` URL for a browser to open.
- **Delete** is `execute: "Delete"` on the Attachment.
- **Embedded images** (TinyMCE paste) post multipart to
`EmbeddedImage.mvc/Upload` with fields `image` and `oidToken`; the
answer is HTML with `<textarea>{ "Url": … }</textarea>`. Stored HTML
references `embedded.img/<id>`, rewritten to app-root URLs. So:
`embeddedImages.upload(asset, blob)` → `{ id, url }`, parsing that
response, and a helper that normalises `embedded.img` URLs.

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

Target branch: `bolt-migration`, not `main`. There, Bolt Phase 2 has already
Expand Down Expand Up @@ -281,6 +338,13 @@ SDK behaviour.

## Track D — agility-rooms (slow)

Open questions from the 2026-11-07 survey: rooms still writes through
rest-1 (5 attribute posts, 2 `?op=Copy`) and reads `rest-1.v1/New/{T}`
templates, which gap 6's "no rest-1 writes" did not foresee; decide whether
those move to api/asset (Copy as an execute?) or the SDK grows a narrow
rest-1 write path. Its commandFailures policy should apply to every
api/asset call, not only URLs spelled `/api/asset`.

Strangler, starting below the call sites so nothing user-visible moves:

- **D1 SDK under the existing axios.** Implement a `Transport` that
Expand Down Expand Up @@ -404,7 +468,8 @@ ESM" here means finishing the edges:
- [x] S1–S4 slack-bot migrations — digital-ai/k6i-agility-slack-bot#21–#24 (on `bolt-migration`)
- [x] B1 slack bot client swap — digital-ai/k6i-agility-slack-bot#29
- [x] B2 slack bot decodes deleted; reads through `client.queryV1` with a schema — digital-ai/k6i-agility-slack-bot#30
- [ ] B3–B4 slack bot writes on api/asset, number resolution — brief: `docs/handoffs/slack-bot-sdk-b3-b4.md` in the bot repo
- [x] B3 slack bot writes on `client.assets.send` (rest-1 writes gone) — digital-ai/k6i-agility-slack-bot#32
- [x] B4 slack bot number lookups on `client.resolve.numbers` — digital-ai/k6i-agility-slack-bot#34 (SDK #44 adopted in #33: pin `785b4c9`, a `TypeCache` per instance)
- [x] C0 gh-v1 surveyed (2026-11-04)
- [x] C1 gh-v1 seam swap — versionone/gh-v1#33, with the vendored SDK's hashes, CI and Node 22 floor in #35
- [x] C2 gh-v1 rows translated through the schema; `isTrue`/`oidOf` gone — versionone/gh-v1#36
Expand Down
5 changes: 5 additions & 0 deletions specs/000-layers.md
Original file line number Diff line number Diff line change
Expand Up @@ -176,3 +176,8 @@ exception to "values travel as `with` variables", because a ChangeDate
variable never compares equal — after a strict timestamp check. Moment is
not a stamp: a filter on an earlier Moment selects the asset as it was then.
A guarded batch with no creates is sent idempotent; a plain one never is.
Two commands on one asset are sent in passes: the second goes only if the
first applied, guarded on the ChangeDate read just after it (a shared
pre-batch stamp would make the second match nothing). A reverse batch
reactivates a closed asset before deleting it, since Agility refuses to
delete a closed asset.
4 changes: 2 additions & 2 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,8 +99,8 @@ export class AgilityApiClient {
* as is.
*/
readonly resolve = {
numbers: (numbers: readonly string[]) => resolve.resolveNumbers(this.transport, numbers),
ref: (spec: string) => resolve.resolveRef(this.transport, spec)
numbers: (numbers: readonly string[]) => resolve.resolveNumbers(this.transport, numbers, { types: this.schema.cache }),
ref: (spec: string) => resolve.resolveRef(this.transport, spec, { types: this.schema.cache })
};

/**
Expand Down
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export type { RequestBody, RequestOptions, ResponseType, Transport } from "./cor

// The model: what things are.
export type { Asset, AttributeValue, CustomAttributes, DateString, Oid, Ref, AssetStateValue } from "./types/asset";
export { ASSET_STATE_CODES } from "./types/asset";
export { ASSET_STATE_CODES, assetStateOf } from "./types/asset";
export { isOidToken, momentless, parseOid, toOid, typeOfOid } from "./types/oid";
export type { OidParts } from "./types/oid";
export type { AssetType, AttributeDefinition, AttributeLookup, AttributeType, AttributeTokenRef, AssetTypeToken, Metamodel } from "./types/metamodel";
Expand Down
60 changes: 50 additions & 10 deletions src/services/batch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import type { AssetTypeToken } from "../types/metamodel";
import { momentless, typeOfOid } from "../types/oid";
import * as assetApi from "../wire/assetApi";
import type { Command, CommandValue } from "../wire/assetApi";
import { aliased, guard, stampsOf, touchedBy, unguardable, UnguardableError } from "./guard";
import { aliased, guard, GuardedPassError, passesOf, sendPasses, stampsOf, touchedBy, unguardable, UnguardableError } from "./guard";
import type { Schema } from "./schema";

/** OIDs per lookup query: bounded, never one giant filter. */
Expand Down Expand Up @@ -275,18 +275,30 @@ export async function runBatch(transport: Transport, schema: Schema, commands: C
if (options.dryRun) return receipt;
if (options.confirm && !(await options.confirm(receipt))) return receipt;

let toSend = commands, unstamped: Oid[] = [];
if (useGuard) {
const stamps = Object.fromEntries(Object.entries(receipt.before).flatMap(([oid, v]) => (typeof v.ChangeDate === "string" ? [[oid, v.ChangeDate]] : [])));
({ commands: toSend, unstamped } = guard(projected, stamps));
}
let unstamped: Oid[] = [];
receipt.guarded = useGuard;
receipt.sentCommands = toSend;
receipt.sentAt = now();
let done: assetApi.Outcome;
try {
done = assetApi.outcomeOf(await assetApi.send(transport, toSend, { idempotent: useGuard && !toSend.some((c) => "AssetType" in bodyOf(c)) }));
if (useGuard) {
// Guarded pass by pass: a second command on the same asset (Reactivate
// then Delete in a reverse) goes only if the first applied (guard.ts).
const stamps = Object.fromEntries(Object.entries(receipt.before).flatMap(([oid, v]) => (typeof v.ChangeDate === "string" ? [[oid, v.ChangeDate]] : [])));
receipt.sentCommands = guard(passesOf(projected)[0]!, stamps).commands; // what the first pass sends; replaced below by everything sent
const result = await sendPasses(transport, projected, stamps);
({ outcome: done, unstamped } = result);
receipt.sentCommands = result.sent;
} else {
receipt.sentCommands = commands;
done = assetApi.outcomeOf(await assetApi.send(transport, commands));
}
} catch (error) {
if (error instanceof GuardedPassError) {
// Earlier passes applied: the receipt says so, and what failed after.
receipt.sent = true;
receipt.outcome = error.outcome;
receipt.sentCommands = error.sent;
}
throw new BatchSendError(error, receipt);
}
receipt.sent = true;
Expand Down Expand Up @@ -315,15 +327,43 @@ export async function runBatch(transport: Transport, schema: Schema, commands: C
return receipt;
}

/** Each asset's AssetState by name ("Active", "Closed"), one bounded query per type and chunk. An asset that no longer exists is absent. */
async function statesOf(transport: Transport, oids: Oid[]): Promise<Record<Oid, string>> {
const out: Record<Oid, string> = {};
for (const [type, list] of byType(oids)) {
for (const part of chunks(list)) {
const rows = await assetApi.query<Asset & { AssetState?: unknown }>(transport, { from: type, select: ["AssetState"], filter: ["ID=$oids"], with: { $oids: part }, page: { size: part.length, start: 0 } });
for (const r of rows) if (r.AssetState != null) out[momentless(r._oid)] = String(r.AssetState);
}
}
return out;
}

/** AssetState as the endpoint spells a closed asset: by name, or by Core's code (128). */
const isClosed = (state: string | undefined): boolean => state === "Closed" || state === "128";

/**
* A receipt's reverse batch, minus the assets someone changed since the
* batch (their ChangeDate is no longer the one recorded just after it),
* unless `force`. Send what it returns with runBatch like any batch.
*
* Agility refuses to Delete a closed asset (Violation'AssetCannotDeleteClosed),
* so a Delete of an asset that is closed now gets a Reactivate just before
* it: two commands on one asset, which runBatch sends in two guarded passes
* (the Delete only if the Reactivate applied). `reactivated` lists them.
*/
export async function reversible(transport: Transport, receipt: Pick<Receipt, "reverse" | "after">, options: { force?: boolean } = {}): Promise<{ commands: Command[]; drifted: { oid: Oid; was: string; now: string | null }[] }> {
export async function reversible(transport: Transport, receipt: Pick<Receipt, "reverse" | "after">, options: { force?: boolean } = {}): Promise<{ commands: Command[]; drifted: { oid: Oid; was: string; now: string | null }[]; reactivated: Oid[] }> {
const targets = receipt.reverse.commands.map((c) => momentless(String(bodyOf(c).from)));
const now = await stampsOf(transport, targets.filter((oid) => receipt.after[oid] !== undefined));
const drifted = targets.flatMap((oid) => (receipt.after[oid] !== undefined && now[oid] !== receipt.after[oid] ? [{ oid, was: receipt.after[oid]!, now: now[oid] ?? null }] : []));
const skip = new Set(options.force ? [] : drifted.map((d) => d.oid));
return { commands: receipt.reverse.commands.filter((c) => !skip.has(momentless(String(bodyOf(c).from)))), drifted };
const kept = receipt.reverse.commands.filter((c) => !skip.has(momentless(String(bodyOf(c).from))));
const deletes = kept.flatMap((c) => (bodyOf(c).execute === "Delete" ? [momentless(String(bodyOf(c).from))] : []));
const states = deletes.length ? await statesOf(transport, deletes) : {};
const reactivated = deletes.filter((oid) => isClosed(states[oid]));
const commands = kept.flatMap((c) => {
const body = bodyOf(c), oid = momentless(String(body.from));
return body.execute === "Delete" && reactivated.includes(oid) ? [{ from: oid, execute: "Reactivate" } as Command, c] : [c];
});
return { commands, drifted, reactivated };
}
Loading
Loading