@versionone/agility-sdk 1.0.0-alpha.0 → 1.0.0-alpha.3

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/dist/index.js CHANGED
@@ -5,16 +5,61 @@ var __export = (target, all) => {
5
5
  };
6
6
 
7
7
  // src/core/errors.ts
8
+ function kindOf(status, code) {
9
+ switch (code) {
10
+ case "ABORTED":
11
+ return "aborted";
12
+ case "TIMEOUT":
13
+ case "NETWORK_ERROR":
14
+ return "transient";
15
+ case "BAD_JSON":
16
+ case "BAD_RESPONSE":
17
+ return "protocol";
18
+ case "USAGE":
19
+ return "usage";
20
+ case "CommandFailure":
21
+ return "commandFailure";
22
+ case "QUERY_REJECTED":
23
+ case "TOO_LARGE":
24
+ return "invalid";
25
+ case "NOT_FOUND":
26
+ return "notFound";
27
+ }
28
+ if (status === 401) return "auth";
29
+ if (status === 403) return "forbidden";
30
+ if (status === 404) return "notFound";
31
+ if (status === 408 || status === 429 || status >= 500) return "transient";
32
+ if (status >= 400) return "invalid";
33
+ return "unknown";
34
+ }
35
+ var causeOf = (details) => details && typeof details === "object" && "cause" in details ? details.cause : void 0;
8
36
  var ApiError = class extends Error {
37
+ /** The HTTP status; 0 when no answer came back (network, timeout, abort, usage). */
9
38
  status;
10
39
  code;
11
40
  details;
12
- constructor(message, status, code, details) {
13
- super(message);
41
+ kind;
42
+ constructor(message, status, code, details, kind = kindOf(status, code)) {
43
+ const cause = causeOf(details);
44
+ super(message, cause === void 0 ? void 0 : { cause });
14
45
  this.name = "ApiError";
15
46
  this.status = status;
16
47
  this.code = code;
17
48
  this.details = details;
49
+ this.kind = kind;
50
+ }
51
+ };
52
+ var UsageError = class extends ApiError {
53
+ constructor(message, details) {
54
+ super(message, 0, "USAGE", details, "usage");
55
+ this.name = "UsageError";
56
+ }
57
+ };
58
+ var WrappedError = class extends ApiError {
59
+ constructor(message, code, cause, details) {
60
+ const inner = cause instanceof ApiError ? cause : void 0;
61
+ super(message, inner?.status ?? 0, code, { ...details, cause }, inner?.kind ?? "unknown");
62
+ this.name = "WrappedError";
18
63
  }
19
64
  };
20
65
 
@@ -154,7 +199,7 @@ function isOidToken(value) {
154
199
  }
155
200
  function parseOid(token) {
156
201
  const m = OID_TOKEN.exec(token);
157
- if (!m) throw new Error(`"${token}" is not an OID token (Story:1234)`);
202
+ if (!m) throw new UsageError(`"${token}" is not an OID token (Story:1234)`);
158
203
  return { type: m[1], id: Number(m[2]), moment: m[3] === void 0 ? null : Number(m[3]) };
159
204
  }
160
205
  function momentless(token) {
@@ -178,6 +223,7 @@ __export(assetApi_exports, {
178
223
  mergeOutcome: () => mergeOutcome,
179
224
  momentless: () => momentless,
180
225
  outcomeOf: () => outcomeOf,
226
+ parseViolation: () => parseViolation,
181
227
  preview: () => preview,
182
228
  queries: () => queries,
183
229
  query: () => query,
@@ -343,11 +389,20 @@ function toNumber(value) {
343
389
  const n = typeof value === "string" && value.trim() !== "" ? Number(value) : NaN;
344
390
  return Number.isNaN(n) ? value : n;
345
391
  }
392
+ function parseViolation(message) {
393
+ const parts = message.trim().split("'");
394
+ if (parts[0] !== "Violation" || !parts[1]) return null;
395
+ const rule = parts[1], rest = parts.slice(2).filter((p) => p !== "");
396
+ if (rest[0] === "AttributeDefinition" && rest[1] && rest[2]) return { rule, attribute: `${rest[2]}.${rest[1]}`, tag: rest.join("'") };
397
+ const token = rest.length === 1 && /^[A-Za-z][A-Za-z0-9_]*\.[A-Za-z][A-Za-z0-9_]*$/.test(rest[0]) ? rest[0] : null;
398
+ return { rule, attribute: token, tag: rest.length ? rest.join("'") : null };
399
+ }
346
400
  function failuresOf(response) {
347
401
  return (response.commandFailures?.commands ?? []).map(({ error, ...echoed }) => ({
348
402
  index: typeof error === "object" && error && typeof error.sourceCommandIndex === "number" ? error.sourceCommandIndex : null,
349
403
  message: typeof error === "string" ? error : error?.message || "command failed",
350
- echoed
404
+ echoed,
405
+ violation: parseViolation(typeof error === "string" ? error : error?.message || "")
351
406
  }));
352
407
  }
353
408
  function outcomeOf(response) {
@@ -373,11 +428,14 @@ function mergeOutcome(into, done, offset) {
373
428
  var CommandFailureError = class extends ApiError {
374
429
  failures;
375
430
  response;
431
+ /** What the batch did, in the model's terms: the commands that succeeded are committed. */
432
+ outcome;
376
433
  constructor(response, failures = failuresOf(response)) {
377
434
  super(failures.map((f) => f.message).join("; ") || "command failed", 200, "CommandFailure", failures);
378
435
  this.name = "CommandFailureError";
379
436
  this.failures = failures;
380
437
  this.response = response;
438
+ this.outcome = outcomeOf(response);
381
439
  }
382
440
  };
383
441
  var bodyOf = (c) => {
@@ -413,7 +471,7 @@ async function send(transport, commands, options = {}) {
413
471
  const results = response.queryResult?.results ?? [];
414
472
  let aligned = realign(queries3, results, failed);
415
473
  if (!aligned) {
416
- if (!failures.some((f) => f.index === null)) throw new Error(`api/asset returned ${results.length} query results for ${queries3.filter((q) => !failed.has(q.index)).length} queries that did not fail; cannot tell which result belongs to which query`);
474
+ if (!failures.some((f) => f.index === null)) throw new ApiError(`api/asset returned ${results.length} query results for ${queries3.filter((q) => !failed.has(q.index)).length} queries that did not fail; cannot tell which result belongs to which query`, 200, "BAD_RESPONSE", response);
417
475
  aligned = queries3.map(() => null);
418
476
  }
419
477
  const contextOf = (i) => {
@@ -435,7 +493,7 @@ async function query(transport, q, options = {}) {
435
493
  return rows ?? [];
436
494
  }
437
495
  async function queryPage(transport, q, options = {}) {
438
- if (!q.page) throw new Error(`query of ${q.from} without a page: every api/asset read must be bounded`);
496
+ if (!q.page) throw new UsageError(`query of ${q.from} without a page: every api/asset read must be bounded`);
439
497
  const response = await send(transport, [{ ...q, needTotal: true }], { ...options, throwOnFailure: true });
440
498
  const rows = response.queryResult.results[0] ?? [];
441
499
  const reported = response.queryResult.resultMetaData?.[0]?.total;
@@ -443,7 +501,7 @@ async function queryPage(transport, q, options = {}) {
443
501
  return { rows, total, more: total === null ? rows.length === q.page.size : q.page.start + rows.length < total };
444
502
  }
445
503
  async function queries(transport, qs, options = {}) {
446
- for (const q of qs) if (!q.page) throw new Error(`query of ${q.from} without a page: every api/asset read must be bounded`);
504
+ for (const q of qs) if (!q.page) throw new UsageError(`query of ${q.from} without a page: every api/asset read must be bounded`);
447
505
  const response = await send(transport, qs, { ...options, throwOnFailure: true });
448
506
  return qs.map((_, i) => response.queryResult.results[i] ?? []);
449
507
  }
@@ -481,7 +539,7 @@ function fromBase64(text) {
481
539
  }
482
540
  function numericId(spec) {
483
541
  const m = /^(?:[A-Za-z]+:)?(\d+)(?::\d+)?$/.exec(String(spec).trim());
484
- if (!m) throw new TypeError(`"${spec}" is not an asset id (5678, or an OID token such as Attachment:5678)`);
542
+ if (!m) throw new UsageError(`"${spec}" is not an asset id (5678, or an OID token such as Attachment:5678)`);
485
543
  return m[1];
486
544
  }
487
545
  var join = (baseUrl, path) => `${baseUrl.replace(/\/+$/, "")}/${path}`;
@@ -577,9 +635,9 @@ async function get(transport, id) {
577
635
  const file = await getFile(transport, meta.id);
578
636
  return { ...meta, bytes: file.bytes, contentType: file.contentType ?? meta.contentType, recordedContentType: meta.contentType };
579
637
  }
580
- var AttachmentUploadError = class extends Error {
638
+ var AttachmentUploadError = class extends WrappedError {
581
639
  constructor(attachment, cause) {
582
- super(`${attachment.oid} was created on its asset with no content, and uploading the file failed: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
640
+ super(`${attachment.oid} was created on its asset with no content, and uploading the file failed: ${cause instanceof Error ? cause.message : String(cause)}`, "ATTACHMENT_UPLOAD", cause);
583
641
  this.attachment = attachment;
584
642
  this.name = "AttachmentUploadError";
585
643
  }
@@ -612,7 +670,7 @@ async function remove(transport, id) {
612
670
  // src/services/guard.ts
613
671
  var CHUNK = 100;
614
672
  var UNGUARDABLE_TYPES = ["Idea", "IdeaChangeSet", "IdeaStatus", "IdeaComment", "IdeaCategory", "IdeaForum", "IdeaUser"];
615
- var UnguardableError = class extends Error {
673
+ var UnguardableError = class extends UsageError {
616
674
  reasons;
617
675
  constructor(reasons) {
618
676
  super(`cannot guard this batch: ${reasons.map((r) => `command ${r.index}: ${r.reason}`).join("; ")}`);
@@ -678,7 +736,7 @@ function guard(projected, stamps) {
678
736
  unstamped.push(x.oid);
679
737
  continue;
680
738
  }
681
- if (!STAMP.test(stamp)) throw new Error(`${x.oid}: ChangeDate ${JSON.stringify(stamp)} is not a timestamp; refusing to put it in a filter`);
739
+ if (!STAMP.test(stamp)) throw new ApiError(`${x.oid}: ChangeDate ${JSON.stringify(stamp)} is not a timestamp; refusing to put it in a filter`, 200, "BAD_RESPONSE", { oid: x.oid, stamp });
682
740
  const scope = { from: typeOfOid(x.oid), filter: ["ID=$oid", `ChangeDate='${stamp}'`], with: { $oid: x.oid } };
683
741
  commands.push(x.update ? { ...scope, update: x.update } : { ...scope, execute: x.execute });
684
742
  }
@@ -695,16 +753,14 @@ function passesOf(projected) {
695
753
  }
696
754
  return passes;
697
755
  }
698
- var GuardedPassError = class extends Error {
756
+ var GuardedPassError = class extends WrappedError {
699
757
  constructor(cause, outcome, sent, pass) {
700
- super(`guarded send failed in pass ${pass + 1}${pass ? " (earlier passes applied)" : ""}: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
701
- this.cause = cause;
758
+ super(`guarded send failed in pass ${pass + 1}${pass ? " (earlier passes applied)" : ""}: ${cause instanceof Error ? cause.message : String(cause)}`, "GUARDED_PASS", cause);
702
759
  this.outcome = outcome;
703
760
  this.sent = sent;
704
761
  this.pass = pass;
705
762
  this.name = "GuardedPassError";
706
763
  }
707
- cause;
708
764
  outcome;
709
765
  sent;
710
766
  pass;
@@ -827,9 +883,46 @@ async function snapshot(transport, described) {
827
883
  }
828
884
  return before;
829
885
  }
830
- var INVERSE = { Inactivate: "Reactivate", Reactivate: "Inactivate", Delete: "Undelete", Undelete: "Delete" };
831
- function reverseOf(described, before, done) {
886
+ var INVERSE = { Inactivate: "Reactivate", QuickClose: "Reactivate", Reactivate: "Inactivate", Delete: "Undelete", Undelete: "Delete" };
887
+ var CASCADING = /* @__PURE__ */ new Set(["Inactivate", "QuickClose"]);
888
+ var CASCADE_DEPTH = 8;
889
+ async function openDescendantsOf(transport, schema, roots) {
890
+ const out = {};
891
+ const rootOf = new Map(roots.map((r) => [momentless(r), momentless(r)]));
892
+ const seen = new Set(rootOf.keys());
893
+ let frontier = [...rootOf.keys()];
894
+ for (let depth = 0; depth < CASCADE_DEPTH && frontier.length; depth += 1) {
895
+ const children = /* @__PURE__ */ new Map();
896
+ for (const [type, list3] of byType(frontier)) {
897
+ await schema.load(type);
898
+ if (!schema.attribute(type, "Children")) continue;
899
+ for (const part of chunks(list3)) {
900
+ const rows = await query(transport, { from: type, select: ["Children"], filter: ["ID=$oids"], with: { $oids: part }, page: { size: part.length, start: 0 } });
901
+ for (const r of rows) {
902
+ const parent = momentless(r._oid);
903
+ for (const c of [].concat(plain(r.Children) ?? [])) {
904
+ const child = momentless(String(c));
905
+ if (seen.has(child)) continue;
906
+ seen.add(child);
907
+ children.set(child, rootOf.get(parent));
908
+ }
909
+ }
910
+ }
911
+ }
912
+ const states = children.size ? await statesOf(transport, [...children.keys()]) : {};
913
+ frontier = [];
914
+ for (const [child, root] of children) {
915
+ if (states[child] === void 0 || isClosed(states[child])) continue;
916
+ rootOf.set(child, root);
917
+ (out[root] ??= []).push(child);
918
+ frontier.push(child);
919
+ }
920
+ }
921
+ return out;
922
+ }
923
+ function reverseOf(described, before, done, context = {}) {
832
924
  const commands = [], notes = [];
925
+ const cannot = (oid, op) => context.canOperate?.(typeOfOid(oid), op) === false;
833
926
  const modified = new Set(done.modified.map(momentless)), operated = new Set(done.operated.map(momentless));
834
927
  for (const u of described.updates) {
835
928
  if (!modified.has(u.oid)) continue;
@@ -854,26 +947,48 @@ function reverseOf(described, before, done) {
854
947
  for (const e of described.executes) {
855
948
  if (!operated.has(e.oid)) continue;
856
949
  const inverse = INVERSE[e.op];
857
- if (inverse) commands.push({ from: e.oid, execute: inverse });
858
- else notes.push(`${e.oid}: ${e.op} has no known inverse (was ${String(before[e.oid]?.AssetState ?? "?")})`);
950
+ if (!inverse) {
951
+ notes.push(`${e.oid}: ${e.op} has no known inverse (was ${String(before[e.oid]?.AssetState ?? "?")})`);
952
+ continue;
953
+ }
954
+ if (cannot(e.oid, inverse)) {
955
+ notes.push(`${e.oid}: ${typeOfOid(e.oid)} has no ${inverse} operation, so its ${e.op} is not reversed`);
956
+ continue;
957
+ }
958
+ commands.push({ from: e.oid, execute: inverse });
959
+ if (!CASCADING.has(e.op)) continue;
960
+ const open = context.openDescendants?.[e.oid] ?? [];
961
+ const closed = context.cascaded ? context.cascaded[e.oid] ?? [] : void 0;
962
+ if (closed === void 0) {
963
+ if (open.length) notes.push(`${e.oid}: ${e.op} may also have closed ${open.length} open descendant${open.length === 1 ? "" : "s"} (${open.join(", ")}); they could not be read back, so they are not reopened`);
964
+ continue;
965
+ }
966
+ for (const d of closed) {
967
+ if (cannot(d, "Reactivate")) notes.push(`${d}: closed with ${e.oid}, but ${typeOfOid(d)} has no Reactivate operation`);
968
+ else commands.push({ from: d, execute: "Reactivate" });
969
+ }
970
+ if (closed.length) notes.push(`${e.oid}: ${e.op} also closed ${closed.join(", ")}; reopened after it`);
971
+ }
972
+ for (const oid of [...done.created].reverse().map(momentless)) {
973
+ if (cannot(oid, "Delete")) notes.push(`${oid}: ${typeOfOid(oid)} has no Delete operation, so it is not deleted${typeOfOid(oid) === "Conversation" ? " (the server removes a Conversation once its last Expression is deleted)" : ""}`);
974
+ else commands.push({ from: oid, execute: "Delete" });
859
975
  }
860
- for (const oid of [...done.created].reverse()) commands.push({ from: momentless(oid), execute: "Delete" });
861
976
  for (const d of described.deferred ?? []) notes.push(`command ${d.index} (${d.alias}): an aliased ${d.kind} of ${d.from}, sent as written with no snapshot; not reversed`);
862
977
  if (commands.length) notes.unshift("restores the state recorded at snapshotAt; anything that changed between then and sentAt is not reflected, and a scalar restored to null is cleared, not unset");
863
978
  return { commands, notes };
864
979
  }
865
- var BatchRecordError = class extends Error {
980
+ var BatchRecordError = class extends WrappedError {
866
981
  receipt;
867
982
  constructor(cause, receipt) {
868
- super(`the batch was sent and its outcome recorded, but reading the assets back afterwards failed, so receipt.after is incomplete: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
983
+ super(`the batch was sent and its outcome recorded, but reading the assets back afterwards failed, so receipt.after is incomplete: ${cause instanceof Error ? cause.message : String(cause)}`, "BATCH_RECORD", cause);
869
984
  this.name = "BatchRecordError";
870
985
  this.receipt = receipt;
871
986
  }
872
987
  };
873
- var BatchSendError = class extends Error {
988
+ var BatchSendError = class extends WrappedError {
874
989
  receipt;
875
990
  constructor(cause, receipt) {
876
- super(`the batch send failed; whether the server applied it is unknown: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
991
+ super(`the batch send failed; whether the server applied it is unknown: ${cause instanceof Error ? cause.message : String(cause)}`, "BATCH_SEND", cause);
877
992
  this.name = "BatchSendError";
878
993
  this.receipt = receipt;
879
994
  }
@@ -881,7 +996,7 @@ var BatchSendError = class extends Error {
881
996
  var now = () => (/* @__PURE__ */ new Date()).toISOString();
882
997
  async function runBatch(transport, schema, commands, options = {}) {
883
998
  const useGuard = options.guard ?? true;
884
- if (!commands.length) throw new Error("the batch is empty");
999
+ if (!commands.length) throw new UsageError("the batch is empty");
885
1000
  const refused = unguardable(commands);
886
1001
  const fatal = refused.filter((r) => r.reason.includes("rejected by the server"));
887
1002
  if (fatal.length || useGuard && refused.length) throw new UnguardableError(useGuard ? refused : fatal);
@@ -892,12 +1007,14 @@ async function runBatch(transport, schema, commands, options = {}) {
892
1007
  const described = describe(commands, projected);
893
1008
  const touched = touchedBy(projected);
894
1009
  const names = await namesOf(transport, schema, touched, noteFailure);
895
- const receipt = { receipt: 1, request: commands, previewedAt, preview: projected, described, names: Object.fromEntries(names), before: {}, sent: false, guarded: false, created: [], touched: [], after: {}, skipped: [], reverse: { commands: [], notes: [] }, notes };
1010
+ const receipt = { receipt: 1, request: commands, previewedAt, preview: projected, described, names: Object.fromEntries(names), before: {}, sent: false, guarded: false, created: [], touched: [], after: {}, skipped: [], failed: [], openDescendants: {}, cascaded: {}, reverse: { commands: [], notes: [] }, notes };
896
1011
  const nothing = !described.creates.length && !described.updates.length && !described.executes.length && !described.queries.length && !described.deferred.length;
897
1012
  if (nothing) return receipt;
898
1013
  for (const d of described.deferred) notes.push(`command ${d.index} (${d.alias}): an aliased ${d.kind}, which preview does not expand; sent as written, not snapshotted, not reversible`);
899
1014
  receipt.snapshotAt = now();
900
1015
  receipt.before = await snapshot(transport, described);
1016
+ const closing = described.executes.filter((e) => CASCADING.has(e.op)).map((e) => e.oid);
1017
+ if (closing.length) receipt.openDescendants = await openDescendantsOf(transport, schema, closing);
901
1018
  if (options.dryRun) return receipt;
902
1019
  if (options.confirm && !await options.confirm(receipt)) return receipt;
903
1020
  let unstamped = [];
@@ -929,13 +1046,29 @@ async function runBatch(transport, schema, commands, options = {}) {
929
1046
  const unnamed = { number: null, name: null };
930
1047
  receipt.created = done.created.map((oid, i) => ({ alias: described.creates[i]?.alias ?? null, type: described.creates[i]?.type ?? typeOfOid(oid), oid, ...unnamed }));
931
1048
  receipt.touched = hit.map((oid) => ({ oid, ...names.get(oid) ?? unnamed }));
932
- receipt.skipped = useGuard ? [.../* @__PURE__ */ new Set([...unstamped, ...touched.filter((oid) => !hit.includes(oid))])] : [];
933
- receipt.reverse = reverseOf(described, receipt.before, done);
1049
+ const sentAs = receipt.sentCommands ?? [];
1050
+ const oidOfSent = (i) => {
1051
+ const body = i === null ? void 0 : sentAs[i] ? bodyOf3(sentAs[i]) : void 0;
1052
+ const id = body?.with?.$oid ?? body?.from;
1053
+ return typeof id === "string" && id.includes(":") ? momentless(id) : null;
1054
+ };
1055
+ receipt.failed = [...new Set(done.failures.map((f) => oidOfSent(f.index)).filter((oid) => !!oid && touched.includes(oid) && !hit.includes(oid)))];
1056
+ receipt.skipped = useGuard ? [.../* @__PURE__ */ new Set([...unstamped, ...touched.filter((oid) => !hit.includes(oid) && !receipt.failed.includes(oid))])] : [];
1057
+ for (const type of new Set([...done.created, ...hit].map((oid) => typeOfOid(momentless(oid))))) await schema.load(type).catch(() => null);
1058
+ const canOperate = (type, op) => schema.canOperate(type, op);
1059
+ const operatedNow = new Set(done.operated.map(momentless));
1060
+ const closed = Object.keys(receipt.openDescendants).filter((oid) => operatedNow.has(oid) && receipt.openDescendants[oid].length);
1061
+ receipt.reverse = reverseOf(described, receipt.before, done, { openDescendants: receipt.openDescendants, cascaded: closed.length ? void 0 : {}, canOperate });
934
1062
  try {
1063
+ if (closed.length) {
1064
+ const states = await statesOf(transport, closed.flatMap((oid) => receipt.openDescendants[oid]));
1065
+ receipt.cascaded = Object.fromEntries(closed.map((oid) => [oid, receipt.openDescendants[oid].filter((d) => isClosed(states[d]))]));
1066
+ receipt.reverse = reverseOf(described, receipt.before, done, { openDescendants: receipt.openDescendants, cascaded: receipt.cascaded, canOperate });
1067
+ }
935
1068
  const more = await namesOf(transport, schema, [...done.created, ...hit.filter((oid) => !names.has(oid))], noteFailure);
936
1069
  receipt.created = receipt.created.map((c) => ({ ...c, ...more.get(c.oid) ?? unnamed }));
937
1070
  receipt.touched = receipt.touched.map((t) => names.has(t.oid) ? t : { ...t, ...more.get(t.oid) ?? unnamed });
938
- const all = [...done.created, ...hit];
1071
+ const all = [.../* @__PURE__ */ new Set([...done.created, ...hit, ...Object.values(receipt.cascaded).flat()])];
939
1072
  receipt.after = all.length ? await stampsOf(transport, all) : {};
940
1073
  } catch (error) {
941
1074
  throw new BatchRecordError(error, receipt);
@@ -1034,7 +1167,7 @@ async function resolveNumbers(transport, numbers, options = {}) {
1034
1167
  for (const n of numbers) if (!firsts.has(n.trim().toUpperCase())) firsts.set(n.trim().toUpperCase(), n.trim());
1035
1168
  const asked = [...firsts.values()];
1036
1169
  const invalid = asked.filter((n) => !isAssetNumber(n));
1037
- if (invalid.length) throw new TypeError(`not asset numbers (expected a prefix and digits, S-1234): ${invalid.join(", ")}`);
1170
+ if (invalid.length) throw new UsageError(`not asset numbers (expected a prefix and digits, S-1234): ${invalid.join(", ")}`);
1038
1171
  const byNumber = /* @__PURE__ */ new Map();
1039
1172
  for (let i = 0; i < asked.length; i += CHUNK3) {
1040
1173
  for (const r of await askRoots(transport, asked.slice(i, i + CHUNK3), options.types)) {
@@ -1055,7 +1188,7 @@ async function resolveRef(transport, spec, options = {}) {
1055
1188
  return { number: null, oid, type: typeOfOid(oid), name: null, assetState: null };
1056
1189
  }
1057
1190
  if (isAssetNumber(text)) return (await resolveNumbers(transport, [text], options)).found[0] ?? null;
1058
- throw new TypeError(`"${spec}" is neither an asset number (S-1234) nor an OID token (Story:1234)`);
1191
+ throw new UsageError(`"${spec}" is neither an asset number (S-1234) nor an OID token (Story:1234)`);
1059
1192
  }
1060
1193
 
1061
1194
  // src/wire/meta.ts
@@ -1099,7 +1232,8 @@ function toAssetType(doc) {
1099
1232
  ...doc.DefaultOrderBy ? { DefaultOrderBy: doc.DefaultOrderBy.tokenref } : {},
1100
1233
  ...doc.DefaultDisplayBy ? { DefaultDisplayBy: doc.DefaultDisplayBy.tokenref } : {},
1101
1234
  ...doc.ShortName ? { ShortName: doc.ShortName.tokenref } : {},
1102
- Attributes: Object.fromEntries(Object.entries(doc.Attributes ?? {}).map(([key, value]) => [key, toAttributeDefinition(value)]))
1235
+ Attributes: Object.fromEntries(Object.entries(doc.Attributes ?? {}).map(([key, value]) => [key, toAttributeDefinition(value)])),
1236
+ ...doc.Operations ? { Operations: Object.keys(doc.Operations).map((token) => token.slice(token.indexOf(".") + 1)) } : {}
1103
1237
  };
1104
1238
  }
1105
1239
  function toMetamodel(doc) {
@@ -1116,7 +1250,7 @@ async function getAssetType(transport, token) {
1116
1250
  }
1117
1251
  async function getAttributeDefinition(transport, tokenref) {
1118
1252
  const [type, attribute] = tokenref.split(".");
1119
- if (!type || !attribute) throw new Error(`"${tokenref}" is not an attribute token ("Story.Name")`);
1253
+ if (!type || !attribute) throw new UsageError(`"${tokenref}" is not an attribute token ("Story.Name")`);
1120
1254
  return toAttributeDefinition(await transport.request({ method: "GET", path: `/meta.v1/${encodeURIComponent(type)}/${encodeURIComponent(attribute)}`, responseType: "json" }));
1121
1255
  }
1122
1256
 
@@ -1163,6 +1297,15 @@ var Schema = class _Schema {
1163
1297
  attribute(type, name) {
1164
1298
  return attributeOf(this, type, name);
1165
1299
  }
1300
+ /**
1301
+ * Can the type do this operation (Delete, Reactivate, …)? Undefined when
1302
+ * the type is not loaded or its definition carries no operations, so a
1303
+ * caller can tell "no" from "don't know".
1304
+ */
1305
+ canOperate(type, op) {
1306
+ const t = this.assetType(type);
1307
+ return t?.Operations ? t.Operations.includes(op) : void 0;
1308
+ }
1166
1309
  /** The tokens loaded so far. */
1167
1310
  get loaded() {
1168
1311
  return [...this.types.entries()].filter(([, t]) => t !== null).map(([token]) => token);
@@ -1234,7 +1377,7 @@ async function localize(transport, tags, options = {}) {
1234
1377
  const encoded = encodeURIComponent(toLson(unique));
1235
1378
  const headers = options.culture ? { "Accept-Language": options.culture } : void 0;
1236
1379
  const result = encoded.length <= (options.maxGetLength ?? DEFAULT_MAX_GET_LENGTH) ? await transport.request({ method: "GET", path: `/loc-2.v1?${encoded}`, headers, responseType: "json" }) : await transport.request({ method: "POST", path: "/loc-2.v1", headers, body: encoded, contentType: "application/x-www-form-urlencoded", responseType: "json", idempotent: true });
1237
- if (!result || typeof result !== "object" || Array.isArray(result)) throw new Error(`loc-2.v1 answered an array of tags with ${JSON.stringify(result)?.slice(0, 100)}, not an object`);
1380
+ if (!result || typeof result !== "object" || Array.isArray(result)) throw new ApiError(`loc-2.v1 answered an array of tags with ${JSON.stringify(result)?.slice(0, 100)}, not an object`, 200, "BAD_RESPONSE", result);
1238
1381
  return result;
1239
1382
  }
1240
1383
  async function localizeOne(transport, tag, options = {}) {
@@ -1254,7 +1397,7 @@ function rejected(error) {
1254
1397
  return messages.length ? new ApiError(messages.join("; "), error.status, "QUERY_REJECTED", error.details) : error;
1255
1398
  }
1256
1399
  async function queries2(transport, qs, options = {}) {
1257
- for (const q of qs) if (!q.page) throw new Error(`query of ${q.from} without a page: every query.v1 read must be bounded`);
1400
+ 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`);
1258
1401
  if (!qs.length) return [];
1259
1402
  let response;
1260
1403
  try {
@@ -1315,8 +1458,8 @@ function fromAssetDocument(doc, { history: history2 = false } = {}) {
1315
1458
  }
1316
1459
  function toQuery(params) {
1317
1460
  return {
1318
- sel: params.sel?.join(","),
1319
- where: params.where?.length ? params.where.join(";") : void 0,
1461
+ sel: (params.select ?? params.sel)?.join(","),
1462
+ where: (params.filter ?? params.where)?.length ? (params.filter ?? params.where).join(";") : void 0,
1320
1463
  with: params.with ? Object.entries(params.with).map(([k, v]) => `${k}=${[].concat(v).join(",")}`).join("|") : void 0,
1321
1464
  find: params.find,
1322
1465
  findin: params.findin?.join(","),
@@ -1328,14 +1471,14 @@ function toQuery(params) {
1328
1471
  };
1329
1472
  }
1330
1473
  async function read(transport, path, params, history2) {
1331
- if (!params.page) throw new Error(`rest-1 read of ${path} without a page: every read must be bounded`);
1474
+ if (!params.page) throw new UsageError(`rest-1 read of ${path} without a page: every read must be bounded`);
1332
1475
  const doc = await transport.request({ method: "GET", path, query: toQuery(params), responseType: "json" });
1333
1476
  return { rows: (doc.Assets ?? []).map((a) => fromAssetDocument(a, { history: history2 })), total: typeof doc.total === "number" && doc.total >= 0 ? doc.total : null, pageSize: doc.pageSize, pageStart: doc.pageStart };
1334
1477
  }
1335
1478
  var list2 = (transport, type, params) => read(transport, `/rest-1.v1/Data/${encodeURIComponent(type)}`, params, false);
1336
- async function get2(transport, oid, sel) {
1479
+ async function get2(transport, oid, select) {
1337
1480
  const [type, id] = momentless(oid).split(":");
1338
- const doc = await transport.request({ method: "GET", path: `/rest-1.v1/Data/${encodeURIComponent(type)}/${encodeURIComponent(id)}`, query: { sel: sel?.join(",") }, responseType: "json" });
1481
+ const doc = await transport.request({ method: "GET", path: `/rest-1.v1/Data/${encodeURIComponent(type)}/${encodeURIComponent(id)}`, query: { sel: select?.join(",") }, responseType: "json" });
1339
1482
  return fromAssetDocument(doc);
1340
1483
  }
1341
1484
  var history = (transport, target, params) => {
@@ -1345,6 +1488,8 @@ var history = (transport, target, params) => {
1345
1488
  };
1346
1489
 
1347
1490
  // src/client.ts
1491
+ var DEFAULT_LIMIT = 1e3;
1492
+ var ME_SELECT = ["Name", "Username", "Nickname", "Email"];
1348
1493
  var AgilityApiClient = class _AgilityApiClient {
1349
1494
  transport;
1350
1495
  schema;
@@ -1376,6 +1521,48 @@ var AgilityApiClient = class _AgilityApiClient {
1376
1521
  const transport = { request: (options) => base.request({ ...options, signal: options.signal ?? signal }) };
1377
1522
  return new _AgilityApiClient(this.config, { transport, schema: this.schema });
1378
1523
  }
1524
+ async query(q) {
1525
+ await this.schema.ensureFor([q]);
1526
+ return query2(this.transport, q, { lookup: this.schema });
1527
+ }
1528
+ async queries(qs) {
1529
+ await this.schema.ensureFor(qs);
1530
+ return queries2(this.transport, [...qs], { lookup: this.schema });
1531
+ }
1532
+ async get(oid, select) {
1533
+ const rows = await this.query({ from: momentless(oid), ...select ? { select } : {}, page: { size: 1, start: 0 } });
1534
+ return rows[0] ?? null;
1535
+ }
1536
+ async queryPage(q) {
1537
+ await this.schema.ensureFor([q]);
1538
+ return queryPage(this.transport, q, { lookup: this.schema });
1539
+ }
1540
+ async *pages(q, options = {}) {
1541
+ if (!q.page) throw new UsageError(`query of ${q.from} without a page: every read must be bounded`);
1542
+ const limit = options.limit ?? DEFAULT_LIMIT;
1543
+ const size = q.page.size;
1544
+ let start = q.page.start, read2 = 0;
1545
+ while (read2 < limit) {
1546
+ const want = Math.min(size, limit - read2);
1547
+ const rows = await this.query({ ...q, page: { size: want, start } });
1548
+ if (rows.length) yield rows;
1549
+ read2 += rows.length;
1550
+ start += rows.length;
1551
+ if (rows.length < want) return;
1552
+ }
1553
+ }
1554
+ async all(q, options = {}) {
1555
+ const out = [];
1556
+ for await (const page2 of this.pages(q, options)) out.push(...page2);
1557
+ return out;
1558
+ }
1559
+ /** The member these credentials belong to (Member.IsSelf, live 2026-11-12): what a comment's Author is. */
1560
+ async me() {
1561
+ const [row] = await this.query({ from: "Member", select: ME_SELECT, filter: ["IsSelf='true'"], page: { size: 1, start: 0 } });
1562
+ if (!row) throw new ApiError("the server knows no member for these credentials", 0, "NOT_FOUND");
1563
+ return row;
1564
+ }
1565
+ // ---- the endpoints behind the reads -----------------------------------------------
1379
1566
  /** meta.v1: the instance's schema, raw. (The translated, cached view is `schema`.) */
1380
1567
  meta = {
1381
1568
  metamodel: () => getMetamodel(this.transport),
@@ -1384,12 +1571,65 @@ var AgilityApiClient = class _AgilityApiClient {
1384
1571
  };
1385
1572
  /**
1386
1573
  * loc-2.v1: labels for tags, in one request (meta's DisplayName values are
1387
- * tags). An unmatched tag comes back as itself; the server never says
1388
- * "missing", so neither does this.
1574
+ * tags, and so is a violation's `tag`). An unmatched tag comes back as
1575
+ * itself; the server never says "missing", so neither does this.
1389
1576
  */
1390
1577
  localize(tags, options) {
1391
1578
  return localize(this.transport, tags, options);
1392
1579
  }
1580
+ /**
1581
+ * rest-1.v1, for what query.v1 cannot do: deleted assets (`deleted:
1582
+ * true`), one asset by OID without a page, and history (one row per
1583
+ * moment). Parameters are spelled as a query's (`select`, `filter`).
1584
+ */
1585
+ data = {
1586
+ list: (type, params) => list2(this.transport, type, params),
1587
+ get: (oid, select) => get2(this.transport, oid, select),
1588
+ history: (target, params) => history(this.transport, target, params)
1589
+ };
1590
+ /** @deprecated since 1.0.0-alpha.3: `client.query` / `client.queries` read through query.v1. Removed in 1.0.0. */
1591
+ queryV1 = {
1592
+ query: (q) => this.query(q),
1593
+ queries: (qs) => this.queries(qs)
1594
+ };
1595
+ // ---- writes -----------------------------------------------------------------------
1596
+ /** api/asset: batches, answered in the model's terms. */
1597
+ assets = {
1598
+ /**
1599
+ * Send a batch. The Outcome says what it did — created, modified and
1600
+ * operated OIDs, failures by index (each with its parsed violation),
1601
+ * query rows per query command — and never the envelope. api/asset
1602
+ * commits per command and answers 200 either way, so a failed command
1603
+ * is in `failures`, or thrown as a CommandFailureError with
1604
+ * `throwOnFailure` (the error carries the same Outcome).
1605
+ */
1606
+ send: async (commands, options) => {
1607
+ await this.schema.ensureFor(commands);
1608
+ return outcomeOf(await send(this.transport, commands, { ...options, lookup: this.schema }));
1609
+ },
1610
+ /** The server's projection of a batch: one command per asset it would touch, nothing applied. */
1611
+ preview: (commands) => preview(this.transport, commands),
1612
+ /**
1613
+ * Send as a compare-and-set: each asset is changed only if nobody has
1614
+ * changed it since the preview read it; the rest come back `skipped`.
1615
+ * Throws UnguardableError, before any request, for shapes it cannot
1616
+ * cover.
1617
+ */
1618
+ sendGuarded: async (commands, options) => {
1619
+ await this.schema.ensureFor(commands);
1620
+ return sendGuarded(this.transport, commands, { ...options, lookup: this.schema });
1621
+ },
1622
+ /** @deprecated since 1.0.0-alpha.3: use `client.query` (or `client.queryPage` for a total). Removed in 1.0.0. */
1623
+ query: async (q) => {
1624
+ await this.schema.ensureFor([q]);
1625
+ return query(this.transport, q, { lookup: this.schema });
1626
+ },
1627
+ /** @deprecated since 1.0.0-alpha.3: use `client.queries`. Removed in 1.0.0. */
1628
+ queries: async (qs) => {
1629
+ await this.schema.ensureFor(qs);
1630
+ return queries(this.transport, qs, { lookup: this.schema });
1631
+ }
1632
+ };
1393
1633
  /**
1394
1634
  * Services: what a person types, turned into assets. Numbers resolve in
1395
1635
  * one api/asset request across every number root; an OID token is taken
@@ -1412,7 +1652,7 @@ var AgilityApiClient = class _AgilityApiClient {
1412
1652
  /**
1413
1653
  * The files attached to assets: metadata on Attachment assets, bytes
1414
1654
  * through attachment.img. `add` sends a small file inline (one request)
1415
- * and a large one in two steps; `url` is where a browser opens one.
1655
+ * and a large one in two steps.
1416
1656
  */
1417
1657
  attachments = {
1418
1658
  list: (asset, options) => list(this.transport, asset, options),
@@ -1422,7 +1662,8 @@ var AgilityApiClient = class _AgilityApiClient {
1422
1662
  add: (asset, data, options) => add(this.transport, asset, data, options),
1423
1663
  put: (id, data, contentType) => put(this.transport, id, data, contentType),
1424
1664
  remove: (id) => remove(this.transport, id),
1425
- url: (id) => attachmentUrl(this.config.baseUrl, id)
1665
+ /** @deprecated since 1.0.0-alpha.3: `client.urls.attachment`. Removed in 1.0.0. */
1666
+ url: (id) => this.urls.attachment(id)
1426
1667
  };
1427
1668
  /**
1428
1669
  * Images embedded in an asset's rich text (Description and other long
@@ -1432,55 +1673,16 @@ var AgilityApiClient = class _AgilityApiClient {
1432
1673
  */
1433
1674
  embeddedImages = {
1434
1675
  upload: (asset, data, options) => uploadEmbeddedImage(this.transport, asset, data, options),
1435
- url: (id) => embeddedImageUrl(this.config.baseUrl, id),
1676
+ /** @deprecated since 1.0.0-alpha.3: `client.urls.embeddedImage`. Removed in 1.0.0. */
1677
+ url: (id) => this.urls.embeddedImage(id),
1436
1678
  normalize: (html) => normalizeEmbeddedImageUrls(html, this.config.baseUrl)
1437
1679
  };
1438
- /** rest-1.v1: bounded reads, one asset, history. */
1439
- data = {
1440
- list: (type, params) => list2(this.transport, type, params),
1441
- get: (oid, sel) => get2(this.transport, oid, sel),
1442
- history: (target, params) => history(this.transport, target, params)
1443
- };
1444
- /**
1445
- * query.v1: reads only, the same query bodies as assets.query, rows
1446
- * translated the same way. The endpoint cannot write, so it is the safe
1447
- * place to explore a query; its requests may be retried like a GET.
1448
- */
1449
- queryV1 = {
1450
- query: async (q) => {
1451
- await this.schema.ensureFor([q]);
1452
- return query2(this.transport, q, { lookup: this.schema });
1453
- },
1454
- queries: async (qs) => {
1455
- await this.schema.ensureFor(qs);
1456
- return queries2(this.transport, qs, { lookup: this.schema });
1457
- }
1458
- };
1459
- /** api/asset: batches and queries, rows translated through the schema. */
1460
- assets = {
1461
- send: async (commands, options) => {
1462
- await this.schema.ensureFor(commands);
1463
- return send(this.transport, commands, { ...options, lookup: this.schema });
1464
- },
1465
- preview: (commands) => preview(this.transport, commands),
1466
- /**
1467
- * Send as a compare-and-set: each asset is changed only if nobody has
1468
- * changed it since the preview read it; the rest come back `skipped`.
1469
- * Throws UnguardableError, before any request, for shapes it cannot
1470
- * cover.
1471
- */
1472
- sendGuarded: async (commands, options) => {
1473
- await this.schema.ensureFor(commands);
1474
- return sendGuarded(this.transport, commands, { ...options, lookup: this.schema });
1475
- },
1476
- query: async (q) => {
1477
- await this.schema.ensureFor([q]);
1478
- return query(this.transport, q, { lookup: this.schema });
1479
- },
1480
- queries: async (qs) => {
1481
- await this.schema.ensureFor(qs);
1482
- return queries(this.transport, qs, { lookup: this.schema });
1483
- }
1680
+ /** Where a browser opens things on this instance: an asset's page, an attachment's bytes, an embedded image. */
1681
+ urls = {
1682
+ /** The asset's page: assetdetail.v1 redirects to the type's own detail page (live 2026-11-12). */
1683
+ asset: (oid) => `${this.config.baseUrl.replace(/\/+$/, "")}/assetdetail.v1?oid=${momentless(oid)}`,
1684
+ attachment: (id) => attachmentUrl(this.config.baseUrl, id),
1685
+ embeddedImage: (id) => embeddedImageUrl(this.config.baseUrl, id)
1484
1686
  };
1485
1687
  };
1486
1688
  export {
@@ -1499,6 +1701,8 @@ export {
1499
1701
  TypeCache,
1500
1702
  UNGUARDABLE_TYPES,
1501
1703
  UnguardableError,
1704
+ UsageError,
1705
+ WrappedError,
1502
1706
  assetApi_exports as assetApi,
1503
1707
  assetStateOf,
1504
1708
  attachment_exports as attachment,
@@ -1507,11 +1711,13 @@ export {
1507
1711
  guard,
1508
1712
  isAssetNumber,
1509
1713
  isOidToken,
1714
+ kindOf,
1510
1715
  loc2_exports as loc2,
1511
1716
  meta_exports as meta,
1512
1717
  momentless,
1513
1718
  namesOf,
1514
1719
  parseOid,
1720
+ parseViolation,
1515
1721
  queryV1_exports as queryV1,
1516
1722
  resolveNumbers,
1517
1723
  resolveRef,