Skip to content

Commit d51fb16

Browse files
authored
Merge pull request #46 from versionone/claude/admiring-ride-vsq4sa
A7: attachments and embedded images
2 parents 94e9431 + 06a15e2 commit d51fb16

11 files changed

Lines changed: 462 additions & 5 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ 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+
- `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
29+
- `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
2830
- `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
2931
- `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
3032
- `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

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

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,16 @@ moves.
251251
Integration tests on v1sdktesting with `sdk-integration` fixtures,
252252
deleted by OID.
253253

254+
Verified live (2026-11-07): an Attachment create **requires** `Content`
255+
(empty is allowed; absent is `Violation'Required'…'Content'`); Base64
256+
`Content` in the create stores the bytes in one request; `attachment.img`
257+
GET answers the bytes with their type and `Content-Disposition`, 404 when
258+
missing, and POST replaces them. `EmbeddedImage.mvc/Upload` accepts token
259+
auth and answers `<textarea>{"Url":…,"Key":"EmbeddedImage:<id>"}</textarea>`.
260+
**An EmbeddedImage cannot be deleted** (`Unknown Operation
261+
'EmbeddedImage.Delete'`), so its live test is opt-in
262+
(`SDK_TEST_EMBEDDED_IMAGES=1`) and leaves one image per run.
263+
254264
What rooms does today (survey 2026-11-07), which A7 must cover:
255265
- **Upload** is one api/asset create with the bytes inline: `{ AssetType:
256266
"Attachment", Asset, ContentType, Filename, Name, Content: <Base64> }`
@@ -462,7 +472,7 @@ ESM" here means finishing the edges:
462472
- [x] A3 loc2 (`loc2.localize`, `client.localize`; GET, POST past 2000 encoded chars, culture via Accept-Language)
463473
- [x] A4 slack-shaped reads — `queryV1` wire module, `client.queryV1.query`/`queries` (read-only, idempotent, same row translation as api/asset)
464474
- [x] A5 services port from gh-v1 — `resolve` (`client.resolve.numbers`/`.ref`), `guard` (`client.assets.sendGuarded`), `batch` (`client.batch.run`/`.reversible`)
465-
- [ ] A7 attachments and embedded images (`attachment` wire module, `client.attachments`; survey rooms first)
475+
- [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)
466476
- [ ] 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)
467477
- [x] G0–G3 gh-v1 migrations — versionone/gh-v1#29–#32
468478
- [x] S1–S4 slack-bot migrations — digital-ai/k6i-agility-slack-bot#21–#24 (on `bolt-migration`)
@@ -474,5 +484,5 @@ ESM" here means finishing the edges:
474484
- [x] C1 gh-v1 seam swap — versionone/gh-v1#33, with the vendored SDK's hashes, CI and Node 22 floor in #35
475485
- [x] C2 gh-v1 rows translated through the schema; `isTrue`/`oidOf` gone — versionone/gh-v1#36
476486
- [x] C3 gh-v1 resolve and batch on `client.resolve`/`client.batch` — versionone/gh-v1#37 (with the SDK fixes from #43)
477-
- [ ] C4 gh-v1 attachments on `client.attachments` (after A7); `raw` stays CLI-local
487+
- [ ] C4 gh-v1 attachments on `client.attachments` (unblocked: A7 done); `raw` stays CLI-local
478488
- [ ] D1–D4 agility-rooms

‎src/client.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import type { ClientConfig } from "./core/clientConfig";
22
import { createFetchTransport } from "./core/fetchTransport";
33
import type { Transport } from "./core/transport";
4+
import * as attachments from "./services/attachments";
45
import * as batch from "./services/batch";
56
import * as guard from "./services/guard";
67
import * as resolve from "./services/resolve";
78
import { Schema, type TypeCache } from "./services/schema";
89
import type { Asset } from "./types/asset";
910
import type { Metamodel } from "./types/metamodel";
1011
import * as assetApi from "./wire/assetApi";
12+
import * as attachment from "./wire/attachment";
1113
import * as loc2 from "./wire/loc2";
1214
import * as meta from "./wire/meta";
1315
import * as queryV1 from "./wire/queryV1";
@@ -114,6 +116,33 @@ export class AgilityApiClient {
114116
reversible: (receipt: Pick<batch.Receipt, "reverse" | "after">, options?: { force?: boolean }) => batch.reversible(this.transport, receipt, options)
115117
};
116118

119+
/**
120+
* The files attached to assets: metadata on Attachment assets, bytes
121+
* through attachment.img. `add` sends a small file inline (one request)
122+
* and a large one in two steps; `url` is where a browser opens one.
123+
*/
124+
readonly attachments = {
125+
list: (asset: string, options?: { page?: { size: number; start: number } }) => attachments.list(this.transport, asset, options),
126+
info: (id: string | number) => attachments.info(this.transport, id),
127+
get: (id: string | number) => attachments.get(this.transport, id),
128+
add: (asset: string, data: attachment.Bytes, options: attachments.AddOptions) => attachments.add(this.transport, asset, data, options),
129+
put: (id: string | number, data: attachment.Bytes, contentType?: string) => attachments.put(this.transport, id, data, contentType),
130+
remove: (id: string | number) => attachments.remove(this.transport, id),
131+
url: (id: string | number) => attachment.attachmentUrl(this.config.baseUrl, id)
132+
};
133+
134+
/**
135+
* Images embedded in an asset's rich text (Description and other long
136+
* text). `upload` returns the URL to put in an <img src>; `normalize`
137+
* rewrites every embedded.img reference in some HTML to this instance's
138+
* absolute URL, as Agility's own editor stores it. They cannot be deleted.
139+
*/
140+
readonly embeddedImages = {
141+
upload: (asset: string, data: attachment.Bytes, options?: { filename?: string; contentType?: string }) => attachment.uploadEmbeddedImage(this.transport, asset, data, options),
142+
url: (id: string | number) => attachment.embeddedImageUrl(this.config.baseUrl, id),
143+
normalize: (html: string) => attachment.normalizeEmbeddedImageUrls(html, this.config.baseUrl)
144+
};
145+
117146
/** rest-1.v1: bounded reads, one asset, history. */
118147
readonly data = {
119148
list: <Row extends Asset = Asset>(type: string, params: rest1.ReadParams) => rest1.list<Row>(this.transport, type, params),

‎src/core/fetchTransport.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,8 @@ function encodeBody(body: RequestBody | undefined, contentType: string | undefin
8989
}
9090
// A form is handed to fetch as is: it writes the multipart boundary itself.
9191
if (typeof FormData !== "undefined" && body instanceof FormData) return { body };
92+
// A Blob (a browser File, say) is bytes with a type of its own.
93+
if (typeof Blob !== "undefined" && body instanceof Blob) return { body, contentType: contentType ?? (body.type || "application/octet-stream") };
9294
return { body: JSON.stringify(body), contentType: contentType ?? "application/json" };
9395
}
9496

‎src/core/transport.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
22

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

66
/**
77
* How to read the response: "auto" parses JSON when the server says JSON and

‎src/index.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,8 @@ export type { AssetType, AttributeDefinition, AttributeLookup, AttributeType, At
1515
export type * from "./types/assets/index";
1616

1717
// The wire layer: one module per endpoint.
18-
export { assetApi, rest1, meta, loc2, queryV1 } from "./wire";
18+
export { assetApi, rest1, meta, loc2, queryV1, attachment } from "./wire";
19+
export type { Bytes, EmbeddedImage } from "./wire/attachment";
1920
export * as select from "./wire/select";
2021
export type { AssetApiResponse, Command, CommandBody, CommandFailureDetail, Outcome, CommandValue, CreateBody, ExecuteBody, QueryBody, UpdateBody, ReadOptions, RowContext, SendOptions } from "./wire/assetApi";
2122
export { CommandFailureError } from "./wire/assetApi";
@@ -24,6 +25,8 @@ export type { Localizations, LocalizeOptions } from "./wire/loc2";
2425

2526
// Services: what to do.
2627
export { Schema, TypeCache } from "./services/schema";
28+
export { AttachmentUploadError, INLINE_LIMIT } from "./services/attachments";
29+
export type { AddOptions as AttachmentAddOptions, AttachmentInfo } from "./services/attachments";
2730
export { CHUNK as RESOLVE_CHUNK, NUMBER_ROOTS, isAssetNumber, resolveNumbers, resolveRef } from "./services/resolve";
2831
export type { Resolution, ResolvedAsset } from "./services/resolve";
2932
export { UNGUARDABLE_TYPES, UnguardableError, guard, sendGuarded, stampsOf, touchedBy, unguardable } from "./services/guard";

‎src/services/attachments.ts‎

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
/**
2+
* The attachments service: the files attached to an asset, as part of
3+
* changing that asset like any other attribute. The metadata lives on an
4+
* Attachment asset (api/asset); the bytes live behind attachment.img
5+
* (wire/attachment). Consumers never call either directly.
6+
*
7+
* Two ways in for the bytes, chosen by size:
8+
* - inline: one api/asset create with Content as Base64. One request,
9+
* what agility-rooms does; the JSON is a third larger than the file.
10+
* - two-step, as the docs prescribe for anything not tiny: create the
11+
* Attachment with empty Content, then POST the raw bytes. A failed
12+
* upload leaves an empty Attachment, which is reported, not hidden.
13+
*
14+
* Listing never selects Content: across many attachments that would Base64
15+
* every file into one response. Reading one file's bytes goes through
16+
* attachment.img.
17+
*/
18+
import { ApiError } from "../core/errors";
19+
import type { Transport } from "../core/transport";
20+
import type { Oid } from "../types/asset";
21+
import { momentless } from "../types/oid";
22+
import * as assetApi from "../wire/assetApi";
23+
import { getBytes, numericId, putBytes, toBase64, toUint8Array, type Bytes } from "../wire/attachment";
24+
25+
/** Files up to this size go inline by default; larger ones in two steps. */
26+
export const INLINE_LIMIT = 1_000_000;
27+
28+
export interface AttachmentInfo {
29+
oid: Oid;
30+
/** The numeric id attachment.img addresses it by. */
31+
id: string;
32+
name: string | null;
33+
filename: string | null;
34+
contentType: string | null;
35+
description: string | null;
36+
createDate: string | null;
37+
createdBy: string | null;
38+
}
39+
40+
type Row = { _oid: string; Name?: string | null; Filename?: string | null; ContentType?: string | null; Description?: string | null; CreateDate?: string | null; "CreatedBy.Name"?: string | null };
41+
const SELECT = ["Name", "Filename", "ContentType", "Description", "CreateDate", "CreatedBy.Name"];
42+
const infoOf = (r: Row): AttachmentInfo => {
43+
const oid = momentless(r._oid);
44+
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 };
45+
};
46+
47+
/** The files attached to an asset, newest first, metadata only. */
48+
export async function list(transport: Transport, asset: Oid, options: { page?: { size: number; start: number } } = {}): Promise<AttachmentInfo[]> {
49+
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 } });
50+
return rows.map((r) => infoOf(r as unknown as Row));
51+
}
52+
53+
/** One attachment's metadata, or null when there is none with that id. */
54+
export async function info(transport: Transport, id: Oid | string | number): Promise<AttachmentInfo | null> {
55+
const [row] = await assetApi.query(transport, { from: "Attachment", select: SELECT, filter: ["ID=$id"], with: { $id: `Attachment:${numericId(id)}` }, page: { size: 1, start: 0 } });
56+
return row ? infoOf(row as unknown as Row) : null;
57+
}
58+
59+
/** One attachment: its metadata and its bytes. */
60+
export async function get(transport: Transport, id: Oid | string | number): Promise<AttachmentInfo & { bytes: Uint8Array }> {
61+
const meta = await info(transport, id);
62+
if (!meta) throw new ApiError(`attachment ${numericId(id)}: not found`, 404, "NOT_FOUND");
63+
return { ...meta, bytes: await getBytes(transport, meta.id) };
64+
}
65+
66+
export interface AddOptions {
67+
filename: string;
68+
/** Defaults to the Blob's type, then application/octet-stream. */
69+
contentType?: string;
70+
/** Defaults to the filename. */
71+
name?: string;
72+
description?: string;
73+
/** Inline (one request) up to this many bytes, two-step above. Default INLINE_LIMIT. */
74+
inlineLimit?: number;
75+
}
76+
77+
/** An Attachment was created but its bytes could not be uploaded: it exists, empty. Remove it or upload again with `put`. */
78+
export class AttachmentUploadError extends Error {
79+
constructor(readonly attachment: AttachmentInfo, cause: unknown) {
80+
super(`${attachment.oid} was created on its asset with no content, and uploading the file failed: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
81+
this.name = "AttachmentUploadError";
82+
}
83+
}
84+
85+
/** Attach a file to an asset. Inline up to `inlineLimit` bytes, two-step above it. */
86+
export async function add(transport: Transport, asset: Oid, data: Bytes, options: AddOptions): Promise<AttachmentInfo> {
87+
const bytes = await toUint8Array(data);
88+
const contentType = options.contentType ?? (typeof Blob !== "undefined" && data instanceof Blob && data.type ? data.type : "application/octet-stream");
89+
const inline = bytes.length <= (options.inlineLimit ?? INLINE_LIMIT);
90+
const create: assetApi.CreateBody = { AssetType: "Attachment", Asset: momentless(asset), Name: options.name ?? options.filename, Filename: options.filename, ContentType: contentType, Content: inline ? toBase64(bytes) : "" };
91+
if (options.description) create.Description = options.description;
92+
const oid = assetApi.outcomeOf(await assetApi.send(transport, [create], { throwOnFailure: true })).created[0]!;
93+
const created: AttachmentInfo = { oid, id: numericId(oid), name: String(create.Name), filename: options.filename, contentType, description: options.description ?? null, createDate: null, createdBy: null };
94+
if (!inline) {
95+
try {
96+
await putBytes(transport, created.id, bytes, contentType);
97+
} catch (error) {
98+
throw new AttachmentUploadError(created, error);
99+
}
100+
}
101+
return created;
102+
}
103+
104+
/** Replace an attachment's bytes. */
105+
export async function put(transport: Transport, id: Oid | string | number, data: Bytes, contentType?: string): Promise<void> {
106+
await putBytes(transport, id, data, contentType);
107+
}
108+
109+
/** Delete an attachment. */
110+
export async function remove(transport: Transport, id: Oid | string | number): Promise<void> {
111+
await assetApi.send(transport, [{ from: `Attachment:${numericId(id)}`, execute: "Delete" }], { throwOnFailure: true });
112+
}

0 commit comments

Comments
 (0)