@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.cjs CHANGED
@@ -35,6 +35,8 @@ __export(index_exports, {
35
35
  TypeCache: () => TypeCache,
36
36
  UNGUARDABLE_TYPES: () => UNGUARDABLE_TYPES,
37
37
  UnguardableError: () => UnguardableError,
38
+ UsageError: () => UsageError,
39
+ WrappedError: () => WrappedError,
38
40
  assetApi: () => assetApi_exports,
39
41
  assetStateOf: () => assetStateOf,
40
42
  attachment: () => attachment_exports,
@@ -43,11 +45,13 @@ __export(index_exports, {
43
45
  guard: () => guard,
44
46
  isAssetNumber: () => isAssetNumber,
45
47
  isOidToken: () => isOidToken,
48
+ kindOf: () => kindOf,
46
49
  loc2: () => loc2_exports,
47
50
  meta: () => meta_exports,
48
51
  momentless: () => momentless,
49
52
  namesOf: () => namesOf,
50
53
  parseOid: () => parseOid,
54
+ parseViolation: () => parseViolation,
51
55
  queryV1: () => queryV1_exports,
52
56
  resolveNumbers: () => resolveNumbers,
53
57
  resolveRef: () => resolveRef,
@@ -67,16 +71,61 @@ __export(index_exports, {
67
71
  module.exports = __toCommonJS(index_exports);
68
72
 
69
73
  // src/core/errors.ts
74
+ function kindOf(status, code) {
75
+ switch (code) {
76
+ case "ABORTED":
77
+ return "aborted";
78
+ case "TIMEOUT":
79
+ case "NETWORK_ERROR":
80
+ return "transient";
81
+ case "BAD_JSON":
82
+ case "BAD_RESPONSE":
83
+ return "protocol";
84
+ case "USAGE":
85
+ return "usage";
86
+ case "CommandFailure":
87
+ return "commandFailure";
88
+ case "QUERY_REJECTED":
89
+ case "TOO_LARGE":
90
+ return "invalid";
91
+ case "NOT_FOUND":
92
+ return "notFound";
93
+ }
94
+ if (status === 401) return "auth";
95
+ if (status === 403) return "forbidden";
96
+ if (status === 404) return "notFound";
97
+ if (status === 408 || status === 429 || status >= 500) return "transient";
98
+ if (status >= 400) return "invalid";
99
+ return "unknown";
100
+ }
101
+ var causeOf = (details) => details && typeof details === "object" && "cause" in details ? details.cause : void 0;
70
102
  var ApiError = class extends Error {
103
+ /** The HTTP status; 0 when no answer came back (network, timeout, abort, usage). */
71
104
  status;
72
105
  code;
73
106
  details;
74
- constructor(message, status, code, details) {
75
- super(message);
107
+ kind;
108
+ constructor(message, status, code, details, kind = kindOf(status, code)) {
109
+ const cause = causeOf(details);
110
+ super(message, cause === void 0 ? void 0 : { cause });
76
111
  this.name = "ApiError";
77
112
  this.status = status;
78
113
  this.code = code;
79
114
  this.details = details;
115
+ this.kind = kind;
116
+ }
117
+ };
118
+ var UsageError = class extends ApiError {
119
+ constructor(message, details) {
120
+ super(message, 0, "USAGE", details, "usage");
121
+ this.name = "UsageError";
122
+ }
123
+ };
124
+ var WrappedError = class extends ApiError {
125
+ constructor(message, code, cause, details) {
126
+ const inner = cause instanceof ApiError ? cause : void 0;
127
+ super(message, inner?.status ?? 0, code, { ...details, cause }, inner?.kind ?? "unknown");
128
+ this.name = "WrappedError";
80
129
  }
81
130
  };
82
131
 
@@ -216,7 +265,7 @@ function isOidToken(value) {
216
265
  }
217
266
  function parseOid(token) {
218
267
  const m = OID_TOKEN.exec(token);
219
- if (!m) throw new Error(`"${token}" is not an OID token (Story:1234)`);
268
+ if (!m) throw new UsageError(`"${token}" is not an OID token (Story:1234)`);
220
269
  return { type: m[1], id: Number(m[2]), moment: m[3] === void 0 ? null : Number(m[3]) };
221
270
  }
222
271
  function momentless(token) {
@@ -240,6 +289,7 @@ __export(assetApi_exports, {
240
289
  mergeOutcome: () => mergeOutcome,
241
290
  momentless: () => momentless,
242
291
  outcomeOf: () => outcomeOf,
292
+ parseViolation: () => parseViolation,
243
293
  preview: () => preview,
244
294
  queries: () => queries,
245
295
  query: () => query,
@@ -405,11 +455,20 @@ function toNumber(value) {
405
455
  const n = typeof value === "string" && value.trim() !== "" ? Number(value) : NaN;
406
456
  return Number.isNaN(n) ? value : n;
407
457
  }
458
+ function parseViolation(message) {
459
+ const parts = message.trim().split("'");
460
+ if (parts[0] !== "Violation" || !parts[1]) return null;
461
+ const rule = parts[1], rest = parts.slice(2).filter((p) => p !== "");
462
+ if (rest[0] === "AttributeDefinition" && rest[1] && rest[2]) return { rule, attribute: `${rest[2]}.${rest[1]}`, tag: rest.join("'") };
463
+ 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;
464
+ return { rule, attribute: token, tag: rest.length ? rest.join("'") : null };
465
+ }
408
466
  function failuresOf(response) {
409
467
  return (response.commandFailures?.commands ?? []).map(({ error, ...echoed }) => ({
410
468
  index: typeof error === "object" && error && typeof error.sourceCommandIndex === "number" ? error.sourceCommandIndex : null,
411
469
  message: typeof error === "string" ? error : error?.message || "command failed",
412
- echoed
470
+ echoed,
471
+ violation: parseViolation(typeof error === "string" ? error : error?.message || "")
413
472
  }));
414
473
  }
415
474
  function outcomeOf(response) {
@@ -435,11 +494,14 @@ function mergeOutcome(into, done, offset) {
435
494
  var CommandFailureError = class extends ApiError {
436
495
  failures;
437
496
  response;
497
+ /** What the batch did, in the model's terms: the commands that succeeded are committed. */
498
+ outcome;
438
499
  constructor(response, failures = failuresOf(response)) {
439
500
  super(failures.map((f) => f.message).join("; ") || "command failed", 200, "CommandFailure", failures);
440
501
  this.name = "CommandFailureError";
441
502
  this.failures = failures;
442
503
  this.response = response;
504
+ this.outcome = outcomeOf(response);
443
505
  }
444
506
  };
445
507
  var bodyOf = (c) => {
@@ -475,7 +537,7 @@ async function send(transport, commands, options = {}) {
475
537
  const results = response.queryResult?.results ?? [];
476
538
  let aligned = realign(queries3, results, failed);
477
539
  if (!aligned) {
478
- 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`);
540
+ 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);
479
541
  aligned = queries3.map(() => null);
480
542
  }
481
543
  const contextOf = (i) => {
@@ -497,7 +559,7 @@ async function query(transport, q, options = {}) {
497
559
  return rows ?? [];
498
560
  }
499
561
  async function queryPage(transport, q, options = {}) {
500
- if (!q.page) throw new Error(`query of ${q.from} without a page: every api/asset read must be bounded`);
562
+ if (!q.page) throw new UsageError(`query of ${q.from} without a page: every api/asset read must be bounded`);
501
563
  const response = await send(transport, [{ ...q, needTotal: true }], { ...options, throwOnFailure: true });
502
564
  const rows = response.queryResult.results[0] ?? [];
503
565
  const reported = response.queryResult.resultMetaData?.[0]?.total;
@@ -505,7 +567,7 @@ async function queryPage(transport, q, options = {}) {
505
567
  return { rows, total, more: total === null ? rows.length === q.page.size : q.page.start + rows.length < total };
506
568
  }
507
569
  async function queries(transport, qs, options = {}) {
508
- 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`);
570
+ 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`);
509
571
  const response = await send(transport, qs, { ...options, throwOnFailure: true });
510
572
  return qs.map((_, i) => response.queryResult.results[i] ?? []);
511
573
  }
@@ -543,7 +605,7 @@ function fromBase64(text) {
543
605
  }
544
606
  function numericId(spec) {
545
607
  const m = /^(?:[A-Za-z]+:)?(\d+)(?::\d+)?$/.exec(String(spec).trim());
546
- if (!m) throw new TypeError(`"${spec}" is not an asset id (5678, or an OID token such as Attachment:5678)`);
608
+ if (!m) throw new UsageError(`"${spec}" is not an asset id (5678, or an OID token such as Attachment:5678)`);
547
609
  return m[1];
548
610
  }
549
611
  var join = (baseUrl, path) => `${baseUrl.replace(/\/+$/, "")}/${path}`;
@@ -639,9 +701,9 @@ async function get(transport, id) {
639
701
  const file = await getFile(transport, meta.id);
640
702
  return { ...meta, bytes: file.bytes, contentType: file.contentType ?? meta.contentType, recordedContentType: meta.contentType };
641
703
  }
642
- var AttachmentUploadError = class extends Error {
704
+ var AttachmentUploadError = class extends WrappedError {
643
705
  constructor(attachment, cause) {
644
- super(`${attachment.oid} was created on its asset with no content, and uploading the file failed: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
706
+ 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);
645
707
  this.attachment = attachment;
646
708
  this.name = "AttachmentUploadError";
647
709
  }
@@ -674,7 +736,7 @@ async function remove(transport, id) {
674
736
  // src/services/guard.ts
675
737
  var CHUNK = 100;
676
738
  var UNGUARDABLE_TYPES = ["Idea", "IdeaChangeSet", "IdeaStatus", "IdeaComment", "IdeaCategory", "IdeaForum", "IdeaUser"];
677
- var UnguardableError = class extends Error {
739
+ var UnguardableError = class extends UsageError {
678
740
  reasons;
679
741
  constructor(reasons) {
680
742
  super(`cannot guard this batch: ${reasons.map((r) => `command ${r.index}: ${r.reason}`).join("; ")}`);
@@ -740,7 +802,7 @@ function guard(projected, stamps) {
740
802
  unstamped.push(x.oid);
741
803
  continue;
742
804
  }
743
- if (!STAMP.test(stamp)) throw new Error(`${x.oid}: ChangeDate ${JSON.stringify(stamp)} is not a timestamp; refusing to put it in a filter`);
805
+ 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 });
744
806
  const scope = { from: typeOfOid(x.oid), filter: ["ID=$oid", `ChangeDate='${stamp}'`], with: { $oid: x.oid } };
745
807
  commands.push(x.update ? { ...scope, update: x.update } : { ...scope, execute: x.execute });
746
808
  }
@@ -757,16 +819,14 @@ function passesOf(projected) {
757
819
  }
758
820
  return passes;
759
821
  }
760
- var GuardedPassError = class extends Error {
822
+ var GuardedPassError = class extends WrappedError {
761
823
  constructor(cause, outcome, sent, pass) {
762
- super(`guarded send failed in pass ${pass + 1}${pass ? " (earlier passes applied)" : ""}: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
763
- this.cause = cause;
824
+ super(`guarded send failed in pass ${pass + 1}${pass ? " (earlier passes applied)" : ""}: ${cause instanceof Error ? cause.message : String(cause)}`, "GUARDED_PASS", cause);
764
825
  this.outcome = outcome;
765
826
  this.sent = sent;
766
827
  this.pass = pass;
767
828
  this.name = "GuardedPassError";
768
829
  }
769
- cause;
770
830
  outcome;
771
831
  sent;
772
832
  pass;
@@ -889,9 +949,46 @@ async function snapshot(transport, described) {
889
949
  }
890
950
  return before;
891
951
  }
892
- var INVERSE = { Inactivate: "Reactivate", Reactivate: "Inactivate", Delete: "Undelete", Undelete: "Delete" };
893
- function reverseOf(described, before, done) {
952
+ var INVERSE = { Inactivate: "Reactivate", QuickClose: "Reactivate", Reactivate: "Inactivate", Delete: "Undelete", Undelete: "Delete" };
953
+ var CASCADING = /* @__PURE__ */ new Set(["Inactivate", "QuickClose"]);
954
+ var CASCADE_DEPTH = 8;
955
+ async function openDescendantsOf(transport, schema, roots) {
956
+ const out = {};
957
+ const rootOf = new Map(roots.map((r) => [momentless(r), momentless(r)]));
958
+ const seen = new Set(rootOf.keys());
959
+ let frontier = [...rootOf.keys()];
960
+ for (let depth = 0; depth < CASCADE_DEPTH && frontier.length; depth += 1) {
961
+ const children = /* @__PURE__ */ new Map();
962
+ for (const [type, list3] of byType(frontier)) {
963
+ await schema.load(type);
964
+ if (!schema.attribute(type, "Children")) continue;
965
+ for (const part of chunks(list3)) {
966
+ const rows = await query(transport, { from: type, select: ["Children"], filter: ["ID=$oids"], with: { $oids: part }, page: { size: part.length, start: 0 } });
967
+ for (const r of rows) {
968
+ const parent = momentless(r._oid);
969
+ for (const c of [].concat(plain(r.Children) ?? [])) {
970
+ const child = momentless(String(c));
971
+ if (seen.has(child)) continue;
972
+ seen.add(child);
973
+ children.set(child, rootOf.get(parent));
974
+ }
975
+ }
976
+ }
977
+ }
978
+ const states = children.size ? await statesOf(transport, [...children.keys()]) : {};
979
+ frontier = [];
980
+ for (const [child, root] of children) {
981
+ if (states[child] === void 0 || isClosed(states[child])) continue;
982
+ rootOf.set(child, root);
983
+ (out[root] ??= []).push(child);
984
+ frontier.push(child);
985
+ }
986
+ }
987
+ return out;
988
+ }
989
+ function reverseOf(described, before, done, context = {}) {
894
990
  const commands = [], notes = [];
991
+ const cannot = (oid, op) => context.canOperate?.(typeOfOid(oid), op) === false;
895
992
  const modified = new Set(done.modified.map(momentless)), operated = new Set(done.operated.map(momentless));
896
993
  for (const u of described.updates) {
897
994
  if (!modified.has(u.oid)) continue;
@@ -916,26 +1013,48 @@ function reverseOf(described, before, done) {
916
1013
  for (const e of described.executes) {
917
1014
  if (!operated.has(e.oid)) continue;
918
1015
  const inverse = INVERSE[e.op];
919
- if (inverse) commands.push({ from: e.oid, execute: inverse });
920
- else notes.push(`${e.oid}: ${e.op} has no known inverse (was ${String(before[e.oid]?.AssetState ?? "?")})`);
1016
+ if (!inverse) {
1017
+ notes.push(`${e.oid}: ${e.op} has no known inverse (was ${String(before[e.oid]?.AssetState ?? "?")})`);
1018
+ continue;
1019
+ }
1020
+ if (cannot(e.oid, inverse)) {
1021
+ notes.push(`${e.oid}: ${typeOfOid(e.oid)} has no ${inverse} operation, so its ${e.op} is not reversed`);
1022
+ continue;
1023
+ }
1024
+ commands.push({ from: e.oid, execute: inverse });
1025
+ if (!CASCADING.has(e.op)) continue;
1026
+ const open = context.openDescendants?.[e.oid] ?? [];
1027
+ const closed = context.cascaded ? context.cascaded[e.oid] ?? [] : void 0;
1028
+ if (closed === void 0) {
1029
+ 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`);
1030
+ continue;
1031
+ }
1032
+ for (const d of closed) {
1033
+ if (cannot(d, "Reactivate")) notes.push(`${d}: closed with ${e.oid}, but ${typeOfOid(d)} has no Reactivate operation`);
1034
+ else commands.push({ from: d, execute: "Reactivate" });
1035
+ }
1036
+ if (closed.length) notes.push(`${e.oid}: ${e.op} also closed ${closed.join(", ")}; reopened after it`);
1037
+ }
1038
+ for (const oid of [...done.created].reverse().map(momentless)) {
1039
+ 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)" : ""}`);
1040
+ else commands.push({ from: oid, execute: "Delete" });
921
1041
  }
922
- for (const oid of [...done.created].reverse()) commands.push({ from: momentless(oid), execute: "Delete" });
923
1042
  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`);
924
1043
  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");
925
1044
  return { commands, notes };
926
1045
  }
927
- var BatchRecordError = class extends Error {
1046
+ var BatchRecordError = class extends WrappedError {
928
1047
  receipt;
929
1048
  constructor(cause, receipt) {
930
- 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 });
1049
+ 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);
931
1050
  this.name = "BatchRecordError";
932
1051
  this.receipt = receipt;
933
1052
  }
934
1053
  };
935
- var BatchSendError = class extends Error {
1054
+ var BatchSendError = class extends WrappedError {
936
1055
  receipt;
937
1056
  constructor(cause, receipt) {
938
- super(`the batch send failed; whether the server applied it is unknown: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
1057
+ super(`the batch send failed; whether the server applied it is unknown: ${cause instanceof Error ? cause.message : String(cause)}`, "BATCH_SEND", cause);
939
1058
  this.name = "BatchSendError";
940
1059
  this.receipt = receipt;
941
1060
  }
@@ -943,7 +1062,7 @@ var BatchSendError = class extends Error {
943
1062
  var now = () => (/* @__PURE__ */ new Date()).toISOString();
944
1063
  async function runBatch(transport, schema, commands, options = {}) {
945
1064
  const useGuard = options.guard ?? true;
946
- if (!commands.length) throw new Error("the batch is empty");
1065
+ if (!commands.length) throw new UsageError("the batch is empty");
947
1066
  const refused = unguardable(commands);
948
1067
  const fatal = refused.filter((r) => r.reason.includes("rejected by the server"));
949
1068
  if (fatal.length || useGuard && refused.length) throw new UnguardableError(useGuard ? refused : fatal);
@@ -954,12 +1073,14 @@ async function runBatch(transport, schema, commands, options = {}) {
954
1073
  const described = describe(commands, projected);
955
1074
  const touched = touchedBy(projected);
956
1075
  const names = await namesOf(transport, schema, touched, noteFailure);
957
- 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 };
1076
+ 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 };
958
1077
  const nothing = !described.creates.length && !described.updates.length && !described.executes.length && !described.queries.length && !described.deferred.length;
959
1078
  if (nothing) return receipt;
960
1079
  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`);
961
1080
  receipt.snapshotAt = now();
962
1081
  receipt.before = await snapshot(transport, described);
1082
+ const closing = described.executes.filter((e) => CASCADING.has(e.op)).map((e) => e.oid);
1083
+ if (closing.length) receipt.openDescendants = await openDescendantsOf(transport, schema, closing);
963
1084
  if (options.dryRun) return receipt;
964
1085
  if (options.confirm && !await options.confirm(receipt)) return receipt;
965
1086
  let unstamped = [];
@@ -991,13 +1112,29 @@ async function runBatch(transport, schema, commands, options = {}) {
991
1112
  const unnamed = { number: null, name: null };
992
1113
  receipt.created = done.created.map((oid, i) => ({ alias: described.creates[i]?.alias ?? null, type: described.creates[i]?.type ?? typeOfOid(oid), oid, ...unnamed }));
993
1114
  receipt.touched = hit.map((oid) => ({ oid, ...names.get(oid) ?? unnamed }));
994
- receipt.skipped = useGuard ? [.../* @__PURE__ */ new Set([...unstamped, ...touched.filter((oid) => !hit.includes(oid))])] : [];
995
- receipt.reverse = reverseOf(described, receipt.before, done);
1115
+ const sentAs = receipt.sentCommands ?? [];
1116
+ const oidOfSent = (i) => {
1117
+ const body = i === null ? void 0 : sentAs[i] ? bodyOf3(sentAs[i]) : void 0;
1118
+ const id = body?.with?.$oid ?? body?.from;
1119
+ return typeof id === "string" && id.includes(":") ? momentless(id) : null;
1120
+ };
1121
+ receipt.failed = [...new Set(done.failures.map((f) => oidOfSent(f.index)).filter((oid) => !!oid && touched.includes(oid) && !hit.includes(oid)))];
1122
+ receipt.skipped = useGuard ? [.../* @__PURE__ */ new Set([...unstamped, ...touched.filter((oid) => !hit.includes(oid) && !receipt.failed.includes(oid))])] : [];
1123
+ for (const type of new Set([...done.created, ...hit].map((oid) => typeOfOid(momentless(oid))))) await schema.load(type).catch(() => null);
1124
+ const canOperate = (type, op) => schema.canOperate(type, op);
1125
+ const operatedNow = new Set(done.operated.map(momentless));
1126
+ const closed = Object.keys(receipt.openDescendants).filter((oid) => operatedNow.has(oid) && receipt.openDescendants[oid].length);
1127
+ receipt.reverse = reverseOf(described, receipt.before, done, { openDescendants: receipt.openDescendants, cascaded: closed.length ? void 0 : {}, canOperate });
996
1128
  try {
1129
+ if (closed.length) {
1130
+ const states = await statesOf(transport, closed.flatMap((oid) => receipt.openDescendants[oid]));
1131
+ receipt.cascaded = Object.fromEntries(closed.map((oid) => [oid, receipt.openDescendants[oid].filter((d) => isClosed(states[d]))]));
1132
+ receipt.reverse = reverseOf(described, receipt.before, done, { openDescendants: receipt.openDescendants, cascaded: receipt.cascaded, canOperate });
1133
+ }
997
1134
  const more = await namesOf(transport, schema, [...done.created, ...hit.filter((oid) => !names.has(oid))], noteFailure);
998
1135
  receipt.created = receipt.created.map((c) => ({ ...c, ...more.get(c.oid) ?? unnamed }));
999
1136
  receipt.touched = receipt.touched.map((t) => names.has(t.oid) ? t : { ...t, ...more.get(t.oid) ?? unnamed });
1000
- const all = [...done.created, ...hit];
1137
+ const all = [.../* @__PURE__ */ new Set([...done.created, ...hit, ...Object.values(receipt.cascaded).flat()])];
1001
1138
  receipt.after = all.length ? await stampsOf(transport, all) : {};
1002
1139
  } catch (error) {
1003
1140
  throw new BatchRecordError(error, receipt);
@@ -1096,7 +1233,7 @@ async function resolveNumbers(transport, numbers, options = {}) {
1096
1233
  for (const n of numbers) if (!firsts.has(n.trim().toUpperCase())) firsts.set(n.trim().toUpperCase(), n.trim());
1097
1234
  const asked = [...firsts.values()];
1098
1235
  const invalid = asked.filter((n) => !isAssetNumber(n));
1099
- if (invalid.length) throw new TypeError(`not asset numbers (expected a prefix and digits, S-1234): ${invalid.join(", ")}`);
1236
+ if (invalid.length) throw new UsageError(`not asset numbers (expected a prefix and digits, S-1234): ${invalid.join(", ")}`);
1100
1237
  const byNumber = /* @__PURE__ */ new Map();
1101
1238
  for (let i = 0; i < asked.length; i += CHUNK3) {
1102
1239
  for (const r of await askRoots(transport, asked.slice(i, i + CHUNK3), options.types)) {
@@ -1117,7 +1254,7 @@ async function resolveRef(transport, spec, options = {}) {
1117
1254
  return { number: null, oid, type: typeOfOid(oid), name: null, assetState: null };
1118
1255
  }
1119
1256
  if (isAssetNumber(text)) return (await resolveNumbers(transport, [text], options)).found[0] ?? null;
1120
- throw new TypeError(`"${spec}" is neither an asset number (S-1234) nor an OID token (Story:1234)`);
1257
+ throw new UsageError(`"${spec}" is neither an asset number (S-1234) nor an OID token (Story:1234)`);
1121
1258
  }
1122
1259
 
1123
1260
  // src/wire/meta.ts
@@ -1161,7 +1298,8 @@ function toAssetType(doc) {
1161
1298
  ...doc.DefaultOrderBy ? { DefaultOrderBy: doc.DefaultOrderBy.tokenref } : {},
1162
1299
  ...doc.DefaultDisplayBy ? { DefaultDisplayBy: doc.DefaultDisplayBy.tokenref } : {},
1163
1300
  ...doc.ShortName ? { ShortName: doc.ShortName.tokenref } : {},
1164
- Attributes: Object.fromEntries(Object.entries(doc.Attributes ?? {}).map(([key, value]) => [key, toAttributeDefinition(value)]))
1301
+ Attributes: Object.fromEntries(Object.entries(doc.Attributes ?? {}).map(([key, value]) => [key, toAttributeDefinition(value)])),
1302
+ ...doc.Operations ? { Operations: Object.keys(doc.Operations).map((token) => token.slice(token.indexOf(".") + 1)) } : {}
1165
1303
  };
1166
1304
  }
1167
1305
  function toMetamodel(doc) {
@@ -1178,7 +1316,7 @@ async function getAssetType(transport, token) {
1178
1316
  }
1179
1317
  async function getAttributeDefinition(transport, tokenref) {
1180
1318
  const [type, attribute] = tokenref.split(".");
1181
- if (!type || !attribute) throw new Error(`"${tokenref}" is not an attribute token ("Story.Name")`);
1319
+ if (!type || !attribute) throw new UsageError(`"${tokenref}" is not an attribute token ("Story.Name")`);
1182
1320
  return toAttributeDefinition(await transport.request({ method: "GET", path: `/meta.v1/${encodeURIComponent(type)}/${encodeURIComponent(attribute)}`, responseType: "json" }));
1183
1321
  }
1184
1322
 
@@ -1225,6 +1363,15 @@ var Schema = class _Schema {
1225
1363
  attribute(type, name) {
1226
1364
  return attributeOf(this, type, name);
1227
1365
  }
1366
+ /**
1367
+ * Can the type do this operation (Delete, Reactivate, …)? Undefined when
1368
+ * the type is not loaded or its definition carries no operations, so a
1369
+ * caller can tell "no" from "don't know".
1370
+ */
1371
+ canOperate(type, op) {
1372
+ const t = this.assetType(type);
1373
+ return t?.Operations ? t.Operations.includes(op) : void 0;
1374
+ }
1228
1375
  /** The tokens loaded so far. */
1229
1376
  get loaded() {
1230
1377
  return [...this.types.entries()].filter(([, t]) => t !== null).map(([token]) => token);
@@ -1296,7 +1443,7 @@ async function localize(transport, tags, options = {}) {
1296
1443
  const encoded = encodeURIComponent(toLson(unique));
1297
1444
  const headers = options.culture ? { "Accept-Language": options.culture } : void 0;
1298
1445
  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 });
1299
- 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`);
1446
+ 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);
1300
1447
  return result;
1301
1448
  }
1302
1449
  async function localizeOne(transport, tag, options = {}) {
@@ -1316,7 +1463,7 @@ function rejected(error) {
1316
1463
  return messages.length ? new ApiError(messages.join("; "), error.status, "QUERY_REJECTED", error.details) : error;
1317
1464
  }
1318
1465
  async function queries2(transport, qs, options = {}) {
1319
- 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`);
1466
+ 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`);
1320
1467
  if (!qs.length) return [];
1321
1468
  let response;
1322
1469
  try {
@@ -1377,8 +1524,8 @@ function fromAssetDocument(doc, { history: history2 = false } = {}) {
1377
1524
  }
1378
1525
  function toQuery(params) {
1379
1526
  return {
1380
- sel: params.sel?.join(","),
1381
- where: params.where?.length ? params.where.join(";") : void 0,
1527
+ sel: (params.select ?? params.sel)?.join(","),
1528
+ where: (params.filter ?? params.where)?.length ? (params.filter ?? params.where).join(";") : void 0,
1382
1529
  with: params.with ? Object.entries(params.with).map(([k, v]) => `${k}=${[].concat(v).join(",")}`).join("|") : void 0,
1383
1530
  find: params.find,
1384
1531
  findin: params.findin?.join(","),
@@ -1390,14 +1537,14 @@ function toQuery(params) {
1390
1537
  };
1391
1538
  }
1392
1539
  async function read(transport, path, params, history2) {
1393
- if (!params.page) throw new Error(`rest-1 read of ${path} without a page: every read must be bounded`);
1540
+ if (!params.page) throw new UsageError(`rest-1 read of ${path} without a page: every read must be bounded`);
1394
1541
  const doc = await transport.request({ method: "GET", path, query: toQuery(params), responseType: "json" });
1395
1542
  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 };
1396
1543
  }
1397
1544
  var list2 = (transport, type, params) => read(transport, `/rest-1.v1/Data/${encodeURIComponent(type)}`, params, false);
1398
- async function get2(transport, oid, sel) {
1545
+ async function get2(transport, oid, select) {
1399
1546
  const [type, id] = momentless(oid).split(":");
1400
- const doc = await transport.request({ method: "GET", path: `/rest-1.v1/Data/${encodeURIComponent(type)}/${encodeURIComponent(id)}`, query: { sel: sel?.join(",") }, responseType: "json" });
1547
+ const doc = await transport.request({ method: "GET", path: `/rest-1.v1/Data/${encodeURIComponent(type)}/${encodeURIComponent(id)}`, query: { sel: select?.join(",") }, responseType: "json" });
1401
1548
  return fromAssetDocument(doc);
1402
1549
  }
1403
1550
  var history = (transport, target, params) => {
@@ -1407,6 +1554,8 @@ var history = (transport, target, params) => {
1407
1554
  };
1408
1555
 
1409
1556
  // src/client.ts
1557
+ var DEFAULT_LIMIT = 1e3;
1558
+ var ME_SELECT = ["Name", "Username", "Nickname", "Email"];
1410
1559
  var AgilityApiClient = class _AgilityApiClient {
1411
1560
  transport;
1412
1561
  schema;
@@ -1438,6 +1587,48 @@ var AgilityApiClient = class _AgilityApiClient {
1438
1587
  const transport = { request: (options) => base.request({ ...options, signal: options.signal ?? signal }) };
1439
1588
  return new _AgilityApiClient(this.config, { transport, schema: this.schema });
1440
1589
  }
1590
+ async query(q) {
1591
+ await this.schema.ensureFor([q]);
1592
+ return query2(this.transport, q, { lookup: this.schema });
1593
+ }
1594
+ async queries(qs) {
1595
+ await this.schema.ensureFor(qs);
1596
+ return queries2(this.transport, [...qs], { lookup: this.schema });
1597
+ }
1598
+ async get(oid, select) {
1599
+ const rows = await this.query({ from: momentless(oid), ...select ? { select } : {}, page: { size: 1, start: 0 } });
1600
+ return rows[0] ?? null;
1601
+ }
1602
+ async queryPage(q) {
1603
+ await this.schema.ensureFor([q]);
1604
+ return queryPage(this.transport, q, { lookup: this.schema });
1605
+ }
1606
+ async *pages(q, options = {}) {
1607
+ if (!q.page) throw new UsageError(`query of ${q.from} without a page: every read must be bounded`);
1608
+ const limit = options.limit ?? DEFAULT_LIMIT;
1609
+ const size = q.page.size;
1610
+ let start = q.page.start, read2 = 0;
1611
+ while (read2 < limit) {
1612
+ const want = Math.min(size, limit - read2);
1613
+ const rows = await this.query({ ...q, page: { size: want, start } });
1614
+ if (rows.length) yield rows;
1615
+ read2 += rows.length;
1616
+ start += rows.length;
1617
+ if (rows.length < want) return;
1618
+ }
1619
+ }
1620
+ async all(q, options = {}) {
1621
+ const out = [];
1622
+ for await (const page2 of this.pages(q, options)) out.push(...page2);
1623
+ return out;
1624
+ }
1625
+ /** The member these credentials belong to (Member.IsSelf, live 2026-11-12): what a comment's Author is. */
1626
+ async me() {
1627
+ const [row] = await this.query({ from: "Member", select: ME_SELECT, filter: ["IsSelf='true'"], page: { size: 1, start: 0 } });
1628
+ if (!row) throw new ApiError("the server knows no member for these credentials", 0, "NOT_FOUND");
1629
+ return row;
1630
+ }
1631
+ // ---- the endpoints behind the reads -----------------------------------------------
1441
1632
  /** meta.v1: the instance's schema, raw. (The translated, cached view is `schema`.) */
1442
1633
  meta = {
1443
1634
  metamodel: () => getMetamodel(this.transport),
@@ -1446,12 +1637,65 @@ var AgilityApiClient = class _AgilityApiClient {
1446
1637
  };
1447
1638
  /**
1448
1639
  * loc-2.v1: labels for tags, in one request (meta's DisplayName values are
1449
- * tags). An unmatched tag comes back as itself; the server never says
1450
- * "missing", so neither does this.
1640
+ * tags, and so is a violation's `tag`). An unmatched tag comes back as
1641
+ * itself; the server never says "missing", so neither does this.
1451
1642
  */
1452
1643
  localize(tags, options) {
1453
1644
  return localize(this.transport, tags, options);
1454
1645
  }
1646
+ /**
1647
+ * rest-1.v1, for what query.v1 cannot do: deleted assets (`deleted:
1648
+ * true`), one asset by OID without a page, and history (one row per
1649
+ * moment). Parameters are spelled as a query's (`select`, `filter`).
1650
+ */
1651
+ data = {
1652
+ list: (type, params) => list2(this.transport, type, params),
1653
+ get: (oid, select) => get2(this.transport, oid, select),
1654
+ history: (target, params) => history(this.transport, target, params)
1655
+ };
1656
+ /** @deprecated since 1.0.0-alpha.3: `client.query` / `client.queries` read through query.v1. Removed in 1.0.0. */
1657
+ queryV1 = {
1658
+ query: (q) => this.query(q),
1659
+ queries: (qs) => this.queries(qs)
1660
+ };
1661
+ // ---- writes -----------------------------------------------------------------------
1662
+ /** api/asset: batches, answered in the model's terms. */
1663
+ assets = {
1664
+ /**
1665
+ * Send a batch. The Outcome says what it did — created, modified and
1666
+ * operated OIDs, failures by index (each with its parsed violation),
1667
+ * query rows per query command — and never the envelope. api/asset
1668
+ * commits per command and answers 200 either way, so a failed command
1669
+ * is in `failures`, or thrown as a CommandFailureError with
1670
+ * `throwOnFailure` (the error carries the same Outcome).
1671
+ */
1672
+ send: async (commands, options) => {
1673
+ await this.schema.ensureFor(commands);
1674
+ return outcomeOf(await send(this.transport, commands, { ...options, lookup: this.schema }));
1675
+ },
1676
+ /** The server's projection of a batch: one command per asset it would touch, nothing applied. */
1677
+ preview: (commands) => preview(this.transport, commands),
1678
+ /**
1679
+ * Send as a compare-and-set: each asset is changed only if nobody has
1680
+ * changed it since the preview read it; the rest come back `skipped`.
1681
+ * Throws UnguardableError, before any request, for shapes it cannot
1682
+ * cover.
1683
+ */
1684
+ sendGuarded: async (commands, options) => {
1685
+ await this.schema.ensureFor(commands);
1686
+ return sendGuarded(this.transport, commands, { ...options, lookup: this.schema });
1687
+ },
1688
+ /** @deprecated since 1.0.0-alpha.3: use `client.query` (or `client.queryPage` for a total). Removed in 1.0.0. */
1689
+ query: async (q) => {
1690
+ await this.schema.ensureFor([q]);
1691
+ return query(this.transport, q, { lookup: this.schema });
1692
+ },
1693
+ /** @deprecated since 1.0.0-alpha.3: use `client.queries`. Removed in 1.0.0. */
1694
+ queries: async (qs) => {
1695
+ await this.schema.ensureFor(qs);
1696
+ return queries(this.transport, qs, { lookup: this.schema });
1697
+ }
1698
+ };
1455
1699
  /**
1456
1700
  * Services: what a person types, turned into assets. Numbers resolve in
1457
1701
  * one api/asset request across every number root; an OID token is taken
@@ -1474,7 +1718,7 @@ var AgilityApiClient = class _AgilityApiClient {
1474
1718
  /**
1475
1719
  * The files attached to assets: metadata on Attachment assets, bytes
1476
1720
  * through attachment.img. `add` sends a small file inline (one request)
1477
- * and a large one in two steps; `url` is where a browser opens one.
1721
+ * and a large one in two steps.
1478
1722
  */
1479
1723
  attachments = {
1480
1724
  list: (asset, options) => list(this.transport, asset, options),
@@ -1484,7 +1728,8 @@ var AgilityApiClient = class _AgilityApiClient {
1484
1728
  add: (asset, data, options) => add(this.transport, asset, data, options),
1485
1729
  put: (id, data, contentType) => put(this.transport, id, data, contentType),
1486
1730
  remove: (id) => remove(this.transport, id),
1487
- url: (id) => attachmentUrl(this.config.baseUrl, id)
1731
+ /** @deprecated since 1.0.0-alpha.3: `client.urls.attachment`. Removed in 1.0.0. */
1732
+ url: (id) => this.urls.attachment(id)
1488
1733
  };
1489
1734
  /**
1490
1735
  * Images embedded in an asset's rich text (Description and other long
@@ -1494,55 +1739,16 @@ var AgilityApiClient = class _AgilityApiClient {
1494
1739
  */
1495
1740
  embeddedImages = {
1496
1741
  upload: (asset, data, options) => uploadEmbeddedImage(this.transport, asset, data, options),
1497
- url: (id) => embeddedImageUrl(this.config.baseUrl, id),
1742
+ /** @deprecated since 1.0.0-alpha.3: `client.urls.embeddedImage`. Removed in 1.0.0. */
1743
+ url: (id) => this.urls.embeddedImage(id),
1498
1744
  normalize: (html) => normalizeEmbeddedImageUrls(html, this.config.baseUrl)
1499
1745
  };
1500
- /** rest-1.v1: bounded reads, one asset, history. */
1501
- data = {
1502
- list: (type, params) => list2(this.transport, type, params),
1503
- get: (oid, sel) => get2(this.transport, oid, sel),
1504
- history: (target, params) => history(this.transport, target, params)
1505
- };
1506
- /**
1507
- * query.v1: reads only, the same query bodies as assets.query, rows
1508
- * translated the same way. The endpoint cannot write, so it is the safe
1509
- * place to explore a query; its requests may be retried like a GET.
1510
- */
1511
- queryV1 = {
1512
- query: async (q) => {
1513
- await this.schema.ensureFor([q]);
1514
- return query2(this.transport, q, { lookup: this.schema });
1515
- },
1516
- queries: async (qs) => {
1517
- await this.schema.ensureFor(qs);
1518
- return queries2(this.transport, qs, { lookup: this.schema });
1519
- }
1520
- };
1521
- /** api/asset: batches and queries, rows translated through the schema. */
1522
- assets = {
1523
- send: async (commands, options) => {
1524
- await this.schema.ensureFor(commands);
1525
- return send(this.transport, commands, { ...options, lookup: this.schema });
1526
- },
1527
- preview: (commands) => preview(this.transport, commands),
1528
- /**
1529
- * Send as a compare-and-set: each asset is changed only if nobody has
1530
- * changed it since the preview read it; the rest come back `skipped`.
1531
- * Throws UnguardableError, before any request, for shapes it cannot
1532
- * cover.
1533
- */
1534
- sendGuarded: async (commands, options) => {
1535
- await this.schema.ensureFor(commands);
1536
- return sendGuarded(this.transport, commands, { ...options, lookup: this.schema });
1537
- },
1538
- query: async (q) => {
1539
- await this.schema.ensureFor([q]);
1540
- return query(this.transport, q, { lookup: this.schema });
1541
- },
1542
- queries: async (qs) => {
1543
- await this.schema.ensureFor(qs);
1544
- return queries(this.transport, qs, { lookup: this.schema });
1545
- }
1746
+ /** Where a browser opens things on this instance: an asset's page, an attachment's bytes, an embedded image. */
1747
+ urls = {
1748
+ /** The asset's page: assetdetail.v1 redirects to the type's own detail page (live 2026-11-12). */
1749
+ asset: (oid) => `${this.config.baseUrl.replace(/\/+$/, "")}/assetdetail.v1?oid=${momentless(oid)}`,
1750
+ attachment: (id) => attachmentUrl(this.config.baseUrl, id),
1751
+ embeddedImage: (id) => embeddedImageUrl(this.config.baseUrl, id)
1546
1752
  };
1547
1753
  };
1548
1754
  // Annotate the CommonJS export names for ESM import in node:
@@ -1562,6 +1768,8 @@ var AgilityApiClient = class _AgilityApiClient {
1562
1768
  TypeCache,
1563
1769
  UNGUARDABLE_TYPES,
1564
1770
  UnguardableError,
1771
+ UsageError,
1772
+ WrappedError,
1565
1773
  assetApi,
1566
1774
  assetStateOf,
1567
1775
  attachment,
@@ -1570,11 +1778,13 @@ var AgilityApiClient = class _AgilityApiClient {
1570
1778
  guard,
1571
1779
  isAssetNumber,
1572
1780
  isOidToken,
1781
+ kindOf,
1573
1782
  loc2,
1574
1783
  meta,
1575
1784
  momentless,
1576
1785
  namesOf,
1577
1786
  parseOid,
1787
+ parseViolation,
1578
1788
  queryV1,
1579
1789
  resolveNumbers,
1580
1790
  resolveRef,