@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,4 +1,4 @@
1
- import { A as isFieldDrivenEvery, C as SAFE_SLUG_PATTERN, D as FEED_SCHEDULES, E as AGENT_INGEST_KIND, O as INGEST_KINDS, S as SAFE_RECORD_ID_PATTERN, T as isSafeSlug, b as selectDynamicRecord, h as deriveAll, k as embedTargetId, l as parseIsoDate, u as parseIsoDateTime, v as firstDateField, w as isSafeRecordId, y as resolveIcon } from "./calendarGrid-kO6rGfm0.js";
1
+ import { A as SAFE_RECORD_ID_PATTERN, C as coerceNumeric, E as actionVisible, F as COMPUTED_TYPES, I as FEED_SCHEDULES, L as INGEST_KINDS, M as isSafeRecordId, N as isSafeSlug, P as AGENT_INGEST_KIND, R as embedTargetId, S as backlinkRows, T as rollupValue, b as selectDynamicRecord, h as deriveAll, j as SAFE_SLUG_PATTERN, l as parseIsoDate, u as parseIsoDateTime, v as firstDateField, w as projectBacklinkRow, y as resolveIcon, z as isFieldDrivenEvery } from "./calendarGrid-B3dtFN-m.js";
2
2
  import { isSafeActionTemplatePath, isSafeCustomViewI18nPath, isSafeCustomViewPath } from "./collection/paths.js";
3
3
  import path from "node:path";
4
4
  import { promises, realpathSync } from "node:fs";
@@ -237,7 +237,9 @@ async function writeFileAtomic(filePath, content) {
237
237
  * inside an otherwise-contained data dir — without this, a record
238
238
  * file could symlink to /etc/passwd and the detail endpoint would
239
239
  * happily serve it. Returns false on ENOENT and on any other lstat
240
- * failure so the caller's "missing" branch covers those cases too. */
240
+ * failure so the caller's "missing" branch covers those cases too.
241
+ * Exported so `ontology.ts`'s record COUNT classifies entries with the
242
+ * SAME lstat logic — the two must agree on what a record file is. */
241
243
  async function isRegularFile(filePath) {
242
244
  try {
243
245
  return (await lstat(filePath)).isFile();
@@ -633,13 +635,6 @@ ${templateText}`;
633
635
  }
634
636
  //#endregion
635
637
  //#region src/collection/core/recordZ.ts
636
- /** derived/embed/toggle are host-computed or projected — never written to
637
- * the record JSON, so required / value checks must not apply to them. */
638
- var COMPUTED_TYPES = /* @__PURE__ */ new Set([
639
- "derived",
640
- "embed",
641
- "toggle"
642
- ]);
643
638
  /** The emptiness rule shared by `required` and the "only check present
644
639
  * values" gate. NOT a truthiness check — `0` and `false` are filled. */
645
640
  var isEmptyValue = (value) => value === void 0 || value === null || value === "";
@@ -651,15 +646,6 @@ function enforcedProblem(key, spec, value) {
651
646
  if (!empty && spec.type === "enum" && !spec.values.includes(String(value))) return `'${key}' = '${String(value)}' is not one of [${spec.values.join(", ")}]`;
652
647
  return null;
653
648
  }
654
- /** Numeric coercion for the strict `number`/`money` check: a plain number,
655
- * or a non-blank numeric string (renderers coerce those via `Number(...)`,
656
- * so they display fine). Anything else — arrays (`[]` stringifies to `""`
657
- * = 0, `[42]` to `"42"`), booleans, objects — is NaN and gets flagged. */
658
- function coerceNumeric(value) {
659
- if (typeof value === "number") return value;
660
- if (typeof value === "string" && value.trim() !== "") return Number(value);
661
- return NaN;
662
- }
663
649
  /** Report-only per-type checks on a PRESENT value. Date / datetime reuse the
664
650
  * calendar's STRICT civil parsers (`parseIsoDate` / `parseIsoDateTime`), so
665
651
  * the lint flags exactly the values the calendar / trigger / spawn code
@@ -827,6 +813,96 @@ function validateRecordObject(record, itemId, schema, tier = "enforced") {
827
813
  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`;
828
814
  return firstRecordProblem(record, schema, tier);
829
815
  }
816
+ /** The param name a `set` value references, or null when the value is a
817
+ * literal (non-strings can never be references). A bare/empty prefix
818
+ * (`"$params."`) returns the empty string — the schema refine rejects
819
+ * it as an undeclared param, never silently treats it as a literal. */
820
+ function paramRefName(value) {
821
+ if (typeof value !== "string" || !value.startsWith("$params.")) return null;
822
+ return value.slice(8);
823
+ }
824
+ /** Resolve a mutate action's `set` map against the submitted params:
825
+ * literals pass through, `$params.<name>` reads the param value. An
826
+ * ABSENT referenced param omits the key entirely (merge semantics —
827
+ * the stored value survives), mirroring how the record form omits
828
+ * empty optionals rather than writing empty strings. */
829
+ function resolveMutateSet(set, params) {
830
+ const resolved = {};
831
+ for (const [key, value] of Object.entries(set)) {
832
+ const ref = paramRefName(value);
833
+ if (ref === null) {
834
+ resolved[key] = value;
835
+ continue;
836
+ }
837
+ const paramValue = params[ref];
838
+ if (paramValue !== void 0 && paramValue !== null && paramValue !== "") resolved[key] = paramValue;
839
+ }
840
+ return resolved;
841
+ }
842
+ //#endregion
843
+ //#region src/collection/server/mutate.ts
844
+ /** First problem with the submitted params, or null. Every declared param
845
+ * is checked by the shared record-field validator; keys the action never
846
+ * declared are rejected outright — a stray key would otherwise ride the
847
+ * resolved `set` semantics silently. */
848
+ function firstMutateParamProblem(action, params) {
849
+ const declared = action.params ?? {};
850
+ for (const key of Object.keys(params)) if (declared[key] === void 0) return `unknown param '${key}' — not declared by action '${action.id}'`;
851
+ for (const [key, spec] of Object.entries(declared)) {
852
+ const problem = recordFieldProblem(key, spec, params[key], "strict");
853
+ if (problem) return problem;
854
+ }
855
+ return null;
856
+ }
857
+ /** Apply one mutate action to one record. Never throws for data-shaped
858
+ * failures — the outcome's `problem` doubles as agent feedback when an
859
+ * LLM (not the user) pressed the button. */
860
+ async function applyMutateAction(collection, action, itemId, params, opts = {}) {
861
+ const paramProblem = firstMutateParamProblem(action, params);
862
+ if (paramProblem) return {
863
+ ok: false,
864
+ status: "invalid-params",
865
+ problem: paramProblem
866
+ };
867
+ const existing = await readItem(collection.dataDir, itemId, opts);
868
+ if (!existing) return {
869
+ ok: false,
870
+ status: "not-found",
871
+ problem: `item '${itemId}' not found`
872
+ };
873
+ if (!actionVisible(action, existing)) return {
874
+ ok: false,
875
+ status: "require-unmet",
876
+ problem: `action '${action.id}' is not available for item '${itemId}' in its current state`
877
+ };
878
+ const stored = Object.entries(existing).filter(([key]) => {
879
+ const spec = collection.schema.fields[key];
880
+ return !spec || !COMPUTED_TYPES.has(spec.type);
881
+ });
882
+ const merged = {
883
+ ...Object.fromEntries(stored),
884
+ ...resolveMutateSet(action.set, params)
885
+ };
886
+ const invalid = validateRecordObject(merged, itemId, collection.schema);
887
+ if (invalid) return {
888
+ ok: false,
889
+ status: "invalid-record",
890
+ problem: invalid
891
+ };
892
+ const result = await writeItem(collection.dataDir, itemId, merged, {
893
+ workspaceRoot: opts.workspaceRoot,
894
+ slug: collection.slug
895
+ });
896
+ if (result.kind !== "ok") return {
897
+ ok: false,
898
+ status: "write-refused",
899
+ problem: `write refused (${result.kind})`
900
+ };
901
+ return {
902
+ ok: true,
903
+ item: merged
904
+ };
905
+ }
830
906
  //#endregion
831
907
  //#region src/collection/core/schemaZ.ts
832
908
  /** Optional visibility predicate shared by actions and fields: the target
@@ -996,6 +1072,48 @@ var EmbedFieldZ = z.object({
996
1072
  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)",
997
1073
  path: ["id"]
998
1074
  });
1075
+ /** Display-only REVERSE refs (plan step ② of plans/collection-ontology.md):
1076
+ * a read-only sub-table of the records in collection `from` whose `via`
1077
+ * ref field stores THIS record's primary key. Stores nothing (joins
1078
+ * `COMPUTED_TYPES`); resolution is shared server/client via
1079
+ * `core/backlinks.ts`. `display` names the `from` columns to show;
1080
+ * `filter` (the standard `when` shape, matched against each SOURCE
1081
+ * record) narrows the rows. Validation is shape-only, like `embed`:
1082
+ * `from` must be a safe slug, but whether it exists — and whether `via` /
1083
+ * `display` name real fields there — resolves fail-soft at render
1084
+ * (empty sub-table). Do NOT add cross-schema existence checks here. */
1085
+ var BacklinksFieldZ = z.object({
1086
+ type: z.literal("backlinks"),
1087
+ ...fieldBase,
1088
+ from: z.string().min(1),
1089
+ via: z.string().trim().min(1),
1090
+ display: z.array(z.string().trim().min(1)).min(1),
1091
+ filter: WhenZ.optional()
1092
+ }).refine((spec) => isSafeSlug(spec.from), slugMessage("from"));
1093
+ /** A cross-collection AGGREGATE over a backlink relation (plan step ⑤ of
1094
+ * plans/collection-ontology.md): a computed number — never stored — that
1095
+ * sums a source column (or counts rows) over the records in `from` whose
1096
+ * `via` ref points at this record. Same `from`/`via`/`filter` vocabulary
1097
+ * and reverse-loading machinery as `backlinks`; resolution shared
1098
+ * server/client via `core/backlinks.ts`'s `rollupValue`. Deliberately a
1099
+ * STRUCTURED field, not `sumOver(...)` formula syntax — the derived
1100
+ * evaluator's no-string-literals boundary stays untouched — and
1101
+ * deliberately just `sum` | `count`. Rollups resolve BEFORE the formula
1102
+ * pass, so a sibling `derived` formula may read them as identifiers
1103
+ * (`played = homePlayed + awayPlayed`). Fail-soft: an unresolvable
1104
+ * `from` renders em-dash; an empty match set is a real 0. */
1105
+ var RollupFieldZ = z.object({
1106
+ type: z.literal("rollup"),
1107
+ ...fieldBase,
1108
+ from: z.string().min(1),
1109
+ via: z.string().trim().min(1),
1110
+ op: z.enum(["sum", "count"]),
1111
+ column: z.string().trim().min(1).optional(),
1112
+ filter: WhenZ.optional()
1113
+ }).refine((spec) => isSafeSlug(spec.from), slugMessage("from")).refine((spec) => spec.op === "sum" === (spec.column !== void 0), {
1114
+ message: "a rollup's `column` names the source column to aggregate: required for op \"sum\", meaningless for op \"count\"",
1115
+ path: ["column"]
1116
+ });
999
1117
  /** A checkbox that is a pure PROJECTION of an `enum` field — it stores
1000
1118
  * nothing of its own. Checked when the enum named by `field` equals
1001
1119
  * `onValue`; toggling writes `onValue` / `offValue` back to that enum
@@ -1018,23 +1136,61 @@ var FieldSpecZ = z.discriminatedUnion("type", [
1018
1136
  TableFieldZ,
1019
1137
  DerivedFieldZ,
1020
1138
  EmbedFieldZ,
1139
+ BacklinksFieldZ,
1140
+ RollupFieldZ,
1021
1141
  ToggleFieldZ
1022
1142
  ]);
1023
- /** A schema-declared record action, rendered as a button in the read-only
1024
- * detail view. Domain-free: the host validates the shape; the meaning
1025
- * (which role, which template) is data. v1 ships only `kind: "chat"` —
1026
- * start a new chat in `role` with a templated seed prompt; the enum
1027
- * reserves room for a future `"mutate"` (status transitions) without
1028
- * another schema-shape change. */
1029
- var ActionSpecZ = z.object({
1143
+ var actionBase = {
1030
1144
  id: z.string().trim().min(1),
1031
1145
  label: z.string().trim().min(1),
1032
- icon: z.string().trim().min(1).optional(),
1033
- kind: z.enum(["chat"]),
1146
+ icon: z.string().trim().min(1).optional()
1147
+ };
1148
+ /** The LLM-seeded action kinds — same shape, different visibility:
1149
+ * - `"chat"` — start a new VISIBLE chat in `role` with the templated
1150
+ * seed prompt (judgment work: drafting, planning, conversation).
1151
+ * - `"agent"` — dispatch a HIDDEN worker (origin `system`) with the SAME
1152
+ * seed; it edits records via manageCollection and finishes silently
1153
+ * (mechanical enrichment: refresh a price, fetch metadata). Spinner
1154
+ * while running, deduped failure bell on error — see
1155
+ * server/api/routes/collectionAgentActions.ts. */
1156
+ var SeededActionZ = z.object({
1157
+ kind: z.enum(["chat", "agent"]),
1158
+ ...actionBase,
1034
1159
  role: z.string().trim().min(1),
1035
1160
  template: z.string().trim().min(1).refine(isSafeActionTemplatePath, "must be a safe path under `templates/` (e.g. `templates/invoice.md`; no `..`, no leading `/`, no backslash)"),
1036
1161
  when: WhenZ.optional()
1037
1162
  });
1163
+ /** `kind: "mutate"` — a declarative, HOST-executed write; no LLM, no
1164
+ * tokens (plan step ④ of plans/collection-ontology.md). Clicking the
1165
+ * button (after an optional `params` mini-form) merges `set` into the
1166
+ * record: values are literals or `$params.<name>` references. `require`
1167
+ * is the state gate — the standard `when` shape, both the visibility
1168
+ * rule AND the server-side authorization rule, exactly like `when` on
1169
+ * the seeded kinds. `params` reuses the table sub-field DSL, and the
1170
+ * form is validated by the SAME compiled record checks `putItems` uses
1171
+ * (`recordFieldProblem`), not a third mechanism. Record-level only —
1172
+ * a collection-level mutate has no record to write (schema refine
1173
+ * below). Merge semantics make half-states unconstructible THROUGH
1174
+ * THIS PATH; the raw file stays editable by design (lint, not lock). */
1175
+ var MutateActionZ = z.object({
1176
+ kind: z.literal("mutate"),
1177
+ ...actionBase,
1178
+ require: WhenZ.optional(),
1179
+ params: z.record(z.string().trim().min(1), SubFieldSpecZ).optional(),
1180
+ set: z.record(z.string().trim().min(1), z.union([
1181
+ z.string(),
1182
+ z.number(),
1183
+ z.boolean()
1184
+ ]))
1185
+ }).refine((spec) => Object.keys(spec.set).length > 0, {
1186
+ message: "a mutate action's `set` must name at least one field to write",
1187
+ path: ["set"]
1188
+ });
1189
+ /** A schema-declared record action, rendered as a button in the read-only
1190
+ * detail view. Domain-free: the host validates the shape; the meaning
1191
+ * (role + template prose, or the declarative `set`) is data. A
1192
+ * discriminated union on `kind` — each kind declares only its own keys. */
1193
+ var ActionSpecZ = z.discriminatedUnion("kind", [SeededActionZ, MutateActionZ]);
1038
1194
  /** A custom (LLM-authored) HTML view registration. Domain-free: the host
1039
1195
  * validates the shape; the view's behaviour lives in the HTML file. `file`
1040
1196
  * is constrained to `views/*.html` (path-safe) so the view-file reader can
@@ -1275,6 +1431,21 @@ var CollectionSchemaZ = z.object({
1275
1431
  }).refine((schema) => schema.collectionActions === void 0 || new Set(schema.collectionActions.map((action) => action.id)).size === schema.collectionActions.length, {
1276
1432
  message: "schema `collectionActions` must have unique `id`s",
1277
1433
  path: ["collectionActions"]
1434
+ }).refine((schema) => (schema.actions ?? []).every((action) => action.kind !== "mutate" || Object.keys(action.set).every((key) => {
1435
+ const target = schema.fields[key];
1436
+ return target !== void 0 && !COMPUTED_TYPES.has(target.type) && key !== schema.primaryKey;
1437
+ })), {
1438
+ message: "a mutate action's `set` keys must name declared, non-computed fields (and never the primaryKey)",
1439
+ path: ["actions"]
1440
+ }).refine((schema) => (schema.actions ?? []).every((action) => action.kind !== "mutate" || Object.values(action.set).every((value) => {
1441
+ const ref = paramRefName(value);
1442
+ return ref === null || (action.params ?? {})[ref] !== void 0;
1443
+ })), {
1444
+ message: "a mutate action's `$params.<name>` references must name keys declared in its `params`",
1445
+ path: ["actions"]
1446
+ }).refine((schema) => (schema.collectionActions ?? []).every((action) => action.kind !== "mutate"), {
1447
+ message: "`collectionActions` cannot contain `kind: \"mutate\"` — a collection-level action has no record to write",
1448
+ path: ["collectionActions"]
1278
1449
  }).refine((schema) => collectCurrencyFieldRefs(schema.fields).every((name) => CODE_FIELD_TYPES.has(schema.fields[name]?.type ?? "")), {
1279
1450
  message: "a money field's `currencyField` must name a top-level `string`, `text`, or `enum` field that holds the currency code",
1280
1451
  path: ["fields"]
@@ -1548,6 +1719,72 @@ function toDetail(collection) {
1548
1719
  };
1549
1720
  }
1550
1721
  //#endregion
1722
+ //#region src/collection/server/ontology.ts
1723
+ /** Extract the relations a schema declares, in field declaration order:
1724
+ * top-level `ref` / `embed` / `backlinks` fields plus `ref` sub-fields
1725
+ * inside `table` columns. Pure — exported so the phase-2 graph panel
1726
+ * can reuse it on already-loaded schemas. */
1727
+ function schemaRelations(schema) {
1728
+ const relations = [];
1729
+ for (const [key, spec] of Object.entries(schema.fields)) {
1730
+ if (spec.type === "ref" || spec.type === "embed") relations.push({
1731
+ field: key,
1732
+ kind: spec.type,
1733
+ to: spec.to
1734
+ });
1735
+ if (spec.type === "backlinks" || spec.type === "rollup") relations.push({
1736
+ field: key,
1737
+ kind: spec.type,
1738
+ to: spec.from
1739
+ });
1740
+ if (spec.type !== "table") continue;
1741
+ for (const [subKey, subSpec] of Object.entries(spec.of)) if (subSpec.type === "ref") relations.push({
1742
+ field: `${key}.${subKey}`,
1743
+ kind: "ref",
1744
+ to: subSpec.to
1745
+ });
1746
+ }
1747
+ return relations;
1748
+ }
1749
+ /** Count the record files in a collection's data dir — the same
1750
+ * `<id>.json` entries `listItems` considers, WITHOUT parsing them (the
1751
+ * ontology is a summary; a malformed record is still a record). Each
1752
+ * candidate is classified by the SHARED `isRegularFile` lstat helper —
1753
+ * the exact file-disclosure defense `listItems` applies — so a
1754
+ * symlinked record never counts and the count can't diverge from the
1755
+ * readable set on any filesystem (Codex review on PR #2099, twice).
1756
+ * Fail-soft: a missing dir or a dataDir escaping the workspace via
1757
+ * symlink counts 0. */
1758
+ async function countRecordFiles(dataDir, workspaceRoot) {
1759
+ if (!isContainedInRoot(dataDir, workspaceRoot)) return 0;
1760
+ try {
1761
+ const names = (await readdir(dataDir)).filter((name) => name.endsWith(".json") && !name.startsWith("."));
1762
+ return (await Promise.all(names.map((name) => isRegularFile(path.join(dataDir, name))))).filter(Boolean).length;
1763
+ } catch {
1764
+ return 0;
1765
+ }
1766
+ }
1767
+ async function toOntologyEntry(collection, workspaceRoot) {
1768
+ const { schema } = collection;
1769
+ return {
1770
+ slug: collection.slug,
1771
+ title: schema.title,
1772
+ icon: schema.icon,
1773
+ primaryKey: schema.primaryKey,
1774
+ displayField: schema.displayField ?? schema.primaryKey,
1775
+ recordCount: await countRecordFiles(collection.dataDir, workspaceRoot),
1776
+ relations: schemaRelations(schema)
1777
+ };
1778
+ }
1779
+ /** Build the workspace ontology: every discovered collection (slug-sorted,
1780
+ * discovery's order), each with its outbound relations and a
1781
+ * readdir-cheap record count — fine to call on demand. */
1782
+ async function buildWorkspaceOntology(opts = {}) {
1783
+ const workspaceRoot = opts.workspaceRoot ?? getWorkspaceRoot();
1784
+ const collections = await discoverCollections(opts);
1785
+ return Promise.all(collections.map((collection) => toOntologyEntry(collection, workspaceRoot)));
1786
+ }
1787
+ //#endregion
1551
1788
  //#region src/collection/server/derive.ts
1552
1789
  /** Slugs of every collection referenced by a `ref` field — top-level
1553
1790
  * and one level into `table` sub-fields (nested tables are
@@ -1570,6 +1807,14 @@ function uniqueEmbedTargets(schema) {
1570
1807
  for (const field of Object.values(schema.fields)) if (field.type === "embed" && typeof field.to === "string" && field.to.length > 0) targets.add(field.to);
1571
1808
  return [...targets];
1572
1809
  }
1810
+ /** Slugs of every SOURCE collection a `backlinks` or `rollup` field
1811
+ * reverses over — loaded exactly like ref/embed targets (whole
1812
+ * collection, once; the two field kinds share one load). */
1813
+ function uniqueBacklinkSources(schema) {
1814
+ const sources = /* @__PURE__ */ new Set();
1815
+ for (const field of Object.values(schema.fields)) if ((field.type === "backlinks" || field.type === "rollup") && field.from.length > 0) sources.add(field.from);
1816
+ return [...sources];
1817
+ }
1573
1818
  async function loadTarget(slug, opts) {
1574
1819
  const target = await loadCollection(slug, opts);
1575
1820
  if (!target) return null;
@@ -1584,11 +1829,16 @@ async function loadTarget(slug, opts) {
1584
1829
  byId
1585
1830
  };
1586
1831
  }
1587
- /** Load every ref/embed target collection once. Unknown / unloadable
1588
- * targets are simply absent — downstream derefs resolve to null, the
1589
- * same fail-soft the UI renders as an em-dash. */
1832
+ /** Load every ref/embed target and backlink source collection once.
1833
+ * Unknown / unloadable targets are simply absent — downstream derefs
1834
+ * resolve to null (em-dash) and backlinks to an empty row set, the
1835
+ * same fail-soft the UI renders. */
1590
1836
  async function loadLinkedTargets(schema, opts) {
1591
- const slugs = [.../* @__PURE__ */ new Set([...uniqueRefTargets(schema), ...uniqueEmbedTargets(schema)])];
1837
+ const slugs = [.../* @__PURE__ */ new Set([
1838
+ ...uniqueRefTargets(schema),
1839
+ ...uniqueEmbedTargets(schema),
1840
+ ...uniqueBacklinkSources(schema)
1841
+ ])];
1592
1842
  const loaded = {};
1593
1843
  for (const slug of slugs) {
1594
1844
  const target = await loadTarget(slug, opts);
@@ -1599,9 +1849,21 @@ async function loadLinkedTargets(schema, opts) {
1599
1849
  function toRefRecords(linked) {
1600
1850
  return Object.fromEntries(Object.entries(linked).map(([slug, target]) => [slug, target.byId]));
1601
1851
  }
1852
+ /** The matching source rows for one `backlinks` field, projected to the
1853
+ * source primaryKey + `display` columns — so getItems on a
1854
+ * heavily-referenced record stays a summary, not a dump of the source
1855
+ * collection. Missing source ⇒ [] (fail-soft). The rows come from the
1856
+ * DERIVED source records (`byId`), so `display`/`filter` on a derived
1857
+ * source column (an invoice `total`) works. */
1858
+ function projectBacklinks(field, schema, enriched, linked) {
1859
+ const source = linked[field.from];
1860
+ if (!source) return [];
1861
+ return backlinkRows(field, String(enriched[schema.primaryKey] ?? ""), Object.values(source.byId)).map((row) => projectBacklinkRow(row, field.display, source.schema.primaryKey));
1862
+ }
1602
1863
  /** Project the computed (never-stored) field kinds onto one derived
1603
1864
  * record: `toggle` → boolean off its enum, `embed` → the target record
1604
- * (fixed `id` or per-record `idField`), or null when missing. */
1865
+ * (fixed `id` or per-record `idField`), or null when missing,
1866
+ * `backlinks` → the matching source rows (see `projectBacklinks`). */
1605
1867
  function projectComputed(schema, enriched, linked) {
1606
1868
  for (const [key, field] of Object.entries(schema.fields)) {
1607
1869
  if (field.type === "toggle" && field.field) enriched[key] = String(enriched[field.field] ?? "") === field.onValue;
@@ -1609,9 +1871,28 @@ function projectComputed(schema, enriched, linked) {
1609
1871
  const targetId = embedTargetId(field, enriched);
1610
1872
  enriched[key] = targetId && linked[field.to]?.byId[targetId] || null;
1611
1873
  }
1874
+ if (field.type === "backlinks") enriched[key] = projectBacklinks(field, schema, enriched, linked);
1612
1875
  }
1613
1876
  return enriched;
1614
1877
  }
1878
+ /** Resolve every rollup field onto a COPY of the record, BEFORE the
1879
+ * formula pass — a `derived` formula may reference rollup values as
1880
+ * plain identifiers (`played = homePlayed + awayPlayed`). Same reverse
1881
+ * machinery as backlinks, collapsed to a number: an unresolvable source
1882
+ * is null (a formula reading it fails soft to em-dash); an empty match
1883
+ * set is a real 0. Returns `record` unchanged when the schema declares
1884
+ * no rollups. */
1885
+ function projectRollups(schema, record, linked) {
1886
+ let out = record;
1887
+ for (const [key, field] of Object.entries(schema.fields)) {
1888
+ if (field.type !== "rollup") continue;
1889
+ if (out === record) out = { ...record };
1890
+ const source = linked[field.from];
1891
+ const selfId = String(record[schema.primaryKey] ?? "");
1892
+ out[key] = source ? rollupValue(field, selfId, Object.values(source.byId)) : null;
1893
+ }
1894
+ return out;
1895
+ }
1615
1896
  /** Enrich records with every host-computed field: derived formulas
1616
1897
  * evaluated (cross-collection derefs included), toggles projected,
1617
1898
  * embeds resolved. Loads each linked collection ONCE per call. Input
@@ -1620,7 +1901,7 @@ async function enrichItems(collection, items, opts = {}) {
1620
1901
  const { schema } = collection;
1621
1902
  const linked = await loadLinkedTargets(schema, opts);
1622
1903
  const refRecords = toRefRecords(linked);
1623
- return items.map((item) => projectComputed(schema, deriveAll(schema, item, refRecords), linked));
1904
+ return items.map((item) => projectComputed(schema, deriveAll(schema, projectRollups(schema, item, linked), refRecords), linked));
1624
1905
  }
1625
1906
  //#endregion
1626
1907
  //#region src/collection/server/dynamicIcon.ts
@@ -2145,6 +2426,6 @@ async function deleteCustomView(collection, viewId, opts = {}) {
2145
2426
  };
2146
2427
  }
2147
2428
  //#endregion
2148
- export { publishCollectionChange as $, deleteItem as A, writeFileAtomic as B, validateCollectionRecords as C, recordFieldProblem as D, compileRecordZ as E, readCustomViewI18n as F, resolveDataDir as G, isContainedInRoot as H, readItem as I, safeSlugName as J, resolveTemplatePath as K, readSkillTemplate as L, listItems as M, promptPathsFor as N, buildActionSeedPrompt as O, readCustomViewHtml as P, log as Q, resolveCreateItemId as R, CollectionSchemaZ as S, COMPUTED_TYPES as T, isContainedInWorkspace as U, SCHEMA_FILE as V, itemFilePath as W, configureCollectionHost as X, collectionsRegistriesConfigPath as Y, getWorkspaceRoot as Z, acceptParsedSchema as _, computeSuccessor as a, toDetail as b, isTriggerDue as c, resolveEvery as d, setCollectionChangePublisher as et, successorId as f, enrichItems as g, computeCollectionIcon as h, advanceTriggerDate as i, generateItemId as j, buildCollectionActionSeedPrompt as k, maybeSpawnSuccessor as l, errorMessage as m, deleteCollection as n, daysInMonth as o, ONE_SECOND_MS as p, safeRecordId as q, deleteCollectionRefusalMessage as r, formatCivil as s, deleteCustomView as t, parseCivil as u, discoverCollections as v, validateRecordObject as w, toSummary as x, loadCollection as y, writeItem as z };
2429
+ export { collectionsRegistriesConfigPath as $, recordFieldProblem as A, readItem as B, toSummary as C, validateCollectionRecords as D, firstMutateParamProblem as E, isRegularFile as F, SCHEMA_FILE as G, resolveCreateItemId as H, listItems as I, itemFilePath as J, isContainedInRoot as K, promptPathsFor as L, buildCollectionActionSeedPrompt as M, deleteItem as N, validateRecordObject as O, generateItemId as P, safeSlugName as Q, readCustomViewHtml as R, toDetail as S, applyMutateAction as T, writeItem as U, readSkillTemplate as V, writeFileAtomic as W, resolveTemplatePath as X, resolveDataDir as Y, safeRecordId as Z, buildWorkspaceOntology as _, computeSuccessor as a, discoverCollections as b, isTriggerDue as c, resolveEvery as d, configureCollectionHost as et, successorId as f, enrichItems as g, computeCollectionIcon as h, advanceTriggerDate as i, setCollectionChangePublisher as it, buildActionSeedPrompt as j, compileRecordZ as k, maybeSpawnSuccessor as l, errorMessage as m, deleteCollection as n, log as nt, daysInMonth as o, ONE_SECOND_MS as p, isContainedInWorkspace as q, deleteCollectionRefusalMessage as r, publishCollectionChange as rt, formatCivil as s, deleteCustomView as t, getWorkspaceRoot as tt, parseCivil as u, schemaRelations as v, CollectionSchemaZ as w, loadCollection as x, acceptParsedSchema as y, readCustomViewI18n as z };
2149
2430
 
2150
- //# sourceMappingURL=server-Cc6XcyOA.js.map
2431
+ //# sourceMappingURL=server-0IVR-Y_G.js.map