@mulmoclaude/core 0.14.0 → 0.18.0

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.
Files changed (49) hide show
  1. package/assets/helps/collection-skills.md +94 -11
  2. package/assets/helps/custom-view.md +28 -0
  3. package/assets/helps/egress-sync.md +158 -0
  4. package/assets/helps/index.md +1 -0
  5. package/dist/{calendarGrid-kO6rGfm0.js → calendarGrid-B3dtFN-m.js} +105 -2
  6. package/dist/calendarGrid-B3dtFN-m.js.map +1 -0
  7. package/dist/{calendarGrid-C1rfCXJ3.cjs → calendarGrid-CRaMAguM.cjs} +158 -1
  8. package/dist/calendarGrid-CRaMAguM.cjs.map +1 -0
  9. package/dist/collection/core/actionVisible.d.ts +15 -4
  10. package/dist/collection/core/backlinks.d.ts +40 -0
  11. package/dist/collection/core/mutateAction.d.ts +14 -0
  12. package/dist/collection/core/recordZ.d.ts +4 -5
  13. package/dist/collection/core/schema.d.ts +21 -4
  14. package/dist/collection/core/schemaZ.d.ts +209 -16
  15. package/dist/collection/core/uiTypes.d.ts +26 -0
  16. package/dist/collection/index.cjs +21 -36
  17. package/dist/collection/index.cjs.map +1 -1
  18. package/dist/collection/index.d.ts +1 -0
  19. package/dist/collection/index.js +12 -33
  20. package/dist/collection/index.js.map +1 -1
  21. package/dist/collection/registry/server/index.cjs +1 -1
  22. package/dist/collection/registry/server/index.js +1 -1
  23. package/dist/collection/server/index.cjs +8 -2
  24. package/dist/collection/server/index.d.ts +2 -0
  25. package/dist/collection/server/index.js +3 -2
  26. package/dist/collection/server/io.d.ts +9 -0
  27. package/dist/collection/server/mutate.d.ts +24 -0
  28. package/dist/collection/server/ontology.d.ts +35 -0
  29. package/dist/collection-watchers/index.cjs +5 -4
  30. package/dist/collection-watchers/index.cjs.map +1 -1
  31. package/dist/collection-watchers/index.js +3 -2
  32. package/dist/collection-watchers/index.js.map +1 -1
  33. package/dist/feeds/index.cjs +2 -2
  34. package/dist/feeds/index.js +2 -2
  35. package/dist/feeds/server/index.cjs +3 -3
  36. package/dist/feeds/server/index.js +3 -3
  37. package/dist/{ingestTypes-BIFXlw7M.cjs → ingestTypes-Cq65Bv-N.cjs} +2 -2
  38. package/dist/{ingestTypes-BIFXlw7M.cjs.map → ingestTypes-Cq65Bv-N.cjs.map} +1 -1
  39. package/dist/{ingestTypes-KuYPX9Ea.js → ingestTypes-DvsJhqMx.js} +2 -2
  40. package/dist/{ingestTypes-KuYPX9Ea.js.map → ingestTypes-DvsJhqMx.js.map} +1 -1
  41. package/dist/{server-Cc6XcyOA.js → server-0IVR-Y_G.js} +316 -35
  42. package/dist/server-0IVR-Y_G.js.map +1 -0
  43. package/dist/{server-Cd5WdM8a.cjs → server-BeoOWQ-a.cjs} +347 -42
  44. package/dist/server-BeoOWQ-a.cjs.map +1 -0
  45. package/package.json +1 -1
  46. package/dist/calendarGrid-C1rfCXJ3.cjs.map +0 -1
  47. package/dist/calendarGrid-kO6rGfm0.js.map +0 -1
  48. package/dist/server-Cc6XcyOA.js.map +0 -1
  49. package/dist/server-Cd5WdM8a.cjs.map +0 -1
@@ -1,5 +1,5 @@
1
1
  const require_rolldown_runtime = require("./rolldown-runtime-D6vf50IK.cjs");
2
- const require_calendarGrid = require("./calendarGrid-C1rfCXJ3.cjs");
2
+ const require_calendarGrid = require("./calendarGrid-CRaMAguM.cjs");
3
3
  const require_collection_paths = require("./collection/paths.cjs");
4
4
  let node_path = require("node:path");
5
5
  node_path = require_rolldown_runtime.__toESM(node_path, 1);
@@ -239,7 +239,9 @@ async function writeFileAtomic(filePath, content) {
239
239
  * inside an otherwise-contained data dir — without this, a record
240
240
  * file could symlink to /etc/passwd and the detail endpoint would
241
241
  * happily serve it. Returns false on ENOENT and on any other lstat
242
- * failure so the caller's "missing" branch covers those cases too. */
242
+ * failure so the caller's "missing" branch covers those cases too.
243
+ * Exported so `ontology.ts`'s record COUNT classifies entries with the
244
+ * SAME lstat logic — the two must agree on what a record file is. */
243
245
  async function isRegularFile(filePath) {
244
246
  try {
245
247
  return (await (0, node_fs_promises.lstat)(filePath)).isFile();
@@ -635,13 +637,6 @@ ${templateText}`;
635
637
  }
636
638
  //#endregion
637
639
  //#region src/collection/core/recordZ.ts
638
- /** derived/embed/toggle are host-computed or projected — never written to
639
- * the record JSON, so required / value checks must not apply to them. */
640
- var COMPUTED_TYPES = /* @__PURE__ */ new Set([
641
- "derived",
642
- "embed",
643
- "toggle"
644
- ]);
645
640
  /** The emptiness rule shared by `required` and the "only check present
646
641
  * values" gate. NOT a truthiness check — `0` and `false` are filled. */
647
642
  var isEmptyValue = (value) => value === void 0 || value === null || value === "";
@@ -653,15 +648,6 @@ function enforcedProblem(key, spec, value) {
653
648
  if (!empty && spec.type === "enum" && !spec.values.includes(String(value))) return `'${key}' = '${String(value)}' is not one of [${spec.values.join(", ")}]`;
654
649
  return null;
655
650
  }
656
- /** Numeric coercion for the strict `number`/`money` check: a plain number,
657
- * or a non-blank numeric string (renderers coerce those via `Number(...)`,
658
- * so they display fine). Anything else — arrays (`[]` stringifies to `""`
659
- * = 0, `[42]` to `"42"`), booleans, objects — is NaN and gets flagged. */
660
- function coerceNumeric(value) {
661
- if (typeof value === "number") return value;
662
- if (typeof value === "string" && value.trim() !== "") return Number(value);
663
- return NaN;
664
- }
665
651
  /** Report-only per-type checks on a PRESENT value. Date / datetime reuse the
666
652
  * calendar's STRICT civil parsers (`parseIsoDate` / `parseIsoDateTime`), so
667
653
  * the lint flags exactly the values the calendar / trigger / spawn code
@@ -672,7 +658,7 @@ function coerceNumeric(value) {
672
658
  function strictTypeProblem(key, spec, value) {
673
659
  switch (spec.type) {
674
660
  case "number":
675
- case "money": return Number.isFinite(coerceNumeric(value)) ? null : `'${key}' = '${String(value)}' is not numeric (a '${spec.type}' field stores a plain number)`;
661
+ case "money": return Number.isFinite(require_calendarGrid.coerceNumeric(value)) ? null : `'${key}' = '${String(value)}' is not numeric (a '${spec.type}' field stores a plain number)`;
676
662
  case "boolean": return value === true || value === false ? null : `'${key}' = '${String(value)}' is not a boolean (store true or false, unquoted)`;
677
663
  case "date": return require_calendarGrid.parseIsoDate(value) !== null ? null : `'${key}' = '${String(value)}' is not a real YYYY-MM-DD date`;
678
664
  case "datetime": return require_calendarGrid.parseIsoDateTime(value) !== null ? null : `'${key}' = '${String(value)}' is not a YYYY-MM-DDTHH:MM datetime (seconds optional, no timezone suffix — the shape the calendar parses)`;
@@ -720,7 +706,7 @@ var compiled = /* @__PURE__ */ new WeakMap();
720
706
  function compileRecordZ(schema, tier) {
721
707
  const cached = compiled.get(schema)?.[tier];
722
708
  if (cached) return cached;
723
- const stored = Object.entries(schema.fields).filter(([, spec]) => !COMPUTED_TYPES.has(spec.type));
709
+ const stored = Object.entries(schema.fields).filter(([, spec]) => !require_calendarGrid.COMPUTED_TYPES.has(spec.type));
724
710
  const validator = zod.z.looseObject({}).superRefine((record, ctx) => {
725
711
  for (const [key, spec] of stored) {
726
712
  const problem = recordFieldProblem(key, spec, record[key], tier);
@@ -829,6 +815,96 @@ function validateRecordObject(record, itemId, schema, tier = "enforced") {
829
815
  if (typeof idValue !== "string" || idValue !== itemId) return `'${schema.primaryKey}' is '${String(idValue ?? "")}' but must equal the filename ('${itemId}'), or the record can't be opened`;
830
816
  return firstRecordProblem(record, schema, tier);
831
817
  }
818
+ /** The param name a `set` value references, or null when the value is a
819
+ * literal (non-strings can never be references). A bare/empty prefix
820
+ * (`"$params."`) returns the empty string — the schema refine rejects
821
+ * it as an undeclared param, never silently treats it as a literal. */
822
+ function paramRefName(value) {
823
+ if (typeof value !== "string" || !value.startsWith("$params.")) return null;
824
+ return value.slice(8);
825
+ }
826
+ /** Resolve a mutate action's `set` map against the submitted params:
827
+ * literals pass through, `$params.<name>` reads the param value. An
828
+ * ABSENT referenced param omits the key entirely (merge semantics —
829
+ * the stored value survives), mirroring how the record form omits
830
+ * empty optionals rather than writing empty strings. */
831
+ function resolveMutateSet(set, params) {
832
+ const resolved = {};
833
+ for (const [key, value] of Object.entries(set)) {
834
+ const ref = paramRefName(value);
835
+ if (ref === null) {
836
+ resolved[key] = value;
837
+ continue;
838
+ }
839
+ const paramValue = params[ref];
840
+ if (paramValue !== void 0 && paramValue !== null && paramValue !== "") resolved[key] = paramValue;
841
+ }
842
+ return resolved;
843
+ }
844
+ //#endregion
845
+ //#region src/collection/server/mutate.ts
846
+ /** First problem with the submitted params, or null. Every declared param
847
+ * is checked by the shared record-field validator; keys the action never
848
+ * declared are rejected outright — a stray key would otherwise ride the
849
+ * resolved `set` semantics silently. */
850
+ function firstMutateParamProblem(action, params) {
851
+ const declared = action.params ?? {};
852
+ for (const key of Object.keys(params)) if (declared[key] === void 0) return `unknown param '${key}' — not declared by action '${action.id}'`;
853
+ for (const [key, spec] of Object.entries(declared)) {
854
+ const problem = recordFieldProblem(key, spec, params[key], "strict");
855
+ if (problem) return problem;
856
+ }
857
+ return null;
858
+ }
859
+ /** Apply one mutate action to one record. Never throws for data-shaped
860
+ * failures — the outcome's `problem` doubles as agent feedback when an
861
+ * LLM (not the user) pressed the button. */
862
+ async function applyMutateAction(collection, action, itemId, params, opts = {}) {
863
+ const paramProblem = firstMutateParamProblem(action, params);
864
+ if (paramProblem) return {
865
+ ok: false,
866
+ status: "invalid-params",
867
+ problem: paramProblem
868
+ };
869
+ const existing = await readItem(collection.dataDir, itemId, opts);
870
+ if (!existing) return {
871
+ ok: false,
872
+ status: "not-found",
873
+ problem: `item '${itemId}' not found`
874
+ };
875
+ if (!require_calendarGrid.actionVisible(action, existing)) return {
876
+ ok: false,
877
+ status: "require-unmet",
878
+ problem: `action '${action.id}' is not available for item '${itemId}' in its current state`
879
+ };
880
+ const stored = Object.entries(existing).filter(([key]) => {
881
+ const spec = collection.schema.fields[key];
882
+ return !spec || !require_calendarGrid.COMPUTED_TYPES.has(spec.type);
883
+ });
884
+ const merged = {
885
+ ...Object.fromEntries(stored),
886
+ ...resolveMutateSet(action.set, params)
887
+ };
888
+ const invalid = validateRecordObject(merged, itemId, collection.schema);
889
+ if (invalid) return {
890
+ ok: false,
891
+ status: "invalid-record",
892
+ problem: invalid
893
+ };
894
+ const result = await writeItem(collection.dataDir, itemId, merged, {
895
+ workspaceRoot: opts.workspaceRoot,
896
+ slug: collection.slug
897
+ });
898
+ if (result.kind !== "ok") return {
899
+ ok: false,
900
+ status: "write-refused",
901
+ problem: `write refused (${result.kind})`
902
+ };
903
+ return {
904
+ ok: true,
905
+ item: merged
906
+ };
907
+ }
832
908
  //#endregion
833
909
  //#region src/collection/core/schemaZ.ts
834
910
  /** Optional visibility predicate shared by actions and fields: the target
@@ -998,6 +1074,48 @@ var EmbedFieldZ = zod.z.object({
998
1074
  message: "fields with type 'embed' must declare a `to` (valid collection slug) and exactly one of `id` (a fixed record's primary key) or `idField` (a sibling field naming the per-record target)",
999
1075
  path: ["id"]
1000
1076
  });
1077
+ /** Display-only REVERSE refs (plan step ② of plans/collection-ontology.md):
1078
+ * a read-only sub-table of the records in collection `from` whose `via`
1079
+ * ref field stores THIS record's primary key. Stores nothing (joins
1080
+ * `COMPUTED_TYPES`); resolution is shared server/client via
1081
+ * `core/backlinks.ts`. `display` names the `from` columns to show;
1082
+ * `filter` (the standard `when` shape, matched against each SOURCE
1083
+ * record) narrows the rows. Validation is shape-only, like `embed`:
1084
+ * `from` must be a safe slug, but whether it exists — and whether `via` /
1085
+ * `display` name real fields there — resolves fail-soft at render
1086
+ * (empty sub-table). Do NOT add cross-schema existence checks here. */
1087
+ var BacklinksFieldZ = zod.z.object({
1088
+ type: zod.z.literal("backlinks"),
1089
+ ...fieldBase,
1090
+ from: zod.z.string().min(1),
1091
+ via: zod.z.string().trim().min(1),
1092
+ display: zod.z.array(zod.z.string().trim().min(1)).min(1),
1093
+ filter: WhenZ.optional()
1094
+ }).refine((spec) => require_calendarGrid.isSafeSlug(spec.from), slugMessage("from"));
1095
+ /** A cross-collection AGGREGATE over a backlink relation (plan step ⑤ of
1096
+ * plans/collection-ontology.md): a computed number — never stored — that
1097
+ * sums a source column (or counts rows) over the records in `from` whose
1098
+ * `via` ref points at this record. Same `from`/`via`/`filter` vocabulary
1099
+ * and reverse-loading machinery as `backlinks`; resolution shared
1100
+ * server/client via `core/backlinks.ts`'s `rollupValue`. Deliberately a
1101
+ * STRUCTURED field, not `sumOver(...)` formula syntax — the derived
1102
+ * evaluator's no-string-literals boundary stays untouched — and
1103
+ * deliberately just `sum` | `count`. Rollups resolve BEFORE the formula
1104
+ * pass, so a sibling `derived` formula may read them as identifiers
1105
+ * (`played = homePlayed + awayPlayed`). Fail-soft: an unresolvable
1106
+ * `from` renders em-dash; an empty match set is a real 0. */
1107
+ var RollupFieldZ = zod.z.object({
1108
+ type: zod.z.literal("rollup"),
1109
+ ...fieldBase,
1110
+ from: zod.z.string().min(1),
1111
+ via: zod.z.string().trim().min(1),
1112
+ op: zod.z.enum(["sum", "count"]),
1113
+ column: zod.z.string().trim().min(1).optional(),
1114
+ filter: WhenZ.optional()
1115
+ }).refine((spec) => require_calendarGrid.isSafeSlug(spec.from), slugMessage("from")).refine((spec) => spec.op === "sum" === (spec.column !== void 0), {
1116
+ message: "a rollup's `column` names the source column to aggregate: required for op \"sum\", meaningless for op \"count\"",
1117
+ path: ["column"]
1118
+ });
1001
1119
  /** A checkbox that is a pure PROJECTION of an `enum` field — it stores
1002
1120
  * nothing of its own. Checked when the enum named by `field` equals
1003
1121
  * `onValue`; toggling writes `onValue` / `offValue` back to that enum
@@ -1020,23 +1138,61 @@ var FieldSpecZ = zod.z.discriminatedUnion("type", [
1020
1138
  TableFieldZ,
1021
1139
  DerivedFieldZ,
1022
1140
  EmbedFieldZ,
1141
+ BacklinksFieldZ,
1142
+ RollupFieldZ,
1023
1143
  ToggleFieldZ
1024
1144
  ]);
1025
- /** A schema-declared record action, rendered as a button in the read-only
1026
- * detail view. Domain-free: the host validates the shape; the meaning
1027
- * (which role, which template) is data. v1 ships only `kind: "chat"` —
1028
- * start a new chat in `role` with a templated seed prompt; the enum
1029
- * reserves room for a future `"mutate"` (status transitions) without
1030
- * another schema-shape change. */
1031
- var ActionSpecZ = zod.z.object({
1145
+ var actionBase = {
1032
1146
  id: zod.z.string().trim().min(1),
1033
1147
  label: zod.z.string().trim().min(1),
1034
- icon: zod.z.string().trim().min(1).optional(),
1035
- kind: zod.z.enum(["chat"]),
1148
+ icon: zod.z.string().trim().min(1).optional()
1149
+ };
1150
+ /** The LLM-seeded action kinds — same shape, different visibility:
1151
+ * - `"chat"` — start a new VISIBLE chat in `role` with the templated
1152
+ * seed prompt (judgment work: drafting, planning, conversation).
1153
+ * - `"agent"` — dispatch a HIDDEN worker (origin `system`) with the SAME
1154
+ * seed; it edits records via manageCollection and finishes silently
1155
+ * (mechanical enrichment: refresh a price, fetch metadata). Spinner
1156
+ * while running, deduped failure bell on error — see
1157
+ * server/api/routes/collectionAgentActions.ts. */
1158
+ var SeededActionZ = zod.z.object({
1159
+ kind: zod.z.enum(["chat", "agent"]),
1160
+ ...actionBase,
1036
1161
  role: zod.z.string().trim().min(1),
1037
1162
  template: zod.z.string().trim().min(1).refine(require_collection_paths.isSafeActionTemplatePath, "must be a safe path under `templates/` (e.g. `templates/invoice.md`; no `..`, no leading `/`, no backslash)"),
1038
1163
  when: WhenZ.optional()
1039
1164
  });
1165
+ /** `kind: "mutate"` — a declarative, HOST-executed write; no LLM, no
1166
+ * tokens (plan step ④ of plans/collection-ontology.md). Clicking the
1167
+ * button (after an optional `params` mini-form) merges `set` into the
1168
+ * record: values are literals or `$params.<name>` references. `require`
1169
+ * is the state gate — the standard `when` shape, both the visibility
1170
+ * rule AND the server-side authorization rule, exactly like `when` on
1171
+ * the seeded kinds. `params` reuses the table sub-field DSL, and the
1172
+ * form is validated by the SAME compiled record checks `putItems` uses
1173
+ * (`recordFieldProblem`), not a third mechanism. Record-level only —
1174
+ * a collection-level mutate has no record to write (schema refine
1175
+ * below). Merge semantics make half-states unconstructible THROUGH
1176
+ * THIS PATH; the raw file stays editable by design (lint, not lock). */
1177
+ var MutateActionZ = zod.z.object({
1178
+ kind: zod.z.literal("mutate"),
1179
+ ...actionBase,
1180
+ require: WhenZ.optional(),
1181
+ params: zod.z.record(zod.z.string().trim().min(1), SubFieldSpecZ).optional(),
1182
+ set: zod.z.record(zod.z.string().trim().min(1), zod.z.union([
1183
+ zod.z.string(),
1184
+ zod.z.number(),
1185
+ zod.z.boolean()
1186
+ ]))
1187
+ }).refine((spec) => Object.keys(spec.set).length > 0, {
1188
+ message: "a mutate action's `set` must name at least one field to write",
1189
+ path: ["set"]
1190
+ });
1191
+ /** A schema-declared record action, rendered as a button in the read-only
1192
+ * detail view. Domain-free: the host validates the shape; the meaning
1193
+ * (role + template prose, or the declarative `set`) is data. A
1194
+ * discriminated union on `kind` — each kind declares only its own keys. */
1195
+ var ActionSpecZ = zod.z.discriminatedUnion("kind", [SeededActionZ, MutateActionZ]);
1040
1196
  /** A custom (LLM-authored) HTML view registration. Domain-free: the host
1041
1197
  * validates the shape; the view's behaviour lives in the HTML file. `file`
1042
1198
  * is constrained to `views/*.html` (path-safe) so the view-file reader can
@@ -1277,6 +1433,21 @@ var CollectionSchemaZ = zod.z.object({
1277
1433
  }).refine((schema) => schema.collectionActions === void 0 || new Set(schema.collectionActions.map((action) => action.id)).size === schema.collectionActions.length, {
1278
1434
  message: "schema `collectionActions` must have unique `id`s",
1279
1435
  path: ["collectionActions"]
1436
+ }).refine((schema) => (schema.actions ?? []).every((action) => action.kind !== "mutate" || Object.keys(action.set).every((key) => {
1437
+ const target = schema.fields[key];
1438
+ return target !== void 0 && !require_calendarGrid.COMPUTED_TYPES.has(target.type) && key !== schema.primaryKey;
1439
+ })), {
1440
+ message: "a mutate action's `set` keys must name declared, non-computed fields (and never the primaryKey)",
1441
+ path: ["actions"]
1442
+ }).refine((schema) => (schema.actions ?? []).every((action) => action.kind !== "mutate" || Object.values(action.set).every((value) => {
1443
+ const ref = paramRefName(value);
1444
+ return ref === null || (action.params ?? {})[ref] !== void 0;
1445
+ })), {
1446
+ message: "a mutate action's `$params.<name>` references must name keys declared in its `params`",
1447
+ path: ["actions"]
1448
+ }).refine((schema) => (schema.collectionActions ?? []).every((action) => action.kind !== "mutate"), {
1449
+ message: "`collectionActions` cannot contain `kind: \"mutate\"` — a collection-level action has no record to write",
1450
+ path: ["collectionActions"]
1280
1451
  }).refine((schema) => collectCurrencyFieldRefs(schema.fields).every((name) => CODE_FIELD_TYPES.has(schema.fields[name]?.type ?? "")), {
1281
1452
  message: "a money field's `currencyField` must name a top-level `string`, `text`, or `enum` field that holds the currency code",
1282
1453
  path: ["fields"]
@@ -1550,6 +1721,72 @@ function toDetail(collection) {
1550
1721
  };
1551
1722
  }
1552
1723
  //#endregion
1724
+ //#region src/collection/server/ontology.ts
1725
+ /** Extract the relations a schema declares, in field declaration order:
1726
+ * top-level `ref` / `embed` / `backlinks` fields plus `ref` sub-fields
1727
+ * inside `table` columns. Pure — exported so the phase-2 graph panel
1728
+ * can reuse it on already-loaded schemas. */
1729
+ function schemaRelations(schema) {
1730
+ const relations = [];
1731
+ for (const [key, spec] of Object.entries(schema.fields)) {
1732
+ if (spec.type === "ref" || spec.type === "embed") relations.push({
1733
+ field: key,
1734
+ kind: spec.type,
1735
+ to: spec.to
1736
+ });
1737
+ if (spec.type === "backlinks" || spec.type === "rollup") relations.push({
1738
+ field: key,
1739
+ kind: spec.type,
1740
+ to: spec.from
1741
+ });
1742
+ if (spec.type !== "table") continue;
1743
+ for (const [subKey, subSpec] of Object.entries(spec.of)) if (subSpec.type === "ref") relations.push({
1744
+ field: `${key}.${subKey}`,
1745
+ kind: "ref",
1746
+ to: subSpec.to
1747
+ });
1748
+ }
1749
+ return relations;
1750
+ }
1751
+ /** Count the record files in a collection's data dir — the same
1752
+ * `<id>.json` entries `listItems` considers, WITHOUT parsing them (the
1753
+ * ontology is a summary; a malformed record is still a record). Each
1754
+ * candidate is classified by the SHARED `isRegularFile` lstat helper —
1755
+ * the exact file-disclosure defense `listItems` applies — so a
1756
+ * symlinked record never counts and the count can't diverge from the
1757
+ * readable set on any filesystem (Codex review on PR #2099, twice).
1758
+ * Fail-soft: a missing dir or a dataDir escaping the workspace via
1759
+ * symlink counts 0. */
1760
+ async function countRecordFiles(dataDir, workspaceRoot) {
1761
+ if (!isContainedInRoot(dataDir, workspaceRoot)) return 0;
1762
+ try {
1763
+ const names = (await (0, node_fs_promises.readdir)(dataDir)).filter((name) => name.endsWith(".json") && !name.startsWith("."));
1764
+ return (await Promise.all(names.map((name) => isRegularFile(node_path.default.join(dataDir, name))))).filter(Boolean).length;
1765
+ } catch {
1766
+ return 0;
1767
+ }
1768
+ }
1769
+ async function toOntologyEntry(collection, workspaceRoot) {
1770
+ const { schema } = collection;
1771
+ return {
1772
+ slug: collection.slug,
1773
+ title: schema.title,
1774
+ icon: schema.icon,
1775
+ primaryKey: schema.primaryKey,
1776
+ displayField: schema.displayField ?? schema.primaryKey,
1777
+ recordCount: await countRecordFiles(collection.dataDir, workspaceRoot),
1778
+ relations: schemaRelations(schema)
1779
+ };
1780
+ }
1781
+ /** Build the workspace ontology: every discovered collection (slug-sorted,
1782
+ * discovery's order), each with its outbound relations and a
1783
+ * readdir-cheap record count — fine to call on demand. */
1784
+ async function buildWorkspaceOntology(opts = {}) {
1785
+ const workspaceRoot = opts.workspaceRoot ?? getWorkspaceRoot();
1786
+ const collections = await discoverCollections(opts);
1787
+ return Promise.all(collections.map((collection) => toOntologyEntry(collection, workspaceRoot)));
1788
+ }
1789
+ //#endregion
1553
1790
  //#region src/collection/server/derive.ts
1554
1791
  /** Slugs of every collection referenced by a `ref` field — top-level
1555
1792
  * and one level into `table` sub-fields (nested tables are
@@ -1572,6 +1809,14 @@ function uniqueEmbedTargets(schema) {
1572
1809
  for (const field of Object.values(schema.fields)) if (field.type === "embed" && typeof field.to === "string" && field.to.length > 0) targets.add(field.to);
1573
1810
  return [...targets];
1574
1811
  }
1812
+ /** Slugs of every SOURCE collection a `backlinks` or `rollup` field
1813
+ * reverses over — loaded exactly like ref/embed targets (whole
1814
+ * collection, once; the two field kinds share one load). */
1815
+ function uniqueBacklinkSources(schema) {
1816
+ const sources = /* @__PURE__ */ new Set();
1817
+ for (const field of Object.values(schema.fields)) if ((field.type === "backlinks" || field.type === "rollup") && field.from.length > 0) sources.add(field.from);
1818
+ return [...sources];
1819
+ }
1575
1820
  async function loadTarget(slug, opts) {
1576
1821
  const target = await loadCollection(slug, opts);
1577
1822
  if (!target) return null;
@@ -1586,11 +1831,16 @@ async function loadTarget(slug, opts) {
1586
1831
  byId
1587
1832
  };
1588
1833
  }
1589
- /** Load every ref/embed target collection once. Unknown / unloadable
1590
- * targets are simply absent — downstream derefs resolve to null, the
1591
- * same fail-soft the UI renders as an em-dash. */
1834
+ /** Load every ref/embed target and backlink source collection once.
1835
+ * Unknown / unloadable targets are simply absent — downstream derefs
1836
+ * resolve to null (em-dash) and backlinks to an empty row set, the
1837
+ * same fail-soft the UI renders. */
1592
1838
  async function loadLinkedTargets(schema, opts) {
1593
- const slugs = [.../* @__PURE__ */ new Set([...uniqueRefTargets(schema), ...uniqueEmbedTargets(schema)])];
1839
+ const slugs = [.../* @__PURE__ */ new Set([
1840
+ ...uniqueRefTargets(schema),
1841
+ ...uniqueEmbedTargets(schema),
1842
+ ...uniqueBacklinkSources(schema)
1843
+ ])];
1594
1844
  const loaded = {};
1595
1845
  for (const slug of slugs) {
1596
1846
  const target = await loadTarget(slug, opts);
@@ -1601,9 +1851,21 @@ async function loadLinkedTargets(schema, opts) {
1601
1851
  function toRefRecords(linked) {
1602
1852
  return Object.fromEntries(Object.entries(linked).map(([slug, target]) => [slug, target.byId]));
1603
1853
  }
1854
+ /** The matching source rows for one `backlinks` field, projected to the
1855
+ * source primaryKey + `display` columns — so getItems on a
1856
+ * heavily-referenced record stays a summary, not a dump of the source
1857
+ * collection. Missing source ⇒ [] (fail-soft). The rows come from the
1858
+ * DERIVED source records (`byId`), so `display`/`filter` on a derived
1859
+ * source column (an invoice `total`) works. */
1860
+ function projectBacklinks(field, schema, enriched, linked) {
1861
+ const source = linked[field.from];
1862
+ if (!source) return [];
1863
+ return require_calendarGrid.backlinkRows(field, String(enriched[schema.primaryKey] ?? ""), Object.values(source.byId)).map((row) => require_calendarGrid.projectBacklinkRow(row, field.display, source.schema.primaryKey));
1864
+ }
1604
1865
  /** Project the computed (never-stored) field kinds onto one derived
1605
1866
  * record: `toggle` → boolean off its enum, `embed` → the target record
1606
- * (fixed `id` or per-record `idField`), or null when missing. */
1867
+ * (fixed `id` or per-record `idField`), or null when missing,
1868
+ * `backlinks` → the matching source rows (see `projectBacklinks`). */
1607
1869
  function projectComputed(schema, enriched, linked) {
1608
1870
  for (const [key, field] of Object.entries(schema.fields)) {
1609
1871
  if (field.type === "toggle" && field.field) enriched[key] = String(enriched[field.field] ?? "") === field.onValue;
@@ -1611,9 +1873,28 @@ function projectComputed(schema, enriched, linked) {
1611
1873
  const targetId = require_calendarGrid.embedTargetId(field, enriched);
1612
1874
  enriched[key] = targetId && linked[field.to]?.byId[targetId] || null;
1613
1875
  }
1876
+ if (field.type === "backlinks") enriched[key] = projectBacklinks(field, schema, enriched, linked);
1614
1877
  }
1615
1878
  return enriched;
1616
1879
  }
1880
+ /** Resolve every rollup field onto a COPY of the record, BEFORE the
1881
+ * formula pass — a `derived` formula may reference rollup values as
1882
+ * plain identifiers (`played = homePlayed + awayPlayed`). Same reverse
1883
+ * machinery as backlinks, collapsed to a number: an unresolvable source
1884
+ * is null (a formula reading it fails soft to em-dash); an empty match
1885
+ * set is a real 0. Returns `record` unchanged when the schema declares
1886
+ * no rollups. */
1887
+ function projectRollups(schema, record, linked) {
1888
+ let out = record;
1889
+ for (const [key, field] of Object.entries(schema.fields)) {
1890
+ if (field.type !== "rollup") continue;
1891
+ if (out === record) out = { ...record };
1892
+ const source = linked[field.from];
1893
+ const selfId = String(record[schema.primaryKey] ?? "");
1894
+ out[key] = source ? require_calendarGrid.rollupValue(field, selfId, Object.values(source.byId)) : null;
1895
+ }
1896
+ return out;
1897
+ }
1617
1898
  /** Enrich records with every host-computed field: derived formulas
1618
1899
  * evaluated (cross-collection derefs included), toggles projected,
1619
1900
  * embeds resolved. Loads each linked collection ONCE per call. Input
@@ -1622,7 +1903,7 @@ async function enrichItems(collection, items, opts = {}) {
1622
1903
  const { schema } = collection;
1623
1904
  const linked = await loadLinkedTargets(schema, opts);
1624
1905
  const refRecords = toRefRecords(linked);
1625
- return items.map((item) => projectComputed(schema, require_calendarGrid.deriveAll(schema, item, refRecords), linked));
1906
+ return items.map((item) => projectComputed(schema, require_calendarGrid.deriveAll(schema, projectRollups(schema, item, linked), refRecords), linked));
1626
1907
  }
1627
1908
  //#endregion
1628
1909
  //#region src/collection/server/dynamicIcon.ts
@@ -2147,12 +2428,6 @@ async function deleteCustomView(collection, viewId, opts = {}) {
2147
2428
  };
2148
2429
  }
2149
2430
  //#endregion
2150
- Object.defineProperty(exports, "COMPUTED_TYPES", {
2151
- enumerable: true,
2152
- get: function() {
2153
- return COMPUTED_TYPES;
2154
- }
2155
- });
2156
2431
  Object.defineProperty(exports, "CollectionSchemaZ", {
2157
2432
  enumerable: true,
2158
2433
  get: function() {
@@ -2183,6 +2458,12 @@ Object.defineProperty(exports, "advanceTriggerDate", {
2183
2458
  return advanceTriggerDate;
2184
2459
  }
2185
2460
  });
2461
+ Object.defineProperty(exports, "applyMutateAction", {
2462
+ enumerable: true,
2463
+ get: function() {
2464
+ return applyMutateAction;
2465
+ }
2466
+ });
2186
2467
  Object.defineProperty(exports, "buildActionSeedPrompt", {
2187
2468
  enumerable: true,
2188
2469
  get: function() {
@@ -2195,6 +2476,12 @@ Object.defineProperty(exports, "buildCollectionActionSeedPrompt", {
2195
2476
  return buildCollectionActionSeedPrompt;
2196
2477
  }
2197
2478
  });
2479
+ Object.defineProperty(exports, "buildWorkspaceOntology", {
2480
+ enumerable: true,
2481
+ get: function() {
2482
+ return buildWorkspaceOntology;
2483
+ }
2484
+ });
2198
2485
  Object.defineProperty(exports, "collectionsRegistriesConfigPath", {
2199
2486
  enumerable: true,
2200
2487
  get: function() {
@@ -2273,6 +2560,12 @@ Object.defineProperty(exports, "errorMessage", {
2273
2560
  return errorMessage;
2274
2561
  }
2275
2562
  });
2563
+ Object.defineProperty(exports, "firstMutateParamProblem", {
2564
+ enumerable: true,
2565
+ get: function() {
2566
+ return firstMutateParamProblem;
2567
+ }
2568
+ });
2276
2569
  Object.defineProperty(exports, "formatCivil", {
2277
2570
  enumerable: true,
2278
2571
  get: function() {
@@ -2303,6 +2596,12 @@ Object.defineProperty(exports, "isContainedInWorkspace", {
2303
2596
  return isContainedInWorkspace;
2304
2597
  }
2305
2598
  });
2599
+ Object.defineProperty(exports, "isRegularFile", {
2600
+ enumerable: true,
2601
+ get: function() {
2602
+ return isRegularFile;
2603
+ }
2604
+ });
2306
2605
  Object.defineProperty(exports, "isTriggerDue", {
2307
2606
  enumerable: true,
2308
2607
  get: function() {
@@ -2423,6 +2722,12 @@ Object.defineProperty(exports, "safeSlugName", {
2423
2722
  return safeSlugName;
2424
2723
  }
2425
2724
  });
2725
+ Object.defineProperty(exports, "schemaRelations", {
2726
+ enumerable: true,
2727
+ get: function() {
2728
+ return schemaRelations;
2729
+ }
2730
+ });
2426
2731
  Object.defineProperty(exports, "setCollectionChangePublisher", {
2427
2732
  enumerable: true,
2428
2733
  get: function() {
@@ -2472,4 +2777,4 @@ Object.defineProperty(exports, "writeItem", {
2472
2777
  }
2473
2778
  });
2474
2779
 
2475
- //# sourceMappingURL=server-Cd5WdM8a.cjs.map
2780
+ //# sourceMappingURL=server-BeoOWQ-a.cjs.map