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

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/README.md CHANGED
@@ -44,7 +44,7 @@ npm test
44
44
  - `assets.sendGuarded` - a batch as a compare-and-set on each asset's ChangeDate; changed assets come back `skipped`
45
45
  - `attachments.list` / `page` / `info` / `get` / `add` / `put` / `remove` / `url` - files attached to an asset: metadata on the Attachment asset, bytes through attachment.img; `page` adds the total and whether more pages follow; `get`'s `contentType` is the type the server sent the bytes with; `add` sends a small file inline (Base64, one request) and a large one in two steps (create, then upload), and a failed upload throws `AttachmentUploadError` naming the empty attachment it left
46
46
  - `embeddedImages.upload` / `url` / `normalize` - images in rich text: upload returns the `<img src>` URL; `normalize` rewrites `embedded.img` references to this instance's URL. They cannot be deleted
47
- - `batch.run` / `batch.reversible` - preview, snapshot, (confirm,) send guarded, and a receipt with a reverse batch. Undo (`batch.reversible`) reactivates a closed asset before deleting it, and a second command on one asset is sent as a second guarded pass. `confirm` sees the touched assets' names; aliased updates/executes are sent as written with `guard: false`; a failure after the send throws `BatchRecordError` carrying the receipt
47
+ - `batch.run` / `batch.reversible` - preview, snapshot, (confirm,) send guarded, and a receipt with a reverse batch. Undo (`batch.reversible`) reactivates a closed asset before deleting it, and a second command on one asset is sent as a second guarded pass. The reverse uses only operations the type has in meta (a created Conversation, which has no `Delete`, is a note), and undoing an `Inactivate` or `QuickClose` reopens the descendants that close cascaded to (`receipt.openDescendants`, `receipt.cascaded`), parent first. A command the server reports as failed lands in `receipt.failed`, not `skipped`. `confirm` sees the touched assets' names; aliased updates/executes are sent as written with `guard: false`; a failure after the send throws `BatchRecordError` carrying the receipt
48
48
  - `TypeCache` - one instance's loaded type definitions, shared by many clients (`new AgilityApiClient(config, { types })`): a server that builds a client per user fetches each type once
49
49
  - `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
50
50
  - `ApiError` - typed API error; `CommandFailureError` - an api/asset batch in which commands failed (`failures` by index, plus the `response`)
@@ -71,15 +71,33 @@ prerelease (`1.0.0-alpha.0`, `-beta.N`, `-rc.N`) goes out on its own
71
71
  dist-tag (`alpha`, `beta`, `rc`), so `latest` is never a prerelease.
72
72
 
73
73
  ```bash
74
- npm version 1.0.0-alpha.1 --no-git-tag-version # bump, commit, merge
75
- git tag v1.0.0-alpha.1 && git push origin v1.0.0-alpha.1
74
+ npm version 1.0.0-alpha.2 --no-git-tag-version # bump, commit, merge
75
+ git tag v1.0.0-alpha.2 && git push origin v1.0.0-alpha.2
76
76
  ```
77
77
 
78
+ The workflow does not publish directly: it runs `npm stage publish`, which
79
+ puts the version in the registry where nobody can install it yet, and prints
80
+ its stage ID in the run's summary. A maintainer then approves it with 2FA,
81
+ which is what publishes it:
82
+
83
+ ```bash
84
+ npm stage view <stage-id> # what was staged
85
+ npm stage approve <stage-id> # publish it (asks for 2FA)
86
+ npm stage reject <stage-id> # discard it
87
+ ```
88
+
89
+ or approves it on the package's page on npmjs. `npm stage` needs npm
90
+ 11.15.0 or later.
91
+
78
92
  Authentication is npm trusted publishing: on npmjs, the package's settings
79
- link this repository and `publish.yml`, and no token is stored. A package
80
- must exist before it can be linked, so the very first version is published
81
- once by hand by an `@versionone` org member (`npm publish --access public
82
- --tag alpha` from a clean checkout of the tagged commit), then linked.
93
+ link this repository, `publish.yml` and the GitHub environment `npm`, with
94
+ the `npm stage publish` permission (leave plain `npm publish` off, as npm
95
+ recommends), and no token is stored. A trusted-publisher link expires; an
96
+ expired one must be deleted and created again, and until then the workflow
97
+ fails with `ENEEDAUTH`. The `npm` environment (repository settings) requires
98
+ an approval, so a pushed tag waits for a maintainer before it stages. A
99
+ package must exist before it can be linked, so the very first version was
100
+ published once by hand by an `@versionone` org member.
83
101
 
84
102
  ## Before PR
85
103
 
package/dist/index.cjs CHANGED
@@ -889,9 +889,46 @@ async function snapshot(transport, described) {
889
889
  }
890
890
  return before;
891
891
  }
892
- var INVERSE = { Inactivate: "Reactivate", Reactivate: "Inactivate", Delete: "Undelete", Undelete: "Delete" };
893
- function reverseOf(described, before, done) {
892
+ var INVERSE = { Inactivate: "Reactivate", QuickClose: "Reactivate", Reactivate: "Inactivate", Delete: "Undelete", Undelete: "Delete" };
893
+ var CASCADING = /* @__PURE__ */ new Set(["Inactivate", "QuickClose"]);
894
+ var CASCADE_DEPTH = 8;
895
+ async function openDescendantsOf(transport, schema, roots) {
896
+ const out = {};
897
+ const rootOf = new Map(roots.map((r) => [momentless(r), momentless(r)]));
898
+ const seen = new Set(rootOf.keys());
899
+ let frontier = [...rootOf.keys()];
900
+ for (let depth = 0; depth < CASCADE_DEPTH && frontier.length; depth += 1) {
901
+ const children = /* @__PURE__ */ new Map();
902
+ for (const [type, list3] of byType(frontier)) {
903
+ await schema.load(type);
904
+ if (!schema.attribute(type, "Children")) continue;
905
+ for (const part of chunks(list3)) {
906
+ const rows = await query(transport, { from: type, select: ["Children"], filter: ["ID=$oids"], with: { $oids: part }, page: { size: part.length, start: 0 } });
907
+ for (const r of rows) {
908
+ const parent = momentless(r._oid);
909
+ for (const c of [].concat(plain(r.Children) ?? [])) {
910
+ const child = momentless(String(c));
911
+ if (seen.has(child)) continue;
912
+ seen.add(child);
913
+ children.set(child, rootOf.get(parent));
914
+ }
915
+ }
916
+ }
917
+ }
918
+ const states = children.size ? await statesOf(transport, [...children.keys()]) : {};
919
+ frontier = [];
920
+ for (const [child, root] of children) {
921
+ if (states[child] === void 0 || isClosed(states[child])) continue;
922
+ rootOf.set(child, root);
923
+ (out[root] ??= []).push(child);
924
+ frontier.push(child);
925
+ }
926
+ }
927
+ return out;
928
+ }
929
+ function reverseOf(described, before, done, context = {}) {
894
930
  const commands = [], notes = [];
931
+ const cannot = (oid, op) => context.canOperate?.(typeOfOid(oid), op) === false;
895
932
  const modified = new Set(done.modified.map(momentless)), operated = new Set(done.operated.map(momentless));
896
933
  for (const u of described.updates) {
897
934
  if (!modified.has(u.oid)) continue;
@@ -916,10 +953,32 @@ function reverseOf(described, before, done) {
916
953
  for (const e of described.executes) {
917
954
  if (!operated.has(e.oid)) continue;
918
955
  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 ?? "?")})`);
956
+ if (!inverse) {
957
+ notes.push(`${e.oid}: ${e.op} has no known inverse (was ${String(before[e.oid]?.AssetState ?? "?")})`);
958
+ continue;
959
+ }
960
+ if (cannot(e.oid, inverse)) {
961
+ notes.push(`${e.oid}: ${typeOfOid(e.oid)} has no ${inverse} operation, so its ${e.op} is not reversed`);
962
+ continue;
963
+ }
964
+ commands.push({ from: e.oid, execute: inverse });
965
+ if (!CASCADING.has(e.op)) continue;
966
+ const open = context.openDescendants?.[e.oid] ?? [];
967
+ const closed = context.cascaded ? context.cascaded[e.oid] ?? [] : void 0;
968
+ if (closed === void 0) {
969
+ 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`);
970
+ continue;
971
+ }
972
+ for (const d of closed) {
973
+ if (cannot(d, "Reactivate")) notes.push(`${d}: closed with ${e.oid}, but ${typeOfOid(d)} has no Reactivate operation`);
974
+ else commands.push({ from: d, execute: "Reactivate" });
975
+ }
976
+ if (closed.length) notes.push(`${e.oid}: ${e.op} also closed ${closed.join(", ")}; reopened after it`);
977
+ }
978
+ for (const oid of [...done.created].reverse().map(momentless)) {
979
+ 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)" : ""}`);
980
+ else commands.push({ from: oid, execute: "Delete" });
921
981
  }
922
- for (const oid of [...done.created].reverse()) commands.push({ from: momentless(oid), execute: "Delete" });
923
982
  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
983
  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
984
  return { commands, notes };
@@ -954,12 +1013,14 @@ async function runBatch(transport, schema, commands, options = {}) {
954
1013
  const described = describe(commands, projected);
955
1014
  const touched = touchedBy(projected);
956
1015
  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 };
1016
+ 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
1017
  const nothing = !described.creates.length && !described.updates.length && !described.executes.length && !described.queries.length && !described.deferred.length;
959
1018
  if (nothing) return receipt;
960
1019
  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
1020
  receipt.snapshotAt = now();
962
1021
  receipt.before = await snapshot(transport, described);
1022
+ const closing = described.executes.filter((e) => CASCADING.has(e.op)).map((e) => e.oid);
1023
+ if (closing.length) receipt.openDescendants = await openDescendantsOf(transport, schema, closing);
963
1024
  if (options.dryRun) return receipt;
964
1025
  if (options.confirm && !await options.confirm(receipt)) return receipt;
965
1026
  let unstamped = [];
@@ -991,13 +1052,29 @@ async function runBatch(transport, schema, commands, options = {}) {
991
1052
  const unnamed = { number: null, name: null };
992
1053
  receipt.created = done.created.map((oid, i) => ({ alias: described.creates[i]?.alias ?? null, type: described.creates[i]?.type ?? typeOfOid(oid), oid, ...unnamed }));
993
1054
  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);
1055
+ const sentAs = receipt.sentCommands ?? [];
1056
+ const oidOfSent = (i) => {
1057
+ const body = i === null ? void 0 : sentAs[i] ? bodyOf3(sentAs[i]) : void 0;
1058
+ const id = body?.with?.$oid ?? body?.from;
1059
+ return typeof id === "string" && id.includes(":") ? momentless(id) : null;
1060
+ };
1061
+ receipt.failed = [...new Set(done.failures.map((f) => oidOfSent(f.index)).filter((oid) => !!oid && touched.includes(oid) && !hit.includes(oid)))];
1062
+ receipt.skipped = useGuard ? [.../* @__PURE__ */ new Set([...unstamped, ...touched.filter((oid) => !hit.includes(oid) && !receipt.failed.includes(oid))])] : [];
1063
+ for (const type of new Set([...done.created, ...hit].map((oid) => typeOfOid(momentless(oid))))) await schema.load(type).catch(() => null);
1064
+ const canOperate = (type, op) => schema.canOperate(type, op);
1065
+ const operatedNow = new Set(done.operated.map(momentless));
1066
+ const closed = Object.keys(receipt.openDescendants).filter((oid) => operatedNow.has(oid) && receipt.openDescendants[oid].length);
1067
+ receipt.reverse = reverseOf(described, receipt.before, done, { openDescendants: receipt.openDescendants, cascaded: closed.length ? void 0 : {}, canOperate });
996
1068
  try {
1069
+ if (closed.length) {
1070
+ const states = await statesOf(transport, closed.flatMap((oid) => receipt.openDescendants[oid]));
1071
+ receipt.cascaded = Object.fromEntries(closed.map((oid) => [oid, receipt.openDescendants[oid].filter((d) => isClosed(states[d]))]));
1072
+ receipt.reverse = reverseOf(described, receipt.before, done, { openDescendants: receipt.openDescendants, cascaded: receipt.cascaded, canOperate });
1073
+ }
997
1074
  const more = await namesOf(transport, schema, [...done.created, ...hit.filter((oid) => !names.has(oid))], noteFailure);
998
1075
  receipt.created = receipt.created.map((c) => ({ ...c, ...more.get(c.oid) ?? unnamed }));
999
1076
  receipt.touched = receipt.touched.map((t) => names.has(t.oid) ? t : { ...t, ...more.get(t.oid) ?? unnamed });
1000
- const all = [...done.created, ...hit];
1077
+ const all = [.../* @__PURE__ */ new Set([...done.created, ...hit, ...Object.values(receipt.cascaded).flat()])];
1001
1078
  receipt.after = all.length ? await stampsOf(transport, all) : {};
1002
1079
  } catch (error) {
1003
1080
  throw new BatchRecordError(error, receipt);
@@ -1161,7 +1238,8 @@ function toAssetType(doc) {
1161
1238
  ...doc.DefaultOrderBy ? { DefaultOrderBy: doc.DefaultOrderBy.tokenref } : {},
1162
1239
  ...doc.DefaultDisplayBy ? { DefaultDisplayBy: doc.DefaultDisplayBy.tokenref } : {},
1163
1240
  ...doc.ShortName ? { ShortName: doc.ShortName.tokenref } : {},
1164
- Attributes: Object.fromEntries(Object.entries(doc.Attributes ?? {}).map(([key, value]) => [key, toAttributeDefinition(value)]))
1241
+ Attributes: Object.fromEntries(Object.entries(doc.Attributes ?? {}).map(([key, value]) => [key, toAttributeDefinition(value)])),
1242
+ ...doc.Operations ? { Operations: Object.keys(doc.Operations).map((token) => token.slice(token.indexOf(".") + 1)) } : {}
1165
1243
  };
1166
1244
  }
1167
1245
  function toMetamodel(doc) {
@@ -1225,6 +1303,15 @@ var Schema = class _Schema {
1225
1303
  attribute(type, name) {
1226
1304
  return attributeOf(this, type, name);
1227
1305
  }
1306
+ /**
1307
+ * Can the type do this operation (Delete, Reactivate, …)? Undefined when
1308
+ * the type is not loaded or its definition carries no operations, so a
1309
+ * caller can tell "no" from "don't know".
1310
+ */
1311
+ canOperate(type, op) {
1312
+ const t = this.assetType(type);
1313
+ return t?.Operations ? t.Operations.includes(op) : void 0;
1314
+ }
1228
1315
  /** The tokens loaded so far. */
1229
1316
  get loaded() {
1230
1317
  return [...this.types.entries()].filter(([, t]) => t !== null).map(([token]) => token);