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: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ 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`
- `attachments.list` / `info` / `get` / `add` / `put` / `remove` / `url` - files attached to an asset: metadata on the Attachment asset, bytes through attachment.img; `add` sends a small file inline (Base64, one request) and a large one in two steps (create, then upload), and a failed upload throws `AttachmentUploadError` naming the empty attachment it left
- `embeddedImages.upload` / `url` / `normalize` - images in rich text: upload returns the `<img src>` URL; `normalize` rewrites `embedded.img` references to this instance's URL. They cannot be deleted
- `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
Expand Down
14 changes: 12 additions & 2 deletions plans/001-sdk-adoption-campaign.md
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,16 @@ moves.
Integration tests on v1sdktesting with `sdk-integration` fixtures,
deleted by OID.

Verified live (2026-11-07): an Attachment create **requires** `Content`
(empty is allowed; absent is `Violation'Required'…'Content'`); Base64
`Content` in the create stores the bytes in one request; `attachment.img`
GET answers the bytes with their type and `Content-Disposition`, 404 when
missing, and POST replaces them. `EmbeddedImage.mvc/Upload` accepts token
auth and answers `<textarea>{"Url":…,"Key":"EmbeddedImage:<id>"}</textarea>`.
**An EmbeddedImage cannot be deleted** (`Unknown Operation
'EmbeddedImage.Delete'`), so its live test is opt-in
(`SDK_TEST_EMBEDDED_IMAGES=1`) and leaves one image per run.

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> }`
Expand Down Expand Up @@ -462,7 +472,7 @@ ESM" here means finishing the edges:
- [x] A3 loc2 (`loc2.localize`, `client.localize`; GET, POST past 2000 encoded chars, culture via Accept-Language)
- [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`)
- [ ] A7 attachments and embedded images (`attachment` wire module, `client.attachments`; survey rooms first)
- [x] A7 attachments and embedded images — `attachment` wire module (`attachment.img` bytes, `EmbeddedImage.mvc/Upload`, URL helpers, browser-safe Base64), `client.attachments` (list/info/get/add/put/remove/url; inline up to `INLINE_LIMIT`, two-step above) and `client.embeddedImages` (upload/url/normalize)
- [ ] 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)
- [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`)
Expand All @@ -474,5 +484,5 @@ ESM" here means finishing the edges:
- [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
- [x] C3 gh-v1 resolve and batch on `client.resolve`/`client.batch` — versionone/gh-v1#37 (with the SDK fixes from #43)
- [ ] C4 gh-v1 attachments on `client.attachments` (after A7); `raw` stays CLI-local
- [ ] C4 gh-v1 attachments on `client.attachments` (unblocked: A7 done); `raw` stays CLI-local
- [ ] D1–D4 agility-rooms
29 changes: 29 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import type { ClientConfig } from "./core/clientConfig";
import { createFetchTransport } from "./core/fetchTransport";
import type { Transport } from "./core/transport";
import * as attachments from "./services/attachments";
import * as batch from "./services/batch";
import * as guard from "./services/guard";
import * as resolve from "./services/resolve";
import { Schema, type TypeCache } from "./services/schema";
import type { Asset } from "./types/asset";
import type { Metamodel } from "./types/metamodel";
import * as assetApi from "./wire/assetApi";
import * as attachment from "./wire/attachment";
import * as loc2 from "./wire/loc2";
import * as meta from "./wire/meta";
import * as queryV1 from "./wire/queryV1";
Expand Down Expand Up @@ -114,6 +116,33 @@ export class AgilityApiClient {
reversible: (receipt: Pick<batch.Receipt, "reverse" | "after">, options?: { force?: boolean }) => batch.reversible(this.transport, receipt, options)
};

/**
* The files attached to assets: metadata on Attachment assets, bytes
* through attachment.img. `add` sends a small file inline (one request)
* and a large one in two steps; `url` is where a browser opens one.
*/
readonly attachments = {
list: (asset: string, options?: { page?: { size: number; start: number } }) => attachments.list(this.transport, asset, options),
info: (id: string | number) => attachments.info(this.transport, id),
get: (id: string | number) => attachments.get(this.transport, id),
add: (asset: string, data: attachment.Bytes, options: attachments.AddOptions) => attachments.add(this.transport, asset, data, options),
put: (id: string | number, data: attachment.Bytes, contentType?: string) => attachments.put(this.transport, id, data, contentType),
remove: (id: string | number) => attachments.remove(this.transport, id),
url: (id: string | number) => attachment.attachmentUrl(this.config.baseUrl, id)
};

/**
* Images embedded in an asset's rich text (Description and other long
* text). `upload` returns the URL to put in an <img src>; `normalize`
* rewrites every embedded.img reference in some HTML to this instance's
* absolute URL, as Agility's own editor stores it. They cannot be deleted.
*/
readonly embeddedImages = {
upload: (asset: string, data: attachment.Bytes, options?: { filename?: string; contentType?: string }) => attachment.uploadEmbeddedImage(this.transport, asset, data, options),
url: (id: string | number) => attachment.embeddedImageUrl(this.config.baseUrl, id),
normalize: (html: string) => attachment.normalizeEmbeddedImageUrls(html, this.config.baseUrl)
};

/** rest-1.v1: bounded reads, one asset, history. */
readonly data = {
list: <Row extends Asset = Asset>(type: string, params: rest1.ReadParams) => rest1.list<Row>(this.transport, type, params),
Expand Down
2 changes: 2 additions & 0 deletions src/core/fetchTransport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,8 @@ function encodeBody(body: RequestBody | undefined, contentType: string | undefin
}
// A form is handed to fetch as is: it writes the multipart boundary itself.
if (typeof FormData !== "undefined" && body instanceof FormData) return { body };
// A Blob (a browser File, say) is bytes with a type of its own.
if (typeof Blob !== "undefined" && body instanceof Blob) return { body, contentType: contentType ?? (body.type || "application/octet-stream") };
return { body: JSON.stringify(body), contentType: contentType ?? "application/json" };
}

Expand Down
4 changes: 2 additions & 2 deletions src/core/transport.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";

/** A JSON value, a text body (YAML for api/asset, say), bytes (attachments), or a multipart form. */
export type RequestBody = unknown | string | Uint8Array | ArrayBuffer | FormData;
/** A JSON value, a text body (YAML for api/asset, say), bytes or a Blob (attachments), or a multipart form. */
export type RequestBody = unknown | string | Uint8Array | ArrayBuffer | Blob | FormData;

/**
* How to read the response: "auto" parses JSON when the server says JSON and
Expand Down
5 changes: 4 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ 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, queryV1 } from "./wire";
export { assetApi, rest1, meta, loc2, queryV1, attachment } from "./wire";
export type { Bytes, EmbeddedImage } from "./wire/attachment";
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 All @@ -24,6 +25,8 @@ export type { Localizations, LocalizeOptions } from "./wire/loc2";

// Services: what to do.
export { Schema, TypeCache } from "./services/schema";
export { AttachmentUploadError, INLINE_LIMIT } from "./services/attachments";
export type { AddOptions as AttachmentAddOptions, AttachmentInfo } from "./services/attachments";
export { CHUNK as RESOLVE_CHUNK, NUMBER_ROOTS, isAssetNumber, resolveNumbers, resolveRef } from "./services/resolve";
export type { Resolution, ResolvedAsset } from "./services/resolve";
export { UNGUARDABLE_TYPES, UnguardableError, guard, sendGuarded, stampsOf, touchedBy, unguardable } from "./services/guard";
Expand Down
112 changes: 112 additions & 0 deletions src/services/attachments.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
/**
* The attachments service: the files attached to an asset, as part of
* changing that asset like any other attribute. The metadata lives on an
* Attachment asset (api/asset); the bytes live behind attachment.img
* (wire/attachment). Consumers never call either directly.
*
* Two ways in for the bytes, chosen by size:
* - inline: one api/asset create with Content as Base64. One request,
* what agility-rooms does; the JSON is a third larger than the file.
* - two-step, as the docs prescribe for anything not tiny: create the
* Attachment with empty Content, then POST the raw bytes. A failed
* upload leaves an empty Attachment, which is reported, not hidden.
*
* Listing never selects Content: across many attachments that would Base64
* every file into one response. Reading one file's bytes goes through
* attachment.img.
*/
import { ApiError } from "../core/errors";
import type { Transport } from "../core/transport";
import type { Oid } from "../types/asset";
import { momentless } from "../types/oid";
import * as assetApi from "../wire/assetApi";
import { getBytes, numericId, putBytes, toBase64, toUint8Array, type Bytes } from "../wire/attachment";

/** Files up to this size go inline by default; larger ones in two steps. */
export const INLINE_LIMIT = 1_000_000;

export interface AttachmentInfo {
oid: Oid;
/** The numeric id attachment.img addresses it by. */
id: string;
name: string | null;
filename: string | null;
contentType: string | null;
description: string | null;
createDate: string | null;
createdBy: string | null;
}

type Row = { _oid: string; Name?: string | null; Filename?: string | null; ContentType?: string | null; Description?: string | null; CreateDate?: string | null; "CreatedBy.Name"?: string | null };
const SELECT = ["Name", "Filename", "ContentType", "Description", "CreateDate", "CreatedBy.Name"];
const infoOf = (r: Row): AttachmentInfo => {
const oid = momentless(r._oid);
return { oid, id: numericId(oid), name: r.Name ?? null, filename: r.Filename ?? null, contentType: r.ContentType ?? null, description: r.Description ?? null, createDate: r.CreateDate ?? null, createdBy: r["CreatedBy.Name"] ?? null };
};

/** The files attached to an asset, newest first, metadata only. */
export async function list(transport: Transport, asset: Oid, options: { page?: { size: number; start: number } } = {}): Promise<AttachmentInfo[]> {
const rows = await assetApi.query(transport, { from: "Attachment", select: SELECT, filter: ["Asset=$asset"], with: { $asset: momentless(asset) }, sort: ["-CreateDate"], page: options.page ?? { size: 50, start: 0 } });
return rows.map((r) => infoOf(r as unknown as Row));
}

/** One attachment's metadata, or null when there is none with that id. */
export async function info(transport: Transport, id: Oid | string | number): Promise<AttachmentInfo | null> {
const [row] = await assetApi.query(transport, { from: "Attachment", select: SELECT, filter: ["ID=$id"], with: { $id: `Attachment:${numericId(id)}` }, page: { size: 1, start: 0 } });
return row ? infoOf(row as unknown as Row) : null;
}

/** One attachment: its metadata and its bytes. */
export async function get(transport: Transport, id: Oid | string | number): Promise<AttachmentInfo & { bytes: Uint8Array }> {
const meta = await info(transport, id);
if (!meta) throw new ApiError(`attachment ${numericId(id)}: not found`, 404, "NOT_FOUND");
return { ...meta, bytes: await getBytes(transport, meta.id) };
}

export interface AddOptions {
filename: string;
/** Defaults to the Blob's type, then application/octet-stream. */
contentType?: string;
/** Defaults to the filename. */
name?: string;
description?: string;
/** Inline (one request) up to this many bytes, two-step above. Default INLINE_LIMIT. */
inlineLimit?: number;
}

/** An Attachment was created but its bytes could not be uploaded: it exists, empty. Remove it or upload again with `put`. */
export class AttachmentUploadError extends Error {
constructor(readonly attachment: AttachmentInfo, cause: unknown) {
super(`${attachment.oid} was created on its asset with no content, and uploading the file failed: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
this.name = "AttachmentUploadError";
}
}

/** Attach a file to an asset. Inline up to `inlineLimit` bytes, two-step above it. */
export async function add(transport: Transport, asset: Oid, data: Bytes, options: AddOptions): Promise<AttachmentInfo> {
const bytes = await toUint8Array(data);
const contentType = options.contentType ?? (typeof Blob !== "undefined" && data instanceof Blob && data.type ? data.type : "application/octet-stream");
const inline = bytes.length <= (options.inlineLimit ?? INLINE_LIMIT);
const create: assetApi.CreateBody = { AssetType: "Attachment", Asset: momentless(asset), Name: options.name ?? options.filename, Filename: options.filename, ContentType: contentType, Content: inline ? toBase64(bytes) : "" };
if (options.description) create.Description = options.description;
const oid = assetApi.outcomeOf(await assetApi.send(transport, [create], { throwOnFailure: true })).created[0]!;
const created: AttachmentInfo = { oid, id: numericId(oid), name: String(create.Name), filename: options.filename, contentType, description: options.description ?? null, createDate: null, createdBy: null };
if (!inline) {
try {
await putBytes(transport, created.id, bytes, contentType);
} catch (error) {
throw new AttachmentUploadError(created, error);
}
}
return created;
}

/** Replace an attachment's bytes. */
export async function put(transport: Transport, id: Oid | string | number, data: Bytes, contentType?: string): Promise<void> {
await putBytes(transport, id, data, contentType);
}

/** Delete an attachment. */
export async function remove(transport: Transport, id: Oid | string | number): Promise<void> {
await assetApi.send(transport, [{ from: `Attachment:${numericId(id)}`, execute: "Delete" }], { throwOnFailure: true });
}
Loading
Loading