diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 5696b6d..d35f62c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -10,10 +10,17 @@ permissions:
jobs:
verify:
- runs-on: ubuntu-latest
+ runs-on: ${{ matrix.os }}
strategy:
+ fail-fast: false
matrix:
+ os: [ubuntu-latest]
node: [20, 22, 24]
+ # Windows too: tools that read paths as globs (tsup's entries) break
+ # on backslashes, and people develop the SDK there.
+ include:
+ - os: windows-latest
+ node: 24
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
@@ -24,10 +31,10 @@ jobs:
# Unit tier only: the integration tier needs a testing instance and writes fixtures.
- run: npm run verify
- name: Pack
- if: matrix.node == 24
+ if: matrix.node == 24 && matrix.os == 'ubuntu-latest'
run: npm pack
- uses: actions/upload-artifact@v4
- if: matrix.node == 24
+ if: matrix.node == 24 && matrix.os == 'ubuntu-latest'
with:
- name: versionone-sdk-tarball
- path: versionone-sdk-*.tgz
+ name: versionone-agility-sdk-tarball
+ path: versionone-agility-sdk-*.tgz
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
new file mode 100644
index 0000000..0cb2d16
--- /dev/null
+++ b/.github/workflows/publish.yml
@@ -0,0 +1,60 @@
+name: Publish
+
+# Publishes @versionone/agility-sdk to npmjs when a version tag is pushed:
+# git tag v1.0.0-alpha.0 && git push origin v1.0.0-alpha.0
+#
+# A prerelease (1.0.0-alpha.0, -beta.1, -rc.2) goes out on its own dist-tag
+# (alpha, beta, rc), so `npm install @versionone/agility-sdk` never picks it
+# up; only a plain version moves `latest`.
+#
+# Authentication is npm trusted publishing (OIDC): link this repository and
+# this workflow file on the package's npmjs settings page, and no token is
+# stored anywhere. The package must exist before it can be linked, so the
+# very first version may need one manual publish (see the README's
+# "Releasing" section); an NPM_TOKEN secret, if set, is used as a fallback.
+
+on:
+ push:
+ tags: ["v*"]
+
+permissions:
+ contents: read
+ id-token: write # OIDC for trusted publishing and provenance
+
+concurrency:
+ group: publish
+ cancel-in-progress: false
+
+jobs:
+ publish:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 24
+ registry-url: https://registry.npmjs.org
+ # Trusted publishing needs npm 11.5.1 or later.
+ - run: npm install -g npm@^11.5.1
+ - run: npm ci
+ - name: The tag names the version in package.json
+ run: |
+ version="$(node -p "require('./package.json').version")"
+ if [ "v${version}" != "${GITHUB_REF_NAME}" ]; then
+ echo "Tag ${GITHUB_REF_NAME} does not match package.json version ${version}" >&2
+ exit 1
+ fi
+ echo "VERSION=${version}" >> "$GITHUB_ENV"
+ - run: npm run verify
+ - name: Choose the dist-tag
+ run: |
+ case "${VERSION}" in
+ *-*) tag="$(echo "${VERSION#*-}" | sed 's/[.0-9]*$//')" ;;
+ *) tag=latest ;;
+ esac
+ if [ -z "${tag}" ]; then tag=next; fi
+ echo "DIST_TAG=${tag}" >> "$GITHUB_ENV"
+ echo "Publishing ${VERSION} with dist-tag ${tag}"
+ - run: npm publish --provenance --access public --tag "${DIST_TAG}"
+ env:
+ NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..c912d5e
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2012-2026 Digital.ai Software, Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index 541480c..3093997 100644
--- a/README.md
+++ b/README.md
@@ -1,8 +1,25 @@
-# VersionOne SDK (TypeScript starter)
+# @versionone/agility-sdk
-Minimal TypeScript SDK starter for building JavaScript bindings to external APIs.
+TypeScript SDK for the Digital.ai Agility (formerly VersionOne) API. It works in Node (ESM or CommonJS) and the browser, ships its own types, and has no runtime dependencies.
-## Quick start
+Not to be confused with `v1sdk`, the 2.x JavaScript SDK: this is a new API, not a compatible successor.
+
+## Install
+
+Alpha releases are for feedback; the API may change between them.
+
+```bash
+npm install @versionone/agility-sdk@alpha
+```
+
+```ts
+import { AgilityApiClient } from "@versionone/agility-sdk";
+
+const client = new AgilityApiClient({ baseUrl: "https://www14.v1host.com/v1sdktesting", accessToken: process.env.V1_ACCESS_TOKEN });
+const stories = await client.queryV1.query({ from: "Story", select: ["Name", "Estimate"], page: { size: 10, start: 0 } });
+```
+
+## Working on the SDK
```bash
npm install
@@ -25,7 +42,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`
- - `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
+ - `attachments.list` / `page` / `info` / `get` / `add` / `put` / `remove` / `url` - files attached to an asset: metadata on the Attachment asset, bytes through attachment.img; `page` adds the total and whether more pages follow; `get`'s `contentType` is the type the server sent the bytes with; `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 `
` 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
@@ -46,6 +63,24 @@ API reference: https://docs.digital.ai/agility-api/
No registry package yet. Pin a commit: `npm install github:versionone/VersionOne.SDK.JavaScript#` builds `dist` on install (the `prepare` script). CI also uploads an `npm pack` tarball on every run.
+## Releasing
+
+Versions are published to npmjs by `.github/workflows/publish.yml` when a
+tag `v` is pushed; the tag must equal `package.json`'s version. A
+prerelease (`1.0.0-alpha.0`, `-beta.N`, `-rc.N`) goes out on its own
+dist-tag (`alpha`, `beta`, `rc`), so `latest` is never a prerelease.
+
+```bash
+npm version 1.0.0-alpha.1 --no-git-tag-version # bump, commit, merge
+git tag v1.0.0-alpha.1 && git push origin v1.0.0-alpha.1
+```
+
+Authentication is npm trusted publishing: on npmjs, the package's settings
+link this repository and `publish.yml`, and no token is stored. A package
+must exist before it can be linked, so the very first version is published
+once by hand by an `@versionone` org member (`npm publish --access public
+--tag alpha` from a clean checkout of the tagged commit), then linked.
+
## Before PR
- Run `npm run verify` to validate typecheck, build, and tests before opening a pull request.
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 340f87a..f35214b 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
- "name": "@versionone/sdk",
- "version": "0.1.0",
+ "name": "@versionone/agility-sdk",
+ "version": "1.0.0-alpha.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
- "name": "@versionone/sdk",
- "version": "0.1.0",
+ "name": "@versionone/agility-sdk",
+ "version": "1.0.0-alpha.0",
"license": "MIT",
"devDependencies": {
"@types/node": "^22.13.10",
diff --git a/package.json b/package.json
index 1d55ce8..7823b5e 100644
--- a/package.json
+++ b/package.json
@@ -1,11 +1,15 @@
{
- "name": "@versionone/sdk",
- "version": "0.1.0",
- "description": "Minimal TypeScript SDK starter for digital.ai Agility APIs",
+ "name": "@versionone/agility-sdk",
+ "version": "1.0.0-alpha.0",
+ "description": "TypeScript SDK for the Digital.ai Agility (VersionOne) API: queries, batches, schema-translated rows, guarded writes, attachments",
"keywords": [
+ "agility",
+ "digital.ai",
+ "versionone",
+ "v1",
"sdk",
- "typescript",
- "api-client"
+ "api-client",
+ "typescript"
],
"license": "MIT",
"type": "module",
@@ -52,5 +56,17 @@
},
"volta": {
"node": "24.14.0"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/versionone/VersionOne.SDK.JavaScript.git"
+ },
+ "homepage": "https://github.com/versionone/VersionOne.SDK.JavaScript#readme",
+ "bugs": {
+ "url": "https://github.com/versionone/VersionOne.SDK.JavaScript/issues"
+ },
+ "publishConfig": {
+ "access": "public",
+ "provenance": true
}
}
diff --git a/plans/001-sdk-adoption-campaign.md b/plans/001-sdk-adoption-campaign.md
index 8bdf4de..a87fafc 100644
--- a/plans/001-sdk-adoption-campaign.md
+++ b/plans/001-sdk-adoption-campaign.md
@@ -1,7 +1,7 @@
# Plan 001: TypeScript SDK campaign and adoption
A parallel campaign, run from a cloud agent session on this repo, alongside
-daily work. Goal: take `@versionone/sdk` from a verified starter (layers,
+daily work. Goal: take `@versionone/agility-sdk` from a verified starter (layers,
api/asset + rest-1 + meta.v1 wire modules, schema-driven translation, 49 unit
tests green) to a package that three surfaces depend on:
@@ -83,7 +83,7 @@ Findings from the testing instance worth carrying into later milestones:
the `Attributes` envelope via `lib/transformDataToAsset.js`; `findAssets`
reads with `find`/`findin`/`page`), and `loc-2.v1`. Its `AGENTS.md` already
plans for this: the Bolt migration's Phase 2 rewrites the seam as
-`services/v1.js` on native `fetch` "so it can migrate into `@versionone/sdk`".
+`services/v1.js` on native `fetch` "so it can migrate into `@versionone/agility-sdk`".
It hand-decodes string scalars in 3 places (`"Team.HasProcess" === "True"`,
`IsKanban.toLowerCase() === "true"`) — exactly what spec 000 §2a removes.
@@ -196,7 +196,7 @@ Found by reading the code above against `src/`:
8. **Distribution.** No CI in this repo, no publish target, version 0.1.0.
Source is browser-safe already (no `node:` imports, no `Buffer`).
9. **CommonJS consumers — done.** `test/unit/cjs.test.ts` builds the CJS
- bundle, installs it as `node_modules/@versionone/sdk`, and has a plain
+ bundle, installs it as `node_modules/@versionone/agility-sdk`, and has a plain
`.cjs` script `require` and use it. The exports map now gives each
condition its own types (`.d.cts` for require), which a CJS caller
type-checking with NodeNext (gh-v1 after G2) needs.
@@ -449,7 +449,9 @@ ESM" here means finishing the edges:
## Decisions needed
- ~~Publish target~~ — decided 2026-11-07: public npmjs, as
- `@versionone/sdk` (the `@versionone` org already publishes there), starting
+ `@versionone/agility-sdk` (renamed from `@versionone/sdk` on 2026-11-07,
+ before any publish: the scope already has `ctm-pipeline-builder-sdk`, and
+ the product is Agility; the `@versionone` org already publishes there), starting
at `1.0.0-alpha.0` on the `alpha` dist-tag so `latest` is never a
prerelease. Not `v1sdk` 3.x: the 2.x package (`v1sdk`, last published
2022) stays as it is, because a new major of the same name would imply a
@@ -473,7 +475,7 @@ ESM" here means finishing the edges:
- [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`)
- [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)
+- [ ] A6 CI + prerelease publish — CI, git-SHA installs, the dependency audit and the publish workflow (`.github/workflows/publish.yml`: on a `v*` tag, verify, dist-tag from the prerelease id, `npm publish --provenance`) done; version `1.0.0-alpha.0`. License decided 2026-11-07: MIT (`LICENSE`; `master`'s BSD 3-clause `LICENSE.md` stays with the 2.x code). Left: the first publish by hand and the trusted-publisher link on npmjs
- [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`)
- [x] B1 slack bot client swap — digital-ai/k6i-agility-slack-bot#29
@@ -484,5 +486,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` (unblocked: A7 done); `raw` stays CLI-local
+- [x] C4 gh-v1 `gh v1 attach` on `client.attachments`, `blob` removed — versionone/gh-v1#38 (re-vendored at `d51fb16`; small files now upload inline); `raw` stays CLI-local
- [ ] D1–D4 agility-rooms
diff --git a/plans/002-adopt-sdk-gh-v1.md b/plans/002-adopt-sdk-gh-v1.md
index ee299c5..f1d0b70 100644
--- a/plans/002-adopt-sdk-gh-v1.md
+++ b/plans/002-adopt-sdk-gh-v1.md
@@ -1,4 +1,4 @@
-# Plan 002: gh-v1 adopts `@versionone/sdk` (Track C)
+# Plan 002: gh-v1 adopts `@versionone/agility-sdk` (Track C)
Brief for a session on `versionone/gh-v1`. Start once the SDK PR that adds
this file has merged into `proposed_type_script_api_version`. Source plan:
@@ -6,7 +6,7 @@ this file has merged into `proposed_type_script_api_version`. Source plan:
## Goal
-gh-v1 talks to Agility only through `@versionone/sdk`. CLI behaviour and
+gh-v1 talks to Agility only through `@versionone/agility-sdk`. CLI behaviour and
output stay the same; the code that duplicates the SDK goes.
## Where things stand (checked 2026-11-06, gh-v1 `2c58c7e`)
@@ -34,7 +34,7 @@ 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
+- `package.json` lists `@versionone/agility-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.
diff --git a/plans/003-adopt-sdk-k6i-agility-slack-bot.md b/plans/003-adopt-sdk-k6i-agility-slack-bot.md
index 353b998..32a9db3 100644
--- a/plans/003-adopt-sdk-k6i-agility-slack-bot.md
+++ b/plans/003-adopt-sdk-k6i-agility-slack-bot.md
@@ -1,4 +1,4 @@
-# Plan 003: k6i-agility-slack-bot adopts `@versionone/sdk` (Track B)
+# Plan 003: k6i-agility-slack-bot adopts `@versionone/agility-sdk` (Track B)
Brief for a session on `digital-ai/k6i-agility-slack-bot`. Start once the
SDK PR that adds this file has merged into `proposed_type_script_api_version`.
@@ -7,7 +7,7 @@ Source plan: `plans/001-sdk-adoption-campaign.md`, Track B. Work on
## Goal
-`services/v1.js`'s `createV1Client` runs on `@versionone/sdk`, with the same
+`services/v1.js`'s `createV1Client` runs on `@versionone/agility-sdk`, with the same
exported surface, so listeners and features do not change. The bot's
hand-decoding and rest-1 write path go.
@@ -27,9 +27,9 @@ hand-decoding and rest-1 write path go.
## Dependency
No registry package yet. Pin a commit:
-`"@versionone/sdk": "github:versionone/VersionOne.SDK.JavaScript#"`.
+`"@versionone/agility-sdk": "github:versionone/VersionOne.SDK.JavaScript#"`.
The SDK's `prepare` script builds `dist` on install. The bot is ESM, so
-`import { AgilityApiClient } from "@versionone/sdk"`.
+`import { AgilityApiClient } from "@versionone/agility-sdk"`.
## Steps, one PR each
diff --git a/plans/handoffs/gh-v1.md b/plans/handoffs/gh-v1.md
index aad5218..0c84838 100644
--- a/plans/handoffs/gh-v1.md
+++ b/plans/handoffs/gh-v1.md
@@ -7,7 +7,7 @@ Paste everything below the line into the session working on
---
-Context: gh-v1 will become the first consumer of `@versionone/sdk` (the
+Context: gh-v1 will become the first consumer of `@versionone/agility-sdk` (the
TypeScript rewrite of the Agility JS SDK). Before that swap, gh-v1 should
adopt these migrations so the swap later touches only `lib/api.js`. Do them
as separate PRs, in order, each with `npm test` green (99 tests
diff --git a/plans/handoffs/k6i-agility-slack-bot.md b/plans/handoffs/k6i-agility-slack-bot.md
index 1dcd988..f33851d 100644
--- a/plans/handoffs/k6i-agility-slack-bot.md
+++ b/plans/handoffs/k6i-agility-slack-bot.md
@@ -8,7 +8,7 @@ in `versionone/VersionOne.SDK.JavaScript` (branch
---
Context: after the Bolt migration, `services/v1.js` (`createV1Client`) will
-be re-implemented on `@versionone/sdk`, the TypeScript rewrite of the
+be re-implemented on `@versionone/agility-sdk`, the TypeScript rewrite of the
Agility JS SDK, keeping its exported surface. These four migrations come
first, so that swap is one file. Work on (or branch from) `bolt-migration`,
not `main`. One PR each, `npm test` green, and follow AGENTS.md and
@@ -51,5 +51,5 @@ https://docs.digital.ai/agility-api/apis/asset-bulk/updating-assets
API reference for all of this: https://docs.digital.ai/agility-api/ (the
whole site as text: https://docs.digital.ai/agility-api/llms-full.txt).
-Do not add `@versionone/sdk` yet (milestone B1, run from the SDK session
+Do not add `@versionone/agility-sdk` yet (milestone B1, run from the SDK session
after S2 and S3 merge). Report back with the PR links.
diff --git a/src/client.ts b/src/client.ts
index 519d94a..c0b510a 100644
--- a/src/client.ts
+++ b/src/client.ts
@@ -123,6 +123,7 @@ export class AgilityApiClient {
*/
readonly attachments = {
list: (asset: string, options?: { page?: { size: number; start: number } }) => attachments.list(this.transport, asset, options),
+ page: (asset: string, options?: { page?: { size: number; start: number } }) => attachments.page(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),
diff --git a/src/core/fetchTransport.ts b/src/core/fetchTransport.ts
index d020ae4..95ddc13 100644
--- a/src/core/fetchTransport.ts
+++ b/src/core/fetchTransport.ts
@@ -184,7 +184,10 @@ export function createFetchTransport(config: FetchTransportConfig): Transport {
}
try {
- if (response.ok) return (await readBody(response, responseType)) as T;
+ if (response.ok) {
+ options.onResponse?.({ status: response.status, headers: response.headers });
+ return (await readBody(response, responseType)) as T;
+ }
// An answer, not a failure to get one. 5xx may be retried when
// idempotent; anything else is final.
diff --git a/src/core/transport.ts b/src/core/transport.ts
index aa68361..e982a2f 100644
--- a/src/core/transport.ts
+++ b/src/core/transport.ts
@@ -29,6 +29,12 @@ export interface RequestOptions {
* still to come) with an ApiError coded ABORTED. Never retried.
*/
signal?: AbortSignal;
+ /**
+ * Called with a successful response's status and headers before its body
+ * is read — for what the body alone does not say (an attachment's
+ * Content-Type, say). A transport that cannot see headers may skip it.
+ */
+ onResponse?: (response: { status: number; headers: { get(name: string): string | null } }) => void;
}
export interface Transport {
diff --git a/src/services/attachments.ts b/src/services/attachments.ts
index 3cd7b0e..1645d02 100644
--- a/src/services/attachments.ts
+++ b/src/services/attachments.ts
@@ -20,7 +20,7 @@ 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";
+import { getFile, 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;
@@ -44,7 +44,13 @@ const infoOf = (r: Row): AttachmentInfo => {
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. */
+/** One page of the files attached to an asset, newest first, metadata only, with the total and whether more pages follow. */
+export async function page(transport: Transport, asset: Oid, options: { page?: { size: number; start: number } } = {}): Promise<{ attachments: AttachmentInfo[]; total: number | null; more: boolean }> {
+ const { rows, total, more } = await assetApi.queryPage(transport, { from: "Attachment", select: SELECT, filter: ["Asset=$asset"], with: { $asset: momentless(asset) }, sort: ["-CreateDate"], page: options.page ?? { size: 50, start: 0 } });
+ return { attachments: rows.map((r) => infoOf(r as unknown as Row)), total, more };
+}
+
+/** The files attached to an asset, newest first, metadata only (one page; `page` also gives the total). */
export async function list(transport: Transport, asset: Oid, options: { page?: { size: number; start: number } } = {}): Promise {
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));
@@ -56,11 +62,16 @@ export async function info(transport: Transport, id: Oid | string | number): Pro
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 {
+/**
+ * One attachment: its metadata and its bytes. `contentType` is the type the
+ * server sent the bytes with, falling back to the Attachment's recorded
+ * one; `recordedContentType` is always the latter.
+ */
+export async function get(transport: Transport, id: Oid | string | number): Promise {
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) };
+ const file = await getFile(transport, meta.id);
+ return { ...meta, bytes: file.bytes, contentType: file.contentType ?? meta.contentType, recordedContentType: meta.contentType };
}
export interface AddOptions {
diff --git a/src/wire/assetApi.ts b/src/wire/assetApi.ts
index 07db990..1f834e9 100644
--- a/src/wire/assetApi.ts
+++ b/src/wire/assetApi.ts
@@ -50,6 +50,8 @@ export interface QueryBody {
find?: string;
findin?: string[];
asof?: string;
+ /** Ask api/asset for the total number of matches (query.v1 ignores it); read it with queryPage. */
+ needTotal?: boolean;
/** Group the rows by a relation: each result row is then a group, its rows under `_children` (translated like any row of `from`). */
group?: (string | { from: string; [key: string]: unknown })[];
}
@@ -408,6 +410,20 @@ export async function query(transport: Transport, q:
return rows ?? [];
}
+/**
+ * One page of a query, with the total number of matches (api/asset reports
+ * it when asked with needTotal; `total` is null when it does not) and
+ * whether more pages follow.
+ */
+export async function queryPage(transport: Transport, q: QueryBody, options: ReadOptions = {}): Promise<{ rows: Row[]; total: number | null; more: boolean }> {
+ if (!q.page) throw new Error(`query of ${q.from} without a page: every api/asset read must be bounded`);
+ const response = await send(transport, [{ ...q, needTotal: true }], { ...options, throwOnFailure: true });
+ const rows = response.queryResult.results[0] ?? [];
+ const reported = (response.queryResult as { resultMetaData?: { total?: unknown }[] }).resultMetaData?.[0]?.total;
+ const total = typeof reported === "number" ? reported : null;
+ return { rows, total, more: total === null ? rows.length === q.page.size : q.page.start + rows.length < total };
+}
+
/**
* Several queries in one request; one array of rows per query, in order, []
* where a query matched nothing. A query the server rejected (a bad select
diff --git a/src/wire/attachment.ts b/src/wire/attachment.ts
index 60de955..1c94c1c 100644
--- a/src/wire/attachment.ts
+++ b/src/wire/attachment.ts
@@ -92,6 +92,24 @@ export async function getBytes(transport: Transport, id: Oid | string | number):
}
}
+/** An attachment's bytes with the type and filename the server sent them with (Content-Type, Content-Disposition), when it did. */
+export async function getFile(transport: Transport, id: Oid | string | number): Promise<{ bytes: Uint8Array; contentType: string | null; filename: string | null }> {
+ const n = numericId(id);
+ let contentType: string | null = null, filename: string | null = null;
+ const onResponse = ({ headers }: { headers: { get(name: string): string | null } }) => {
+ contentType = headers.get("content-type");
+ const disposition = headers.get("content-disposition") ?? "";
+ const encoded = /filename\*=UTF-8''([^;]+)/i.exec(disposition)?.[1];
+ filename = encoded ? decodeURIComponent(encoded) : /filename="?([^";]+)"?/i.exec(disposition)?.[1] ?? null;
+ };
+ try {
+ const bytes = await transport.request({ method: "GET", path: `/attachment.img/${n}`, responseType: "bytes", onResponse });
+ return { bytes, contentType, filename };
+ } catch (error) {
+ throw named(error, `attachment ${n}`);
+ }
+}
+
/** Replace an attachment's bytes. The Attachment asset (name, filename, content type) is unchanged. */
export async function putBytes(transport: Transport, id: Oid | string | number, data: Bytes, contentType = "application/octet-stream"): Promise {
const n = numericId(id);
diff --git a/test/integration/attachments.test.ts b/test/integration/attachments.test.ts
index 8873ef6..93f74ef 100644
--- a/test/integration/attachments.test.ts
+++ b/test/integration/attachments.test.ts
@@ -39,6 +39,11 @@ describeInstance("attachments (live)", () => {
expect(got).toMatchObject({ filename: "sdk-integration large.txt", contentType: "text/plain", description: "two-step" });
const listed = await client.attachments.list(story);
expect(listed.map((a) => a.oid).sort()).toEqual([small.oid, large.oid].sort());
+ // The type the server served the bytes with, and a page with its total.
+ expect((await client.attachments.get(small.id)).contentType).toMatch(/^text\/plain/);
+ const first = await client.attachments.page(story, { page: { size: 1, start: 0 } });
+ expect(first).toMatchObject({ total: 2, more: true });
+ expect(first.attachments).toHaveLength(1);
});
it("put replaces the bytes; remove deletes the attachment; a missing one is NOT_FOUND", async () => {
diff --git a/test/unit/attachment.test.ts b/test/unit/attachment.test.ts
index 8a81937..a8698f5 100644
--- a/test/unit/attachment.test.ts
+++ b/test/unit/attachment.test.ts
@@ -3,8 +3,9 @@ import { AgilityApiClient } from "../../src/client";
import { ApiError } from "../../src/core/errors";
import { createFetchTransport } from "../../src/core/fetchTransport";
import type { RequestOptions, Transport } from "../../src/core/transport";
-import { AttachmentUploadError, add, list } from "../../src/services/attachments";
-import { fromBase64, getBytes, normalizeEmbeddedImageUrls, numericId, toBase64, uploadEmbeddedImage } from "../../src/wire/attachment";
+import { AttachmentUploadError, add, list, page } from "../../src/services/attachments";
+import { queryPage } from "../../src/wire/assetApi";
+import { fromBase64, getBytes, getFile, normalizeEmbeddedImageUrls, numericId, toBase64, uploadEmbeddedImage } from "../../src/wire/attachment";
const envelope = (over: Record = {}) => ({
requestId: "r", assetsCreated: { oidTokens: [], count: 0 }, assetsModified: { oidTokens: [], count: 0 }, assetsOperatedOn: { oidTokens: [], count: 0 },
@@ -103,3 +104,34 @@ describe("attachments service", () => {
expect(client.embeddedImages.url(12)).toBe("https://www14.v1host.com/v1sdktesting/embedded.img/12");
});
});
+
+describe("attachment gaps from gh-v1's C4", () => {
+ it("the fetch transport hands onResponse the headers of a successful response", async () => {
+ const fetchImpl = vi.fn().mockResolvedValue(new Response(new Uint8Array([1]), { status: 200, headers: { "content-type": "image/png", "content-disposition": 'inline; filename="a b.png"' } }));
+ const seen: (string | null)[] = [];
+ await createFetchTransport({ baseUrl: "https://x", fetchImpl }).request({ method: "GET", path: "/attachment.img/5", responseType: "bytes", onResponse: ({ headers }) => seen.push(headers.get("content-type")) });
+ expect(seen).toEqual(["image/png"]);
+ });
+
+ it("getFile returns the bytes with the served Content-Type and the filename from Content-Disposition", async () => {
+ const fetchImpl = vi.fn()
+ .mockResolvedValueOnce(new Response(new Uint8Array([1, 2]), { status: 200, headers: { "content-type": "text/plain", "content-disposition": 'inline; filename="inline.txt"' } }))
+ .mockResolvedValueOnce(new Response(new Uint8Array([3]), { status: 200, headers: { "content-type": "image/png", "content-disposition": "attachment; filename*=UTF-8''caf%C3%A9.png" } }));
+ const t = createFetchTransport({ baseUrl: "https://x", fetchImpl });
+ expect(await getFile(t, "Attachment:5")).toEqual({ bytes: new Uint8Array([1, 2]), contentType: "text/plain", filename: "inline.txt" });
+ expect((await getFile(t, 6)).filename).toBe("café.png");
+ });
+
+ it("queryPage asks for the total and says whether more pages follow; attachments.page uses it", async () => {
+ // resultMetaData as api/asset sends it with needTotal (v1sdktesting, 2026-11-07).
+ const withTotal = (rows: unknown[], total: number | null) => envelope({ queryResult: { results: [rows], resultMetaData: [{ page: { start: 0, size: 2 }, total }], count: 1 } });
+ const t = fake(withTotal([{ _oid: "Story:1" }, { _oid: "Story:2" }], 17093));
+ const p = await queryPage(t, { from: "Story", select: ["Name"], page: { size: 2, start: 0 } });
+ expect((t.calls[0]!.body as Record[])[0]!.needTotal).toBe(true);
+ expect(p).toMatchObject({ total: 17093, more: true });
+ const last = await queryPage(fake(withTotal([{ _oid: "Story:3" }], 3)), { from: "Story", select: ["Name"], page: { size: 2, start: 2 } });
+ expect(last).toMatchObject({ total: 3, more: false });
+ const a = await page(fake(withTotal([{ _oid: "Attachment:5", Name: "n" }], 1)), "Story:1");
+ expect(a).toMatchObject({ total: 1, more: false, attachments: [{ oid: "Attachment:5", id: "5" }] });
+ });
+});
diff --git a/test/unit/cjs.test.ts b/test/unit/cjs.test.ts
index a5cfb25..537e078 100644
--- a/test/unit/cjs.test.ts
+++ b/test/unit/cjs.test.ts
@@ -8,9 +8,9 @@ import { afterAll, beforeAll, describe, expect, it } from "vitest";
/**
* CommonJS consumers (gh-v1 is `require`-based) load the package through
* the "require" condition of its exports map. This builds the CJS bundle
- * the way the package does, installs it under node_modules/@versionone/sdk
+ * the way the package does, installs it under node_modules/@versionone/agility-sdk
* in a temp directory with the real package.json, and has a plain .cjs
- * script `require("@versionone/sdk")` and use it — so a bundle that only
+ * script `require("@versionone/agility-sdk")` and use it — so a bundle that only
* works as ESM (top-level await, import.meta, an ESM-only dependency) or an
* exports map that points nowhere fails here, not in a consumer.
*/
@@ -20,8 +20,11 @@ let dir = "";
beforeAll(async () => {
dir = mkdtempSync(join(tmpdir(), "sdk-cjs-"));
- const installed = join(dir, "node_modules", "@versionone", "sdk");
- await build({ config: false, entry: [join(root, "src", "index.ts")], format: ["cjs"], outDir: join(installed, "dist"), target: "node18", dts: false, sourcemap: false, silent: true });
+ const installed = join(dir, "node_modules", "@versionone", "agility-sdk");
+ // tsup reads entries as glob patterns, where "\\" is an escape: on Windows an
+ // absolute path (D:\\source\\…) matches nothing. Forward slashes work everywhere.
+ const entry = join(root, "src", "index.ts").replace(/\\/g, "/");
+ await build({ config: false, entry: [entry], format: ["cjs"], outDir: join(installed, "dist"), target: "node18", dts: false, sourcemap: false, silent: true });
copyFileSync(join(root, "package.json"), join(installed, "package.json"));
}, 30_000);
afterAll(() => { if (dir) rmSync(dir, { recursive: true, force: true }); });
@@ -29,7 +32,7 @@ afterAll(() => { if (dir) rmSync(dir, { recursive: true, force: true }); });
// The consumer: CommonJS, no transpiler, a fake fetch standing in for the server.
const CONSUMER = String.raw`
const assert = require("node:assert/strict");
-const sdk = require("@versionone/sdk");
+const sdk = require("@versionone/agility-sdk");
for (const name of ["AgilityApiClient", "createFetchTransport", "ApiError", "CommandFailureError", "assetApi", "rest1", "meta", "loc2", "Schema", "parseOid", "momentless"]) {
assert.ok(sdk[name], "missing export " + name);
}
@@ -55,7 +58,7 @@ const fetchImpl = async (url) => {
`;
describe("CommonJS consumers", () => {
- it("require('@versionone/sdk') resolves through the exports map and the bundle works with no ESM in sight", () => {
+ it("require('@versionone/agility-sdk') resolves through the exports map and the bundle works with no ESM in sight", () => {
const script = join(dir, "consumer.cjs");
writeFileSync(script, CONSUMER);
const r = spawnSync(process.execPath, [script], { cwd: dir, encoding: "utf8" });