@versionone/agility-sdk 1.0.0-alpha.4 → 1.0.0-alpha.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,10 +1,66 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.0.0-alpha.6
4
+
5
+ - The generated model is refreshed from meta.v1 26.3.2.13 (was
6
+ 26.3.1.13): no type, attribute, flag or operation changed between the
7
+ two; only the version stamp moved.
8
+
9
+ ### Breaking
10
+
11
+ - **Node 22 or later** (`engines.node` is `>=22`, was `>=18`), and `dist`
12
+ is now built for Node 22+ (tsup's `target` was `node18`). Node 18
13
+ reached end of life in April 2025 and Node 20 in April 2026; neither
14
+ gets security fixes. CI tests the maintained LTS lines, 22, 24 and 26,
15
+ so a consumer on 18 or 20 was already untested. (The chat-bot consumer
16
+ runs 24 and the CLI consumer requires 22; the browser app consumer
17
+ does not run on Node.)
18
+
19
+ ## 1.0.0-alpha.5
20
+
21
+ ### Added
22
+
23
+ - **Conditional schema reads** (for a browser app consumer). meta.v1 sends one
24
+ strong ETag for the whole schema (`"26.3.1.13.<stamp>"`, the same on
25
+ the document, a type and an attribute) and honours `If-None-Match` with
26
+ a 304. `client.meta.read({ ifNoneMatch })` and `readType(token, {
27
+ ifNoneMatch })` answer `{ value, etag }`, or null without a download when
28
+ the tag still matches; `client.meta.etag()` reads the tag alone from
29
+ the smallest resource. `TypeCache.etag` remembers the tag the loaded
30
+ types came with, and `client.schema.revalidate()` asks the server in
31
+ one small read and drops the loaded types only when the tag moved —
32
+ what a browser app's metamodel store does by hand. Transport: a request with
33
+ `acceptNotModified` resolves to undefined on a 304 after `onResponse`
34
+ sees it.
35
+
36
+ ### Changed
37
+
38
+ - **Idempotent requests are resent once by default** (`retries` now
39
+ defaults to 1, was 0): a GET, a query.v1 read or a guarded batch that
40
+ says so, after a network failure or a 5xx, 150 ms later. A plain write
41
+ is never resent, as before. Pass `retries: 0` for the old behaviour.
42
+ (a CLI consumer: the doc promised a retry the default never made.) A 429 counts
43
+ as transient like a 5xx — the instance throttles per token — and a
44
+ Retry-After is honoured when sent, up to 30 s.
45
+ - **A rejected query's `details` are the exceptions alone**
46
+ (`{ type, message }[]`, exported as `QueryRejection`), not query.v1's
47
+ whole error document; `message` is unchanged. (a CLI consumer: a formatter that
48
+ printed details printed a JSON document.)
49
+ - **`CommandFailureDetail.echoed` is not enumerable**: still readable on
50
+ every failure for a corrected resend, but a serialised failure (a
51
+ receipt, a log line, a bot's result) carries index, message and
52
+ violation only. (chat-bot and CLI consumers: re-mapping failures to keep results
53
+ small.)
54
+ - **The committed `metamodel.json` names no instance**: every `href` is
55
+ spelled from `/meta.v1/` (`scripts/download-metamodel.mjs` rewrites
56
+ them on a pull). The generated types are unchanged; nothing in the SDK
57
+ reads an href.
58
+
3
59
  ## 1.0.0-alpha.4 — what the three adoptions asked for
4
60
 
5
61
  ### Added
6
62
 
7
- - **Two narrow rest-1 paths** (agility-rooms): `client.data.operate(oid,
63
+ - **Two narrow rest-1 paths** (for a browser app consumer): `client.data.operate(oid,
8
64
  op)` runs an operation for its result — api/asset performs a Copy but
9
65
  reports only the source; rest-1 answers the copy's OID — and
10
66
  `client.copy(oid)` is that for Copy. `client.data.template(type, {
@@ -22,9 +78,9 @@
22
78
  module `fields`.
23
79
  - **`client.urls.asset` takes an asset Number** as well as an OID
24
80
  (`assetdetail.v1?Number=S-01001`): the readable form a CLI prints and a
25
- chat message shows (gh-v1, slack bot).
81
+ chat message shows (a CLI consumer, a chat-bot consumer).
26
82
  - **`Outcome.requestId`**: the server's id for a batch, worth logging
27
- (slack bot). Null for an outcome merged from several sends.
83
+ (a chat-bot consumer). Null for an outcome merged from several sends.
28
84
  - `outcomeOf` tolerating a partial envelope is now a stated contract
29
85
  with a test: consumers' fakes may send only the fields they need.
30
86
 
@@ -32,7 +88,7 @@
32
88
 
33
89
  - `parseViolation`: `tag` is null for the token spelling
34
90
  (`Violation'Invalid'Epic.Scope`); it was the token, which is not a loc-2
35
- tag (gh-v1).
91
+ tag (a CLI consumer).
36
92
  - `CHANGELOG.md` ships in the npm package.
37
93
  - Doc notes: `kind: "usage"` means nothing was sent (status 0), so an
38
94
  error formatter should not blame the server; `client.query`'s resend
@@ -50,9 +106,9 @@
50
106
  Alpha releases may break between versions. Every breaking change is listed
51
107
  here with the one-line migration a consumer applies. A renamed export keeps
52
108
  its old name as a `@deprecated` alias for one alpha and is removed in the
53
- next; `1.0.0` follows agility-rooms' adoption, and semver after that.
109
+ next; `1.0.0` follows the browser app consumer's adoption, and semver after that.
54
110
 
55
- ## 1.0.0-alpha.3 — the breaking batch (plan 005, step 2)
111
+ ## 1.0.0-alpha.3 — the breaking batch
56
112
 
57
113
  ### Breaking
58
114
 
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @versionone/agility-sdk
2
2
 
3
- 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.
3
+ TypeScript SDK for the Digital.ai Agility (formerly VersionOne) API. It works in Node 22 or later (ESM or CommonJS) and the browser, ships its own types, and has no runtime dependencies.
4
4
 
5
5
  Not to be confused with `v1sdk`, the 2.x JavaScript SDK: this is a new API, not a compatible successor.
6
6
 
@@ -15,7 +15,7 @@ npm install @versionone/agility-sdk@alpha
15
15
  ```ts
16
16
  import { AgilityApiClient } from "@versionone/agility-sdk";
17
17
 
18
- const client = new AgilityApiClient({ baseUrl: "https://www14.v1host.com/v1sdktesting", accessToken: process.env.V1_ACCESS_TOKEN });
18
+ const client = new AgilityApiClient({ baseUrl: "https://agility.example.com/MyInstance", accessToken: process.env.V1_ACCESS_TOKEN });
19
19
  const stories = await client.query({ from: "Story", select: ["Name", "Estimate"], page: { size: 10, start: 0 } });
20
20
  // stories[0].Estimate is a number | null; stories[0].Nmae does not compile
21
21
  ```
@@ -35,7 +35,7 @@ npm test
35
35
  - `npm run typecheck` - run TypeScript type checks
36
36
  - `npm run verify` - run typecheck + build + tests
37
37
  - `npm run test` - run unit tests
38
- - `npm run download:metamodel -- <baseAgilityUrl>` - refresh `metamodel.json` from an instance on the current release (instance-specific definitions stripped; `--raw` keeps them), then `npm run generate:types`. The cleanest source is a dev instance on a blank database (Core's gulp task for that); a hosted instance on the release gives the same file. The generated model is from meta.v1 **26.3.1.13** (hosted, 2026-11-12)
38
+ - `npm run download:metamodel -- <baseAgilityUrl>` - refresh `metamodel.json` from an instance on the current release (instance-specific definitions stripped; `--raw` keeps them), then `npm run generate:types`. The cleanest source is a dev instance on a blank database (the product's dev tooling has a task for that); a hosted instance on the release gives the same file. The generated model is from meta.v1 **26.3.2.13** (hosted, 2026-11-14)
39
39
 
40
40
  ## Public API
41
41
 
@@ -52,7 +52,7 @@ Changes between alphas are in `CHANGELOG.md`, each with its migration line.
52
52
  - `copy(oid)` - the Copy operation, answering the copy's OID (api/asset performs it but never says what it made); `data.operate(oid, op)` is the general form
53
53
  - `fields.describe(type, scope)` - what a form needs: required attributes (meta), the ones the product shows there (`api/visibleFields`, custom fields included), the scheme's important fields and whether they are enforced at close
54
54
  - `urls.asset(oidOrNumber)` / `attachment` / `embeddedImage` - where a browser opens each
55
- - `data.list` / `get` / `history` / `template` - rest-1 reads: deleted assets, history, and a new asset's template for a context (`New/<Type>?ctx=`); `meta.*` - meta.v1 raw; `schema` - the translated, cached view; `localize` - loc-2 labels for tags (a violation's `tag` is one)
55
+ - `data.list` / `get` / `history` / `template` - rest-1 reads: deleted assets, history, and a new asset's template for a context (`New/<Type>?ctx=`); `meta.*` - meta.v1 raw, with conditional reads (`read` / `readType` with `ifNoneMatch`, `etag()`: meta.v1 sends one ETag for the whole schema); `schema` - the translated, cached view, `schema.revalidate()` drops it only when that tag moves; `localize` - loc-2 labels for tags (a violation's `tag` is one)
56
56
  - Errors: everything thrown is an `ApiError` with a `kind` (`auth`, `forbidden`, `notFound`, `invalid`, `transient`, `aborted`, `commandFailure`, `protocol`, `usage`, `unknown`). A mistake caught before any request is a `UsageError`
57
57
  - `TypeCache` - one instance's loaded type definitions, shared by many clients (`new AgilityApiClient(config, { types })`)
58
58
  - `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
@@ -112,6 +112,8 @@ an approval, so a pushed tag waits for a maintainer before it stages. A
112
112
  package must exist before it can be linked, so the very first version was
113
113
  published once by hand by an `@versionone` org member.
114
114
 
115
- ## Before PR
115
+ ## Contributing
116
116
 
117
- - Run `npm run verify` to validate typecheck, build, and tests before opening a pull request.
117
+ Run `npm run verify` (typecheck, build, tests) before opening a pull request.
118
+ This repository is public: `CONTRIBUTING.md` says what may be committed
119
+ here and where working plans and consumer hand-offs live instead.
package/dist/index.cjs CHANGED
@@ -185,10 +185,22 @@ function messageOf(data, status) {
185
185
  return `HTTP ${status}`;
186
186
  }
187
187
  var delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
188
+ var MAX_RETRY_AFTER_MS = 3e4;
189
+ function retryDelay(response, attempt) {
190
+ const header = response.headers.get("retry-after");
191
+ if (header) {
192
+ const seconds = Number(header);
193
+ const ms = Number.isFinite(seconds) ? seconds * 1e3 : Date.parse(header) - Date.now();
194
+ if (Number.isFinite(ms) && ms > MAX_RETRY_AFTER_MS) return null;
195
+ if (Number.isFinite(ms) && ms > 0) return ms;
196
+ }
197
+ return 150 * (attempt + 1);
198
+ }
199
+ var retriable = (status) => status === 429 || status >= 500;
188
200
  function createFetchTransport(config) {
189
201
  const fetchImpl = config.fetchImpl ?? fetch;
190
202
  const timeoutMs = config.timeoutMs ?? 6e4;
191
- const retries = Math.max(0, config.retries ?? 0);
203
+ const retries = Math.max(0, config.retries ?? 1);
192
204
  const authorization = config.authorization ?? (config.accessToken ? `Bearer ${config.accessToken}` : void 0);
193
205
  const unauthorizedStatuses = config.unauthorizedStatuses ?? [401];
194
206
  return {
@@ -231,6 +243,10 @@ function createFetchTransport(config) {
231
243
  options.onResponse?.({ status: response.status, headers: response.headers });
232
244
  return await readBody(response, responseType);
233
245
  }
246
+ if (response.status === 304 && options.acceptNotModified) {
247
+ options.onResponse?.({ status: 304, headers: response.headers });
248
+ return void 0;
249
+ }
234
250
  const data = await readBody(response, responseType === "bytes" ? "text" : responseType === "json" ? "auto" : responseType);
235
251
  lastError = new ApiError(messageOf(data, response.status), response.status, void 0, data);
236
252
  } catch (error) {
@@ -249,8 +265,10 @@ function createFetchTransport(config) {
249
265
  }
250
266
  break;
251
267
  }
252
- if (response.status >= 500 && attempt + 1 < attempts) {
253
- await delay(150 * (attempt + 1));
268
+ if (retriable(response.status) && attempt + 1 < attempts) {
269
+ const wait = retryDelay(response, attempt);
270
+ if (wait === null) break;
271
+ await delay(wait);
254
272
  continue;
255
273
  }
256
274
  break;
@@ -457,6 +475,11 @@ function toNumber(value) {
457
475
  const n = typeof value === "string" && value.trim() !== "" ? Number(value) : NaN;
458
476
  return Number.isNaN(n) ? value : n;
459
477
  }
478
+ function failureDetail(index, message, echoed) {
479
+ const detail = { index, message, violation: parseViolation(message) };
480
+ Object.defineProperty(detail, "echoed", { value: echoed, enumerable: false, writable: false });
481
+ return detail;
482
+ }
460
483
  function parseViolation(message) {
461
484
  const parts = message.trim().split("'");
462
485
  if (parts[0] !== "Violation" || !parts[1]) return null;
@@ -466,12 +489,11 @@ function parseViolation(message) {
466
489
  return { rule, attribute: token, tag: !token && rest.length > 1 ? rest.join("'") : null };
467
490
  }
468
491
  function failuresOf(response) {
469
- return (response.commandFailures?.commands ?? []).map(({ error, ...echoed }) => ({
470
- index: typeof error === "object" && error && typeof error.sourceCommandIndex === "number" ? error.sourceCommandIndex : null,
471
- message: typeof error === "string" ? error : error?.message || "command failed",
472
- echoed,
473
- violation: parseViolation(typeof error === "string" ? error : error?.message || "")
474
- }));
492
+ return (response.commandFailures?.commands ?? []).map(({ error, ...echoed }) => failureDetail(
493
+ typeof error === "object" && error && typeof error.sourceCommandIndex === "number" ? error.sourceCommandIndex : null,
494
+ typeof error === "string" ? error : error?.message || "command failed",
495
+ echoed
496
+ ));
475
497
  }
476
498
  function outcomeOf(response) {
477
499
  return {
@@ -489,7 +511,7 @@ function mergeOutcome(into, done, offset) {
489
511
  into.created.push(...done.created);
490
512
  into.modified.push(...done.modified);
491
513
  into.operated.push(...done.operated);
492
- into.failures.push(...done.failures.map((f) => ({ ...f, index: f.index === null ? null : f.index + offset })));
514
+ into.failures.push(...done.failures.map((f) => failureDetail(f.index === null ? null : f.index + offset, f.message, f.echoed)));
493
515
  into.queryResults.push(...done.queryResults);
494
516
  into.requestId = null;
495
517
  if (done.durationSeconds !== null) into.durationSeconds = (into.durationSeconds ?? 0) + done.durationSeconds;
@@ -1323,10 +1345,13 @@ async function resolveRef(transport, spec, options = {}) {
1323
1345
  // src/wire/meta.ts
1324
1346
  var meta_exports = {};
1325
1347
  __export(meta_exports, {
1348
+ etag: () => etag,
1326
1349
  getAssetType: () => getAssetType,
1327
1350
  getAttributeDefinition: () => getAttributeDefinition,
1328
1351
  getMetamodel: () => getMetamodel,
1329
1352
  isMetamodelDocument: () => isMetamodelDocument,
1353
+ readAssetType: () => readAssetType,
1354
+ readMetamodel: () => readMetamodel,
1330
1355
  toAssetType: () => toAssetType,
1331
1356
  toAttributeDefinition: () => toAttributeDefinition,
1332
1357
  toMetamodel: () => toMetamodel
@@ -1371,12 +1396,36 @@ function toMetamodel(doc) {
1371
1396
  function isMetamodelDocument(value) {
1372
1397
  return !!value && typeof value === "object" && typeof value.Version === "string" && !!value.AssetTypes && typeof value.AssetTypes === "object";
1373
1398
  }
1399
+ var PROBE_PATH = "/meta.v1/BaseAsset/Name";
1400
+ async function conditional(transport, path, options) {
1401
+ let etag2 = null, status = 200;
1402
+ const onResponse = (r) => {
1403
+ status = r.status;
1404
+ etag2 = r.headers.get("etag");
1405
+ };
1406
+ const headers = options.ifNoneMatch ? { "if-none-match": options.ifNoneMatch } : void 0;
1407
+ const doc = await transport.request({ method: "GET", path, headers, responseType: "json", onResponse, acceptNotModified: !!options.ifNoneMatch });
1408
+ if (status === 304 || doc === void 0) return null;
1409
+ return { value: doc, etag: etag2 };
1410
+ }
1374
1411
  async function getMetamodel(transport) {
1375
1412
  return toMetamodel(await transport.request({ method: "GET", path: "/meta.v1", responseType: "json" }));
1376
1413
  }
1414
+ async function readMetamodel(transport, options = {}) {
1415
+ const read2 = await conditional(transport, "/meta.v1", options);
1416
+ return read2 && { value: toMetamodel(read2.value), etag: read2.etag };
1417
+ }
1377
1418
  async function getAssetType(transport, token) {
1378
1419
  return toAssetType(await transport.request({ method: "GET", path: `/meta.v1/${encodeURIComponent(token)}`, responseType: "json" }));
1379
1420
  }
1421
+ async function readAssetType(transport, token, options = {}) {
1422
+ const read2 = await conditional(transport, `/meta.v1/${encodeURIComponent(token)}`, options);
1423
+ return read2 && { value: toAssetType(read2.value), etag: read2.etag };
1424
+ }
1425
+ async function etag(transport, options = {}) {
1426
+ const read2 = await conditional(transport, PROBE_PATH, options);
1427
+ return read2 ? read2.etag : options.ifNoneMatch ?? null;
1428
+ }
1380
1429
  async function getAttributeDefinition(transport, tokenref) {
1381
1430
  const [type, attribute] = tokenref.split(".");
1382
1431
  if (!type || !attribute) throw new UsageError(`"${tokenref}" is not an attribute token ("Story.Name")`);
@@ -1388,6 +1437,13 @@ var TypeCache = class _TypeCache {
1388
1437
  /** Loaded types; null records that the instance has no such type, so it is not asked for again. */
1389
1438
  types = /* @__PURE__ */ new Map();
1390
1439
  loading = /* @__PURE__ */ new Map();
1440
+ /** The schema's tag the loaded types came with (meta.v1's ETag, one for the whole schema); null until a load, or for a seeded cache. */
1441
+ etag = null;
1442
+ /** Forget every loaded type (and the tag): the next query loads afresh. In-flight loads finish into the cache as before. */
1443
+ clear() {
1444
+ this.types.clear();
1445
+ this.etag = null;
1446
+ }
1391
1447
  /** A cache seeded from a metamodel document. */
1392
1448
  static fromMetamodel(metamodel) {
1393
1449
  const cache = new _TypeCache();
@@ -1452,7 +1508,10 @@ var Schema = class _Schema {
1452
1508
  this.types.set(token, null);
1453
1509
  return null;
1454
1510
  }
1455
- const request = getAssetType(this.transport, token).catch((error) => {
1511
+ const request = readAssetType(this.transport, token).then((read2) => {
1512
+ if (read2?.etag) this.cache.etag = read2.etag;
1513
+ return read2 ? read2.value : null;
1514
+ }).catch((error) => {
1456
1515
  if (error instanceof ApiError && error.status === 404) return null;
1457
1516
  throw error;
1458
1517
  }).then((type) => {
@@ -1462,6 +1521,23 @@ var Schema = class _Schema {
1462
1521
  this.loading.set(token, request);
1463
1522
  return request;
1464
1523
  }
1524
+ /**
1525
+ * Has the instance's schema changed since these types were loaded? One
1526
+ * small conditional read of meta.v1. Unchanged: nothing happens.
1527
+ * Changed (or never tagged, or the transport cannot see headers): the
1528
+ * loaded types are dropped, so the next query loads them again, and the
1529
+ * new tag is kept. Answers whether it changed and the current tag. A
1530
+ * schema without a transport (seeded) never changes.
1531
+ */
1532
+ async revalidate() {
1533
+ if (!this.transport) return { changed: false, etag: this.cache.etag };
1534
+ const was = this.cache.etag;
1535
+ const now2 = await etag(this.transport, { ifNoneMatch: was });
1536
+ if (was && now2 === was) return { changed: false, etag: was };
1537
+ this.cache.clear();
1538
+ this.cache.etag = now2;
1539
+ return { changed: true, etag: now2 };
1540
+ }
1465
1541
  /**
1466
1542
  * Load whatever the queries in a batch need to have their rows translated:
1467
1543
  * the `from` types, then the types behind every relation hop and sub-select,
@@ -1522,8 +1598,8 @@ __export(queryV1_exports, {
1522
1598
  });
1523
1599
  function rejected(error) {
1524
1600
  const details = error.details;
1525
- const messages = (details?.exceptions ?? []).map((e) => e?.message).filter((m) => typeof m === "string" && !!m);
1526
- return messages.length ? new ApiError(messages.join("; "), error.status, "QUERY_REJECTED", error.details) : error;
1601
+ const rejections = (details?.exceptions ?? []).filter((e) => e && typeof e.message === "string" && e.message).map((e) => ({ type: typeof e.type === "string" ? e.type : null, message: e.message }));
1602
+ return rejections.length ? new ApiError(rejections.map((r) => r.message).join("; "), error.status, "QUERY_REJECTED", rejections) : error;
1527
1603
  }
1528
1604
  async function queries2(transport, qs, options = {}) {
1529
1605
  for (const q of qs) if (!q.page) throw new UsageError(`query of ${q.from} without a page: every query.v1 read must be bounded`);
@@ -1707,11 +1783,21 @@ var AgilityApiClient = class _AgilityApiClient {
1707
1783
  return row;
1708
1784
  }
1709
1785
  // ---- the endpoints behind the reads -----------------------------------------------
1710
- /** meta.v1: the instance's schema, raw. (The translated, cached view is `schema`.) */
1786
+ /**
1787
+ * meta.v1: the instance's schema, raw. (The translated, cached view is
1788
+ * `schema`; `schema.revalidate()` drops it when the schema's tag moves.)
1789
+ * `read` and `readType` are conditional: pass the tag a previous answer
1790
+ * carried and an unchanged schema answers null without a download —
1791
+ * meta.v1 sends one strong ETag for the whole schema. `etag()` reads that
1792
+ * tag alone, from the smallest resource.
1793
+ */
1711
1794
  meta = {
1712
1795
  metamodel: () => getMetamodel(this.transport),
1796
+ read: (options) => readMetamodel(this.transport, options),
1713
1797
  assetType: (token) => getAssetType(this.transport, token),
1714
- attributeDefinition: (tokenref) => getAttributeDefinition(this.transport, tokenref)
1798
+ readType: (token, options) => readAssetType(this.transport, token, options),
1799
+ attributeDefinition: (tokenref) => getAttributeDefinition(this.transport, tokenref),
1800
+ etag: (options) => etag(this.transport, options)
1715
1801
  };
1716
1802
  /**
1717
1803
  * loc-2.v1: labels for tags, in one request (meta's DisplayName values are