@mulmoclaude/core 0.13.1 → 0.15.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 (59) hide show
  1. package/assets/helps/billing-clients-worklog.md +15 -12
  2. package/assets/helps/collection-skills.md +67 -22
  3. package/assets/helps/todo-collection.md +13 -7
  4. package/dist/{deriveAll-BQ_p5HSB.cjs → calendarGrid-CaR-q36Y.js} +352 -74
  5. package/dist/calendarGrid-CaR-q36Y.js.map +1 -0
  6. package/dist/calendarGrid-gN4sei7s.cjs +1070 -0
  7. package/dist/calendarGrid-gN4sei7s.cjs.map +1 -0
  8. package/dist/collection/core/backlinks.d.ts +20 -0
  9. package/dist/collection/core/ids.d.ts +12 -0
  10. package/dist/collection/core/recordZ.d.ts +23 -0
  11. package/dist/collection/core/schema.d.ts +72 -418
  12. package/dist/collection/core/schemaZ.d.ts +878 -0
  13. package/dist/collection/core/templatePath.d.ts +34 -0
  14. package/dist/collection/core/uiTypes.d.ts +22 -0
  15. package/dist/collection/core/where.d.ts +9 -15
  16. package/dist/collection/index.cjs +50 -330
  17. package/dist/collection/index.cjs.map +1 -1
  18. package/dist/collection/index.d.ts +2 -0
  19. package/dist/collection/index.js +14 -301
  20. package/dist/collection/index.js.map +1 -1
  21. package/dist/collection/paths.cjs +1 -1
  22. package/dist/collection/paths.cjs.map +1 -1
  23. package/dist/collection/paths.js +1 -1
  24. package/dist/collection/paths.js.map +1 -1
  25. package/dist/collection/registry/server/index.cjs +1 -1
  26. package/dist/collection/registry/server/index.js +1 -1
  27. package/dist/collection/server/discovery.d.ts +2 -244
  28. package/dist/collection/server/index.cjs +8 -2
  29. package/dist/collection/server/index.d.ts +1 -0
  30. package/dist/collection/server/index.js +3 -2
  31. package/dist/collection/server/io.d.ts +9 -0
  32. package/dist/collection/server/ontology.d.ts +35 -0
  33. package/dist/collection/server/templatePath.d.ts +1 -34
  34. package/dist/collection/server/validate.d.ts +11 -7
  35. package/dist/collection-watchers/index.cjs +5 -4
  36. package/dist/collection-watchers/index.cjs.map +1 -1
  37. package/dist/collection-watchers/index.js +3 -2
  38. package/dist/collection-watchers/index.js.map +1 -1
  39. package/dist/feeds/index.cjs +4 -4
  40. package/dist/feeds/index.js +2 -2
  41. package/dist/feeds/ingestTypes.d.ts +15 -40
  42. package/dist/feeds/server/index.cjs +5 -5
  43. package/dist/feeds/server/index.js +3 -3
  44. package/dist/{ingestTypes-C7EheYZX.cjs → ingestTypes-DG8RExWt.cjs} +3 -3
  45. package/dist/ingestTypes-DG8RExWt.cjs.map +1 -0
  46. package/dist/{ingestTypes-CmJeOUJc.js → ingestTypes-V4upKeBx.js} +2 -2
  47. package/dist/ingestTypes-V4upKeBx.js.map +1 -0
  48. package/dist/{server-DKXXFTbw.js → server-BNTR7ySK.js} +531 -196
  49. package/dist/server-BNTR7ySK.js.map +1 -0
  50. package/dist/{server-G6GtOdaW.cjs → server-bsNrf2yW.cjs} +572 -213
  51. package/dist/server-bsNrf2yW.cjs.map +1 -0
  52. package/package.json +1 -1
  53. package/dist/deriveAll-BQ_p5HSB.cjs.map +0 -1
  54. package/dist/deriveAll-CMFXDQ_G.js +0 -506
  55. package/dist/deriveAll-CMFXDQ_G.js.map +0 -1
  56. package/dist/ingestTypes-C7EheYZX.cjs.map +0 -1
  57. package/dist/ingestTypes-CmJeOUJc.js.map +0 -1
  58. package/dist/server-DKXXFTbw.js.map +0 -1
  59. package/dist/server-G6GtOdaW.cjs.map +0 -1
@@ -1,4 +1,4 @@
1
- import { a as resolveIcon, c as AGENT_INGEST_KIND, d as embedTargetId, f as isFieldDrivenEvery, i as firstDateField, l as FEED_SCHEDULES, o as selectDynamicRecord, t as deriveAll, u as INGEST_KINDS } from "./deriveAll-CMFXDQ_G.js";
1
+ import { A as isSafeSlug, C as projectBacklinkRow, D as SAFE_RECORD_ID_PATTERN, F as embedTargetId, I as isFieldDrivenEvery, M as COMPUTED_TYPES, N as FEED_SCHEDULES, O as SAFE_SLUG_PATTERN, P as INGEST_KINDS, S as backlinkRows, b as selectDynamicRecord, h as deriveAll, j as AGENT_INGEST_KIND, k as isSafeRecordId, l as parseIsoDate, u as parseIsoDateTime, v as firstDateField, y as resolveIcon } from "./calendarGrid-CaR-q36Y.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";
@@ -78,7 +78,6 @@ var log = {
78
78
  //#endregion
79
79
  //#region src/collection/server/paths.ts
80
80
  var SCHEMA_FILE = "schema.json";
81
- var SAFE_SLUG_PATTERN = /^[a-zA-Z0-9](?:[a-zA-Z0-9_-]*[a-zA-Z0-9])?$/;
82
81
  /** Sanitise a user-supplied slug into a safe directory-name leaf.
83
82
  * Returns null for anything that fails the slug whitelist OR isn't a
84
83
  * basename (i.e. survives `path.basename` round-trip unchanged).
@@ -91,7 +90,6 @@ function safeSlugName(slug) {
91
90
  if (basename !== slug) return null;
92
91
  return basename;
93
92
  }
94
- var SAFE_RECORD_ID_PATTERN = /^[a-zA-Z0-9](?:[a-zA-Z0-9_.-]*[a-zA-Z0-9])?$/;
95
93
  /** Sanitise a user-supplied record id into a safe filename stem. Like
96
94
  * `safeSlugName` but tolerates interior dots (so natural keys work),
97
95
  * while still rejecting any `..` substring, path separators, and
@@ -239,7 +237,9 @@ async function writeFileAtomic(filePath, content) {
239
237
  * inside an otherwise-contained data dir — without this, a record
240
238
  * file could symlink to /etc/passwd and the detail endpoint would
241
239
  * happily serve it. Returns false on ENOENT and on any other lstat
242
- * 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. */
243
243
  async function isRegularFile(filePath) {
244
244
  try {
245
245
  return (await lstat(filePath)).isFile();
@@ -634,13 +634,112 @@ ${dataJson}
634
634
  ${templateText}`;
635
635
  }
636
636
  //#endregion
637
+ //#region src/collection/core/recordZ.ts
638
+ /** The emptiness rule shared by `required` and the "only check present
639
+ * values" gate. NOT a truthiness check — `0` and `false` are filled. */
640
+ var isEmptyValue = (value) => value === void 0 || value === null || value === "";
641
+ /** The historical write-gate checks, verbatim: required non-empty, enum
642
+ * membership (compared as strings, so a numeric `5` satisfies `"5"`). */
643
+ function enforcedProblem(key, spec, value) {
644
+ const empty = isEmptyValue(value);
645
+ if (spec.required && empty) return `missing required field '${key}'`;
646
+ if (!empty && spec.type === "enum" && !spec.values.includes(String(value))) return `'${key}' = '${String(value)}' is not one of [${spec.values.join(", ")}]`;
647
+ return null;
648
+ }
649
+ /** Numeric coercion for the strict `number`/`money` check: a plain number,
650
+ * or a non-blank numeric string (renderers coerce those via `Number(...)`,
651
+ * so they display fine). Anything else — arrays (`[]` stringifies to `""`
652
+ * = 0, `[42]` to `"42"`), booleans, objects — is NaN and gets flagged. */
653
+ function coerceNumeric(value) {
654
+ if (typeof value === "number") return value;
655
+ if (typeof value === "string" && value.trim() !== "") return Number(value);
656
+ return NaN;
657
+ }
658
+ /** Report-only per-type checks on a PRESENT value. Date / datetime reuse the
659
+ * calendar's STRICT civil parsers (`parseIsoDate` / `parseIsoDateTime`), so
660
+ * the lint flags exactly the values the calendar / trigger / spawn code
661
+ * would silently drop — impossible days like `2026-02-30`, and datetimes
662
+ * outside the canonical `YYYY-MM-DDTHH:MM[:SS]` shape (e.g. a `Z` suffix,
663
+ * which the day view can't place). `string`-backed types accept anything
664
+ * stringifiable; `ref` existence is out of scope. */
665
+ function strictTypeProblem(key, spec, value) {
666
+ switch (spec.type) {
667
+ case "number":
668
+ case "money": return Number.isFinite(coerceNumeric(value)) ? null : `'${key}' = '${String(value)}' is not numeric (a '${spec.type}' field stores a plain number)`;
669
+ case "boolean": return value === true || value === false ? null : `'${key}' = '${String(value)}' is not a boolean (store true or false, unquoted)`;
670
+ case "date": return parseIsoDate(value) !== null ? null : `'${key}' = '${String(value)}' is not a real YYYY-MM-DD date`;
671
+ case "datetime": return 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)`;
672
+ default: return null;
673
+ }
674
+ }
675
+ /** Strict check for a PRESENT `table` value: an array of row objects, each
676
+ * row conforming to the sub-schema (required / enum / typed sub-values).
677
+ * First row problem wins, prefixed with the row number so the fix is
678
+ * locatable. */
679
+ function strictTableProblem(key, spec, value) {
680
+ if (!Array.isArray(value)) return `'${key}' = '${String(value)}' is not an array of rows (a 'table' field stores an array of row objects)`;
681
+ for (let index = 0; index < value.length; index++) {
682
+ const row = value[index];
683
+ if (!row || typeof row !== "object" || Array.isArray(row)) return `'${key}' row ${index + 1} is not an object`;
684
+ for (const [subKey, subSpec] of Object.entries(spec.of)) {
685
+ const subValue = row[subKey];
686
+ const problem = enforcedProblem(subKey, subSpec, subValue) ?? (isEmptyValue(subValue) ? null : strictTypeProblem(subKey, subSpec, subValue));
687
+ if (problem) return `'${key}' row ${index + 1}: ${problem}`;
688
+ }
689
+ }
690
+ return null;
691
+ }
692
+ /** First problem for one field's stored value under `tier`, or null.
693
+ * Enforced checks always run (and their messages never vary by tier — the
694
+ * scan and the write gate must agree on them); strict adds the per-type
695
+ * layer on present values only. */
696
+ function recordFieldProblem(key, spec, value, tier) {
697
+ const enforced = enforcedProblem(key, spec, value);
698
+ if (enforced || tier === "enforced") return enforced;
699
+ if (isEmptyValue(value)) return null;
700
+ if (spec.type === "table") return strictTableProblem(key, spec, value);
701
+ return strictTypeProblem(key, spec, value);
702
+ }
703
+ var compiled = /* @__PURE__ */ new WeakMap();
704
+ /** Compile `schema.fields` into a zod validator for a stored record.
705
+ * Loose object: unknown keys are allowed and any declared key may be
706
+ * absent (records are user files, not parse-and-rewrite targets —
707
+ * callers validate, they never persist the parse output). The checks run
708
+ * as ONE object-level refine iterating fields in declaration order —
709
+ * per-key shape schemas can't express "key may be absent BUT its absence
710
+ * must still reach the required check", and the single loop keeps the
711
+ * first reported issue identical to the historical first-problem-wins
712
+ * contract. */
713
+ function compileRecordZ(schema, tier) {
714
+ const cached = compiled.get(schema)?.[tier];
715
+ if (cached) return cached;
716
+ const stored = Object.entries(schema.fields).filter(([, spec]) => !COMPUTED_TYPES.has(spec.type));
717
+ const validator = z.looseObject({}).superRefine((record, ctx) => {
718
+ for (const [key, spec] of stored) {
719
+ const problem = recordFieldProblem(key, spec, record[key], tier);
720
+ if (problem) ctx.addIssue({
721
+ code: "custom",
722
+ message: problem,
723
+ path: [key]
724
+ });
725
+ }
726
+ });
727
+ const entry = compiled.get(schema) ?? {};
728
+ entry[tier] = validator;
729
+ compiled.set(schema, entry);
730
+ return validator;
731
+ }
732
+ /** First schema problem on an in-memory record under `tier`, or null. One
733
+ * issue per record keeps the report short and the fix obvious (the
734
+ * historical contract of `validateRecordObject`). */
735
+ function firstRecordProblem(record, schema, tier) {
736
+ const result = compileRecordZ(schema, tier).safeParse(record);
737
+ if (result.success) return null;
738
+ return result.error.issues[0]?.message ?? "record failed schema validation";
739
+ }
740
+ //#endregion
637
741
  //#region src/collection/server/validate.ts
638
742
  var MAX_ISSUES = 25;
639
- var COMPUTED_TYPES = /* @__PURE__ */ new Set([
640
- "derived",
641
- "embed",
642
- "toggle"
643
- ]);
644
743
  /** Read every `<id>.json` under the collection's dataDir and report the
645
744
  * ones that won't load or violate the schema. An empty list means every
646
745
  * record is fine. */
@@ -703,72 +802,70 @@ async function inspectRecord(fullPath, name, schema) {
703
802
  file: name,
704
803
  problem: "not a JSON object — skipped, won't appear"
705
804
  };
706
- const problem = validateRecordObject(parsed, name.replace(/\.json$/, ""), schema);
805
+ const problem = validateRecordObject(parsed, name.replace(/\.json$/, ""), schema, "strict");
707
806
  return problem ? {
708
807
  file: name,
709
808
  problem
710
809
  } : null;
711
810
  }
712
811
  /** First schema problem on an in-memory record (primaryKey↔id mismatch,
713
- * missing required, bad enum value), or null when it's fine. One issue
714
- * per record keeps the report short and the fix obvious. Pure +
715
- * exported so write paths (manageCollection putItems) can gate on the
716
- * SAME rules the post-hoc file scan reports `itemId` is the id the
717
- * record is (or would be) stored under. */
718
- function validateRecordObject(record, itemId, schema) {
812
+ * then the compiled per-field checks see `../core/recordZ` for the two
813
+ * tiers), or null when it's fine. One issue per record keeps the report
814
+ * short and the fix obvious. Pure + exported so write paths
815
+ * (manageCollection putItems) can gate on the SAME enforced rules the
816
+ * post-hoc file scan reports — `itemId` is the id the record is (or
817
+ * would be) stored under. The default `"enforced"` tier keeps every
818
+ * write gate on the historical three checks; only pass `"strict"` from
819
+ * report-only surfaces. */
820
+ function validateRecordObject(record, itemId, schema, tier = "enforced") {
719
821
  const idValue = record[schema.primaryKey];
720
822
  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`;
721
- for (const [field, spec] of Object.entries(schema.fields)) {
722
- if (COMPUTED_TYPES.has(spec.type)) continue;
723
- const value = record[field];
724
- const empty = value === void 0 || value === null || value === "";
725
- if (spec.required && empty) return `missing required field '${field}'`;
726
- if (!empty && spec.type === "enum" && spec.values && !spec.values.includes(String(value))) return `'${field}' = '${String(value)}' is not one of [${spec.values.join(", ")}]`;
727
- }
728
- return null;
823
+ return firstRecordProblem(record, schema, tier);
729
824
  }
730
825
  //#endregion
731
- //#region src/collection/server/discovery.ts
732
- var refRefine = (spec) => {
733
- if (spec.type !== "ref") return true;
734
- if (typeof spec.to !== "string") return false;
735
- return safeSlugName(spec.to) !== null;
736
- };
737
- var refMessage = {
738
- message: "fields with type 'ref' must declare a `to` that is a valid collection slug (alphanumeric / hyphen / underscore, no path separators)",
739
- path: ["to"]
740
- };
741
- var isDateLike = (type) => type === "date" || type === "datetime";
742
- var isTimeStringField = (type) => type === "string" || type === "text";
743
- var embedRefine = (spec) => {
744
- if (spec.type !== "embed") return true;
745
- if (typeof spec.to !== "string" || safeSlugName(spec.to) === null) return false;
746
- return (typeof spec.id === "string" && spec.id.trim().length > 0) !== (typeof spec.idField === "string" && spec.idField.trim().length > 0);
747
- };
748
- var embedMessage = {
749
- 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)",
750
- path: ["id"]
751
- };
752
- var enumRefine = (spec) => spec.type !== "enum" || Array.isArray(spec.values) && spec.values.length > 0 && spec.values.every((value) => typeof value === "string" && value.length > 0);
753
- var enumMessage = {
754
- message: "fields with type 'enum' must declare a non-empty `values` array of non-empty strings",
755
- path: ["values"]
826
+ //#region src/collection/core/schemaZ.ts
827
+ /** Optional visibility predicate shared by actions and fields: the target
828
+ * shows only when the open record's `field` (stringified) is one of `in`.
829
+ * Domain-free `field` is any non-empty key, `in` a non-empty array of
830
+ * non-empty values; the host never interprets the meaning.
831
+ *
832
+ * `trim().min(1)` rather than bare `min(1)` so a whitespace-only string
833
+ * (" ") fails validation otherwise the cell formatter / dropdown would
834
+ * render visual blanks that look like missing data. Applied consistently to
835
+ * every "non-empty string" slot in this file (CodeRabbit PR #1497). */
836
+ var WhenZ = z.object({
837
+ field: z.string().trim().min(1),
838
+ in: z.array(z.string().trim().min(1)).min(1)
839
+ });
840
+ var fieldBase = {
841
+ label: z.string().min(1),
842
+ primary: z.boolean().optional(),
843
+ required: z.boolean().optional(),
844
+ when: WhenZ.optional()
756
845
  };
757
- var currencyRefine = (spec) => {
758
- if (!(spec.type === "money" || spec.type === "derived" && spec.display === "money")) return true;
759
- const hasLiteral = typeof spec.currency === "string" && spec.currency.trim().length > 0;
760
- const hasPointer = typeof spec.currencyField === "string" && spec.currencyField.trim().length > 0;
761
- return hasLiteral || hasPointer;
846
+ var currencyKeys = {
847
+ currency: z.string().trim().min(1).optional(),
848
+ currencyField: z.string().trim().min(1).optional()
762
849
  };
850
+ var hasCurrencySource = (spec) => spec.currency !== void 0 || spec.currencyField !== void 0;
763
851
  var currencyMessage = {
764
852
  message: "fields that render as money (type 'money', or 'derived' with display 'money') must declare either a literal `currency` (ISO 4217 code, e.g. 'USD', 'JPY') or a `currencyField` naming the record field that holds the code",
765
853
  path: ["currency"]
766
854
  };
767
- var WhenSchema = z.object({
768
- field: z.string().trim().min(1),
769
- in: z.array(z.string().trim().min(1)).min(1)
855
+ var slugMessage = (key) => ({
856
+ message: `\`${key}\` must be a valid collection slug (alphanumeric / hyphen / underscore, no path separators)`,
857
+ path: [key]
770
858
  });
771
- var SubFieldSpecSchema = z.object({
859
+ /** The plain scalar field types. Stored and edited as primitive values; no
860
+ * variant-specific keys.
861
+ * - `image`: a workspace-relative image path (e.g. a `data/attachments/...`
862
+ * upload); rendered as an <img> in the detail view (not the list table —
863
+ * a per-row fetch is too expensive at scale). Stored as a plain string.
864
+ * - `file`: a workspace-relative file path as a plain string (e.g. an
865
+ * `artifacts/html/<name>.html` app). Rendered as a clickable link in both
866
+ * the list table and the detail view: HTML / SVG artifacts open their
867
+ * rendered form in a new tab; any other path opens in the File Explorer. */
868
+ var ScalarFieldZ = z.object({
772
869
  type: z.enum([
773
870
  "string",
774
871
  "text",
@@ -778,18 +875,39 @@ var SubFieldSpecSchema = z.object({
778
875
  "datetime",
779
876
  "boolean",
780
877
  "markdown",
781
- "ref",
782
- "money",
783
- "enum"
878
+ "image",
879
+ "file"
784
880
  ]),
881
+ ...fieldBase
882
+ });
883
+ /** A link to another collection: the record stores the target item's
884
+ * primary-key slug and the host renders a clickable link + dropdown picker.
885
+ * `to` must be a real slug (not `../foo`, not `mc-clients/extra` — see
886
+ * Codex P2 on PR #1495); whether the target collection exists resolves
887
+ * fail-soft at render time, never here. */
888
+ var RefFieldZ = z.object({
889
+ type: z.literal("ref"),
890
+ ...fieldBase,
891
+ to: z.string().min(1)
892
+ }).refine((spec) => isSafeSlug(spec.to), slugMessage("to"));
893
+ /** A money amount. See `currencyKeys` for the currency-source contract. */
894
+ var MoneyFieldZ = z.object({
895
+ type: z.literal("money"),
896
+ ...fieldBase,
897
+ ...currencyKeys
898
+ }).refine(hasCurrencySource, currencyMessage);
899
+ /** A closed set of allowed string values. The form renders a `<select>`
900
+ * populated from `values`; storage is a plain string. */
901
+ var EnumFieldZ = z.object({
902
+ type: z.literal("enum"),
903
+ ...fieldBase,
904
+ values: z.array(z.string().trim().min(1)).min(1)
905
+ });
906
+ var subFieldBase = {
785
907
  label: z.string().min(1),
786
- required: z.boolean().optional(),
787
- to: z.string().min(1).optional(),
788
- currency: z.string().trim().min(1).optional(),
789
- currencyField: z.string().trim().min(1).optional(),
790
- values: z.array(z.string().trim().min(1)).min(1).optional()
791
- }).refine(refRefine, refMessage).refine(enumRefine, enumMessage).refine(currencyRefine, currencyMessage);
792
- var FieldSpecSchema = z.object({
908
+ required: z.boolean().optional()
909
+ };
910
+ var SubScalarFieldZ = z.object({
793
911
  type: z.enum([
794
912
  "string",
795
913
  "text",
@@ -798,62 +916,145 @@ var FieldSpecSchema = z.object({
798
916
  "date",
799
917
  "datetime",
800
918
  "boolean",
801
- "markdown",
802
- "ref",
803
- "money",
804
- "enum",
805
- "table",
806
- "derived",
807
- "embed",
808
- "image",
809
- "file",
810
- "toggle"
919
+ "markdown"
811
920
  ]),
812
- label: z.string().min(1),
813
- primary: z.boolean().optional(),
814
- required: z.boolean().optional(),
815
- to: z.string().min(1).optional(),
816
- id: z.string().trim().min(1).optional(),
817
- idField: z.string().trim().min(1).optional(),
818
- currency: z.string().trim().min(1).optional(),
819
- currencyField: z.string().trim().min(1).optional(),
820
- values: z.array(z.string().trim().min(1)).min(1).optional(),
821
- field: z.string().trim().min(1).optional(),
822
- onValue: z.string().trim().min(1).optional(),
823
- offValue: z.string().trim().min(1).optional(),
824
- of: z.record(z.string(), SubFieldSpecSchema).optional(),
825
- formula: z.string().trim().min(1).optional(),
826
- /** Inner type to render a derived value as (e.g. `"money"`).
827
- * Restricted to the non-composite display targets — derived
828
- * values are scalars, so rendering them via `table` or another
829
- * `derived` would be meaningless. */
921
+ ...subFieldBase
922
+ });
923
+ var SubRefFieldZ = z.object({
924
+ type: z.literal("ref"),
925
+ ...subFieldBase,
926
+ to: z.string().min(1)
927
+ }).refine((spec) => isSafeSlug(spec.to), slugMessage("to"));
928
+ var SubMoneyFieldZ = z.object({
929
+ type: z.literal("money"),
930
+ ...subFieldBase,
931
+ ...currencyKeys
932
+ }).refine(hasCurrencySource, currencyMessage);
933
+ var SubEnumFieldZ = z.object({
934
+ type: z.literal("enum"),
935
+ ...subFieldBase,
936
+ values: z.array(z.string().trim().min(1)).min(1)
937
+ });
938
+ var SubFieldSpecZ = z.discriminatedUnion("type", [
939
+ SubScalarFieldZ,
940
+ SubRefFieldZ,
941
+ SubMoneyFieldZ,
942
+ SubEnumFieldZ
943
+ ]);
944
+ /** A flat sub-table: each row is a record of `of`'s sub-schema (insertion
945
+ * order = column order). v0 disallows nested tables and derived columns to
946
+ * keep the editor + evaluator simple. */
947
+ var TableFieldZ = z.object({
948
+ type: z.literal("table"),
949
+ ...fieldBase,
950
+ of: z.record(z.string(), SubFieldSpecZ)
951
+ }).refine((spec) => Object.keys(spec.of).length > 0, {
952
+ message: "fields with type 'table' must declare a non-empty `of` (sub-schema for each row)",
953
+ path: ["of"]
954
+ });
955
+ /** A computed scalar: `formula` is a tiny expression evaluated against the
956
+ * record — `+ - * /`, parens, identifier refs to top-level fields,
957
+ * `sum(tableField[].col)`, and `sum(tableField[].col * tableField[].col)`
958
+ * (see `./derivedFormula`). `display` picks the inner type the value renders
959
+ * as (default `"number"`) — restricted to the non-composite display targets,
960
+ * since a derived value is a scalar. Never stored; computed by `deriveAll`
961
+ * on both server and client. */
962
+ var DerivedFieldZ = z.object({
963
+ type: z.literal("derived"),
964
+ ...fieldBase,
965
+ formula: z.string().trim().min(1),
830
966
  display: z.enum([
831
967
  "string",
832
968
  "number",
833
969
  "money",
834
970
  "date"
835
971
  ]).optional(),
836
- when: WhenSchema.optional()
837
- }).refine(refRefine, refMessage).refine(enumRefine, enumMessage).refine(embedRefine, embedMessage).refine(currencyRefine, currencyMessage).refine((spec) => spec.type !== "table" || spec.of !== void 0 && Object.keys(spec.of).length > 0, {
838
- message: "fields with type 'table' must declare a non-empty `of` (sub-schema for each row)",
839
- path: ["of"]
840
- }).refine((spec) => spec.type !== "derived" || typeof spec.formula === "string" && spec.formula.length > 0, {
841
- message: "fields with type 'derived' must declare a non-empty `formula` (see src/utils/collections/derivedFormula.ts)",
842
- path: ["formula"]
843
- }).refine((spec) => spec.type !== "toggle" || typeof spec.field === "string" && spec.field.length > 0 && typeof spec.onValue === "string" && typeof spec.offValue === "string", {
844
- message: "fields with type 'toggle' must declare `field` (the enum field to project), `onValue`, and `offValue`",
845
- path: ["field"]
972
+ ...currencyKeys
973
+ }).refine((spec) => spec.display !== "money" || hasCurrencySource(spec), currencyMessage);
974
+ /** Pulls a record from another collection into the read-only detail view.
975
+ * Display-only — nothing is stored on this record, so it never appears in
976
+ * the list table or the edit form. Must declare a valid `to` slug (same
977
+ * path-traversal guard as `ref`) AND exactly one of `id` (a fixed target
978
+ * record, e.g. `me` for the singleton profile — same for every record) or
979
+ * `idField` (a sibling top-level field naming the per-record target, e.g.
980
+ * an invoice's `issuerId` selecting which profile to embed as the bill-from
981
+ * block; an absent/empty value resolves fail-soft to "no record"). The
982
+ * `idField` target is validated to be a real `ref`/`string` field by a
983
+ * schema-level refine below. */
984
+ var EmbedFieldZ = z.object({
985
+ type: z.literal("embed"),
986
+ ...fieldBase,
987
+ to: z.string().min(1),
988
+ id: z.string().trim().min(1).optional(),
989
+ idField: z.string().trim().min(1).optional()
990
+ }).refine((spec) => isSafeSlug(spec.to) && spec.id !== void 0 !== (spec.idField !== void 0), {
991
+ 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)",
992
+ path: ["id"]
846
993
  });
847
- var ActionSpecSchema = z.object({
994
+ /** Display-only REVERSE refs (plan step ② of plans/collection-ontology.md):
995
+ * a read-only sub-table of the records in collection `from` whose `via`
996
+ * ref field stores THIS record's primary key. Stores nothing (joins
997
+ * `COMPUTED_TYPES`); resolution is shared server/client via
998
+ * `core/backlinks.ts`. `display` names the `from` columns to show;
999
+ * `filter` (the standard `when` shape, matched against each SOURCE
1000
+ * record) narrows the rows. Validation is shape-only, like `embed`:
1001
+ * `from` must be a safe slug, but whether it exists — and whether `via` /
1002
+ * `display` name real fields there — resolves fail-soft at render
1003
+ * (empty sub-table). Do NOT add cross-schema existence checks here. */
1004
+ var BacklinksFieldZ = z.object({
1005
+ type: z.literal("backlinks"),
1006
+ ...fieldBase,
1007
+ from: z.string().min(1),
1008
+ via: z.string().trim().min(1),
1009
+ display: z.array(z.string().trim().min(1)).min(1),
1010
+ filter: WhenZ.optional()
1011
+ }).refine((spec) => isSafeSlug(spec.from), slugMessage("from"));
1012
+ /** A checkbox that is a pure PROJECTION of an `enum` field — it stores
1013
+ * nothing of its own. Checked when the enum named by `field` equals
1014
+ * `onValue`; toggling writes `onValue` / `offValue` back to that enum
1015
+ * field. Lets a "done" checkbox front a kanban `status` field with the enum
1016
+ * as the single source of truth (no separate stored boolean to keep in
1017
+ * sync). `field` / `onValue` / `offValue` are validated against the target
1018
+ * enum's `values` by a schema-level refine below. */
1019
+ var ToggleFieldZ = z.object({
1020
+ type: z.literal("toggle"),
1021
+ ...fieldBase,
1022
+ field: z.string().trim().min(1),
1023
+ onValue: z.string().trim().min(1),
1024
+ offValue: z.string().trim().min(1)
1025
+ });
1026
+ var FieldSpecZ = z.discriminatedUnion("type", [
1027
+ ScalarFieldZ,
1028
+ RefFieldZ,
1029
+ MoneyFieldZ,
1030
+ EnumFieldZ,
1031
+ TableFieldZ,
1032
+ DerivedFieldZ,
1033
+ EmbedFieldZ,
1034
+ BacklinksFieldZ,
1035
+ ToggleFieldZ
1036
+ ]);
1037
+ /** A schema-declared record action, rendered as a button in the read-only
1038
+ * detail view. Domain-free: the host validates the shape; the meaning
1039
+ * (which role, which template) is data. v1 ships only `kind: "chat"` —
1040
+ * start a new chat in `role` with a templated seed prompt; the enum
1041
+ * reserves room for a future `"mutate"` (status transitions) without
1042
+ * another schema-shape change. */
1043
+ var ActionSpecZ = z.object({
848
1044
  id: z.string().trim().min(1),
849
1045
  label: z.string().trim().min(1),
850
1046
  icon: z.string().trim().min(1).optional(),
851
1047
  kind: z.enum(["chat"]),
852
1048
  role: z.string().trim().min(1),
853
1049
  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)"),
854
- when: WhenSchema.optional()
1050
+ when: WhenZ.optional()
855
1051
  });
856
- var CustomViewSchema = z.object({
1052
+ /** A custom (LLM-authored) HTML view registration. Domain-free: the host
1053
+ * validates the shape; the view's behaviour lives in the HTML file. `file`
1054
+ * is constrained to `views/*.html` (path-safe) so the view-file reader can
1055
+ * never reach the data folder or the schema/template files. `id` is
1056
+ * validated to be a real slug + unique by schema-level refines below. */
1057
+ var CustomViewZ = z.object({
857
1058
  id: z.string().trim().min(1),
858
1059
  label: z.string().trim().min(1),
859
1060
  icon: z.string().trim().min(1).optional(),
@@ -866,7 +1067,14 @@ var CustomViewSchema = z.object({
866
1067
  imageFields: z.array(z.string().trim().min(1)).optional(),
867
1068
  imageMaxEdge: z.number().int().min(1).optional()
868
1069
  });
869
- var EveryLiteralSchema = z.object({
1070
+ /** Recurrence advance for `spawn.every`. `interval` is a positive integer
1071
+ * count of `unit`s (`interval: 3` + `unit: "month"` = quarterly);
1072
+ * `dayOfMonth` (month/year only) is the CANONICAL day-of-month anchor
1073
+ * (1-31, read from the rule and clamped per-month at compute time so "31st
1074
+ * of every month" never drifts) or the `"last"` sentinel for end-of-month.
1075
+ * `.strict()` so the union below cleanly rejects an object carrying BOTH
1076
+ * `unit` and `fromField` (it fails this arm on the unknown `fromField`). */
1077
+ var EveryLiteralZ = z.object({
870
1078
  unit: z.enum([
871
1079
  "day",
872
1080
  "week",
@@ -876,17 +1084,114 @@ var EveryLiteralSchema = z.object({
876
1084
  interval: z.number().int().min(1),
877
1085
  dayOfMonth: z.union([z.number().int().min(1).max(31), z.literal("last")]).optional()
878
1086
  }).strict();
879
- var EveryFieldDrivenSchema = z.object({
1087
+ /** Field-driven recurrence: pick the interval per-record by an `enum`
1088
+ * field's value — one collection can mix daily / weekly / monthly
1089
+ * obligations in a single list. `map` keys are validated to exactly cover
1090
+ * that field's `values` by a `CollectionSchemaZ` refine (which can see the
1091
+ * sibling `fields`); here each map value just has to be a well-formed
1092
+ * literal `every`. `.strict()` mirrors the literal arm so a both-keys
1093
+ * object fails this arm too. */
1094
+ var EveryFieldDrivenZ = z.object({
880
1095
  fromField: z.string().trim().min(1),
881
- map: z.record(z.string(), EveryLiteralSchema)
1096
+ map: z.record(z.string(), EveryLiteralZ)
882
1097
  }).strict();
883
- var EverySchema = z.union([EveryLiteralSchema, EveryFieldDrivenSchema]);
884
- var SpawnSchema = z.object({
885
- when: WhenSchema.optional(),
886
- every: EverySchema,
1098
+ /** Either a single literal interval (applied to every record) or the
1099
+ * field-driven map. Two `.strict()` arms mean "both keys" and "neither
1100
+ * key" both fail validation, with no extra refine. */
1101
+ var EveryZ = z.union([EveryLiteralZ, EveryFieldDrivenZ]);
1102
+ /** Host-driven recurrence: when a record satisfies `when` (default:
1103
+ * "`completionField` value ∈ `completionDoneValues`"), the host creates the
1104
+ * next record with a forward-advanced `triggerField` date. `carry` copies
1105
+ * record fields verbatim onto the successor; `set` forces fixed values
1106
+ * (typically resetting the status field to its pending value). The
1107
+ * successor's id and contents are a pure function of (source record, this
1108
+ * rule); creation is create-if-absent, so the mechanism stays convergent. */
1109
+ var SpawnZ = z.object({
1110
+ when: WhenZ.optional(),
1111
+ every: EveryZ,
887
1112
  carry: z.array(z.string().trim().min(1)).optional(),
888
1113
  set: z.record(z.string(), z.unknown()).optional()
889
1114
  });
1115
+ /** Declarative retrieval config for a Feed (a collection that refills itself
1116
+ * from the internet). `http-json` needs `itemsAt` (a path to the items
1117
+ * array) only when the response body isn't itself the array; rss/atom yield
1118
+ * items natively and ignore it — so no kind-specific requirement here. */
1119
+ var DeclarativeIngestZ = z.object({
1120
+ kind: z.enum(INGEST_KINDS),
1121
+ url: z.string().url(),
1122
+ schedule: z.enum(FEED_SCHEDULES),
1123
+ atHour: z.number().int().min(0).max(23).optional(),
1124
+ itemsAt: z.string().trim().min(1).optional(),
1125
+ map: z.record(z.string().trim().min(1), z.string().trim().min(1)),
1126
+ idFrom: z.string().trim().min(1).optional(),
1127
+ maxItems: z.number().int().min(0).optional()
1128
+ });
1129
+ /** Agent-performed retrieval. Valid on any collection (the primary consumer
1130
+ * is skill-backed collections — feeds keep their declarative kinds). No
1131
+ * `url`/`map`: the worker owns retrieval and record shape, seeded by
1132
+ * `template` + a summary of every record, run in `role`. `template` is
1133
+ * validated the SAME way an action's template is (safe path under
1134
+ * `templates/`), so the skill-bridge mirrors it identically. */
1135
+ var AgentIngestZ = z.object({
1136
+ kind: z.literal(AGENT_INGEST_KIND),
1137
+ schedule: z.enum(FEED_SCHEDULES),
1138
+ atHour: z.number().int().min(0).max(23).optional(),
1139
+ role: z.string().trim().min(1),
1140
+ template: z.string().trim().min(1).refine(isSafeActionTemplatePath, "must be a safe path under `templates/` (e.g. `templates/refresh.md`; no `..`, no leading `/`, no backslash)")
1141
+ });
1142
+ /** `ingest` is a discriminated union on `kind`: the three declarative
1143
+ * retrievers fetch-and-map; `agent` dispatches a hidden worker. Optional on
1144
+ * every schema — skill-backed collections usually omit it; only feeds
1145
+ * discovered from `<workspace>/feeds/` are REQUIRED to carry it (gated by
1146
+ * `acceptParsedSchema`). */
1147
+ var IngestZ = z.discriminatedUnion("kind", [DeclarativeIngestZ, AgentIngestZ]);
1148
+ var ValueRefZ = z.object({
1149
+ record: z.string().trim().min(1).optional(),
1150
+ field: z.string().trim().min(1)
1151
+ });
1152
+ var WhereCondZ = z.object({
1153
+ field: z.string().trim().min(1),
1154
+ op: z.enum([
1155
+ "eq",
1156
+ "ne",
1157
+ "in",
1158
+ "gt",
1159
+ "gte",
1160
+ "lt",
1161
+ "lte",
1162
+ "contains"
1163
+ ]),
1164
+ value: z.union([z.string(), z.array(z.string())]).optional(),
1165
+ valueFrom: ValueRefZ.optional()
1166
+ }).refine((cond) => cond.value !== void 0 !== (cond.valueFrom !== void 0), {
1167
+ message: "a where condition must declare exactly one of `value` (a literal) or `valueFrom` (a reference to another record's field), never both or neither",
1168
+ path: ["value"]
1169
+ }).refine((cond) => cond.value === void 0 || cond.op === "in" === Array.isArray(cond.value), {
1170
+ message: "`in` requires an array `value` (the allowed set); every other op requires a single string `value`",
1171
+ path: ["value"]
1172
+ });
1173
+ var WhereZ = z.array(WhereCondZ);
1174
+ var DynamicIconSourceZ = z.object({
1175
+ collection: z.string().trim().min(1),
1176
+ from: z.enum([
1177
+ "latest",
1178
+ "first",
1179
+ "when"
1180
+ ]).optional(),
1181
+ orderBy: z.string().trim().min(1).optional(),
1182
+ where: WhereZ.optional()
1183
+ });
1184
+ var DynamicIconRuleZ = z.object({
1185
+ where: WhereZ,
1186
+ icon: z.string().trim().min(1)
1187
+ });
1188
+ var DynamicIconSpecZ = z.object({
1189
+ source: DynamicIconSourceZ,
1190
+ rules: z.array(DynamicIconRuleZ),
1191
+ fallback: z.string().trim().min(1).optional()
1192
+ });
1193
+ var isDateLike = (type) => type === "date" || type === "datetime";
1194
+ var isTimeStringField = (type) => type === "string" || type === "text";
890
1195
  var CODE_FIELD_TYPES = /* @__PURE__ */ new Set([
891
1196
  "string",
892
1197
  "text",
@@ -924,7 +1229,7 @@ function spawnSuccessorStartsInert(schema) {
924
1229
  }
925
1230
  function fieldDrivenSpawnEvery(schema) {
926
1231
  const every = schema.spawn?.every;
927
- if (!every || !isFieldDrivenEvery(every)) return null;
1232
+ if (!every || !("fromField" in every)) return null;
928
1233
  return every;
929
1234
  }
930
1235
  function fieldDrivenFromFieldIsEnum(schema) {
@@ -952,93 +1257,30 @@ function fieldDrivenFromFieldCarried(schema) {
952
1257
  }
953
1258
  return (carry ?? []).includes(driven.fromField);
954
1259
  }
955
- var DeclarativeIngestZ = z.object({
956
- kind: z.enum(INGEST_KINDS),
957
- url: z.string().url(),
958
- schedule: z.enum(FEED_SCHEDULES),
959
- atHour: z.number().int().min(0).max(23).optional(),
960
- itemsAt: z.string().trim().min(1).optional(),
961
- map: z.record(z.string().trim().min(1), z.string().trim().min(1)),
962
- idFrom: z.string().trim().min(1).optional(),
963
- maxItems: z.number().int().min(0).optional()
964
- });
965
- var AgentIngestZ = z.object({
966
- kind: z.literal(AGENT_INGEST_KIND),
967
- schedule: z.enum(FEED_SCHEDULES),
968
- atHour: z.number().int().min(0).max(23).optional(),
969
- role: z.string().trim().min(1),
970
- template: z.string().trim().min(1).refine(isSafeActionTemplatePath, "must be a safe path under `templates/` (e.g. `templates/refresh.md`; no `..`, no leading `/`, no backslash)")
971
- });
972
- var IngestSchemaZ = z.discriminatedUnion("kind", [DeclarativeIngestZ, AgentIngestZ]);
973
- var ValueRefZ = z.object({
974
- record: z.string().trim().min(1).optional(),
975
- field: z.string().trim().min(1)
976
- });
977
- var WhereCondZ = z.object({
978
- field: z.string().trim().min(1),
979
- op: z.enum([
980
- "eq",
981
- "ne",
982
- "in",
983
- "gt",
984
- "gte",
985
- "lt",
986
- "lte",
987
- "contains"
988
- ]),
989
- value: z.union([z.string(), z.array(z.string())]).optional(),
990
- valueFrom: ValueRefZ.optional()
991
- }).refine((cond) => cond.value !== void 0 !== (cond.valueFrom !== void 0), {
992
- message: "a where condition must declare exactly one of `value` (a literal) or `valueFrom` (a reference to another record's field), never both or neither",
993
- path: ["value"]
994
- }).refine((cond) => cond.value === void 0 || cond.op === "in" === Array.isArray(cond.value), {
995
- message: "`in` requires an array `value` (the allowed set); every other op requires a single string `value`",
996
- path: ["value"]
997
- });
998
- var WhereZ = z.array(WhereCondZ);
999
- var DynamicIconSourceZ = z.object({
1000
- collection: z.string().trim().min(1),
1001
- from: z.enum([
1002
- "latest",
1003
- "first",
1004
- "when"
1005
- ]).optional(),
1006
- orderBy: z.string().trim().min(1).optional(),
1007
- where: WhereZ.optional()
1008
- });
1009
- var DynamicIconRuleZ = z.object({
1010
- where: WhereZ,
1011
- icon: z.string().trim().min(1)
1012
- });
1013
- var DynamicIconSpecZ = z.object({
1014
- source: DynamicIconSourceZ,
1015
- rules: z.array(DynamicIconRuleZ),
1016
- fallback: z.string().trim().min(1).optional()
1017
- });
1018
1260
  var CollectionSchemaZ = z.object({
1019
1261
  title: z.string().min(1),
1020
1262
  icon: z.string().min(1),
1021
1263
  dataPath: z.string().min(1),
1022
1264
  primaryKey: z.string().min(1),
1023
1265
  singleton: z.string().trim().min(1).optional(),
1024
- fields: z.record(z.string(), FieldSpecSchema),
1025
- actions: z.array(ActionSpecSchema).optional(),
1026
- collectionActions: z.array(ActionSpecSchema).optional(),
1266
+ fields: z.record(z.string(), FieldSpecZ),
1267
+ actions: z.array(ActionSpecZ).optional(),
1268
+ collectionActions: z.array(ActionSpecZ).optional(),
1027
1269
  completionField: z.string().trim().min(1).optional(),
1028
1270
  completionDoneValues: z.array(z.string().trim().min(1)).min(1).optional(),
1029
1271
  displayField: z.string().trim().min(1).optional(),
1030
1272
  triggerField: z.string().trim().min(1).optional(),
1031
1273
  triggerLeadDays: z.number().int().min(0).optional(),
1032
- spawn: SpawnSchema.optional(),
1274
+ spawn: SpawnZ.optional(),
1033
1275
  calendarField: z.string().trim().min(1).optional(),
1034
1276
  calendarEndField: z.string().trim().min(1).optional(),
1035
1277
  calendarTimeField: z.string().trim().min(1).optional(),
1036
1278
  kanbanField: z.string().trim().min(1).optional(),
1037
- views: z.array(CustomViewSchema).optional(),
1038
- notifyWhen: WhenSchema.optional(),
1039
- ingest: IngestSchemaZ.optional(),
1279
+ views: z.array(CustomViewZ).optional(),
1280
+ notifyWhen: WhenZ.optional(),
1281
+ ingest: IngestZ.optional(),
1040
1282
  dynamicIcon: DynamicIconSpecZ.optional()
1041
- }).refine((schema) => schema.singleton === void 0 || safeRecordId(schema.singleton) !== null, {
1283
+ }).refine((schema) => schema.singleton === void 0 || isSafeRecordId(schema.singleton), {
1042
1284
  message: "schema `singleton` must be a valid item id (alphanumeric / hyphen / underscore / interior dot, no `..` or path separators)",
1043
1285
  path: ["singleton"]
1044
1286
  }).refine((schema) => schema.actions === void 0 || new Set(schema.actions.map((action) => action.id)).size === schema.actions.length, {
@@ -1129,13 +1371,15 @@ var CollectionSchemaZ = z.object({
1129
1371
  }).refine((schema) => schema.notifyWhen === void 0 || schema.fields[schema.notifyWhen.field] !== void 0, {
1130
1372
  message: "schema `notifyWhen.field` must name a top-level field declared in `fields`",
1131
1373
  path: ["notifyWhen"]
1132
- }).refine((schema) => schema.views === void 0 || schema.views.every((view) => safeSlugName(view.id) !== null), {
1374
+ }).refine((schema) => schema.views === void 0 || schema.views.every((view) => isSafeSlug(view.id)), {
1133
1375
  message: "every `views[].id` must be a valid slug (alphanumeric / hyphen / underscore, no path separators)",
1134
1376
  path: ["views"]
1135
1377
  }).refine((schema) => schema.views === void 0 || new Set(schema.views.map((view) => view.id)).size === schema.views.length, {
1136
1378
  message: "schema `views` must have unique `id`s",
1137
1379
  path: ["views"]
1138
1380
  });
1381
+ //#endregion
1382
+ //#region src/collection/server/discovery.ts
1139
1383
  function applyFeedSchemaDefaults(parsed, slug) {
1140
1384
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return parsed;
1141
1385
  const obj = parsed;
@@ -1318,6 +1562,72 @@ function toDetail(collection) {
1318
1562
  };
1319
1563
  }
1320
1564
  //#endregion
1565
+ //#region src/collection/server/ontology.ts
1566
+ /** Extract the relations a schema declares, in field declaration order:
1567
+ * top-level `ref` / `embed` / `backlinks` fields plus `ref` sub-fields
1568
+ * inside `table` columns. Pure — exported so the phase-2 graph panel
1569
+ * can reuse it on already-loaded schemas. */
1570
+ function schemaRelations(schema) {
1571
+ const relations = [];
1572
+ for (const [key, spec] of Object.entries(schema.fields)) {
1573
+ if (spec.type === "ref" || spec.type === "embed") relations.push({
1574
+ field: key,
1575
+ kind: spec.type,
1576
+ to: spec.to
1577
+ });
1578
+ if (spec.type === "backlinks") relations.push({
1579
+ field: key,
1580
+ kind: "backlinks",
1581
+ to: spec.from
1582
+ });
1583
+ if (spec.type !== "table") continue;
1584
+ for (const [subKey, subSpec] of Object.entries(spec.of)) if (subSpec.type === "ref") relations.push({
1585
+ field: `${key}.${subKey}`,
1586
+ kind: "ref",
1587
+ to: subSpec.to
1588
+ });
1589
+ }
1590
+ return relations;
1591
+ }
1592
+ /** Count the record files in a collection's data dir — the same
1593
+ * `<id>.json` entries `listItems` considers, WITHOUT parsing them (the
1594
+ * ontology is a summary; a malformed record is still a record). Each
1595
+ * candidate is classified by the SHARED `isRegularFile` lstat helper —
1596
+ * the exact file-disclosure defense `listItems` applies — so a
1597
+ * symlinked record never counts and the count can't diverge from the
1598
+ * readable set on any filesystem (Codex review on PR #2099, twice).
1599
+ * Fail-soft: a missing dir or a dataDir escaping the workspace via
1600
+ * symlink counts 0. */
1601
+ async function countRecordFiles(dataDir, workspaceRoot) {
1602
+ if (!isContainedInRoot(dataDir, workspaceRoot)) return 0;
1603
+ try {
1604
+ const names = (await readdir(dataDir)).filter((name) => name.endsWith(".json") && !name.startsWith("."));
1605
+ return (await Promise.all(names.map((name) => isRegularFile(path.join(dataDir, name))))).filter(Boolean).length;
1606
+ } catch {
1607
+ return 0;
1608
+ }
1609
+ }
1610
+ async function toOntologyEntry(collection, workspaceRoot) {
1611
+ const { schema } = collection;
1612
+ return {
1613
+ slug: collection.slug,
1614
+ title: schema.title,
1615
+ icon: schema.icon,
1616
+ primaryKey: schema.primaryKey,
1617
+ displayField: schema.displayField ?? schema.primaryKey,
1618
+ recordCount: await countRecordFiles(collection.dataDir, workspaceRoot),
1619
+ relations: schemaRelations(schema)
1620
+ };
1621
+ }
1622
+ /** Build the workspace ontology: every discovered collection (slug-sorted,
1623
+ * discovery's order), each with its outbound relations and a
1624
+ * readdir-cheap record count — fine to call on demand. */
1625
+ async function buildWorkspaceOntology(opts = {}) {
1626
+ const workspaceRoot = opts.workspaceRoot ?? getWorkspaceRoot();
1627
+ const collections = await discoverCollections(opts);
1628
+ return Promise.all(collections.map((collection) => toOntologyEntry(collection, workspaceRoot)));
1629
+ }
1630
+ //#endregion
1321
1631
  //#region src/collection/server/derive.ts
1322
1632
  /** Slugs of every collection referenced by a `ref` field — top-level
1323
1633
  * and one level into `table` sub-fields (nested tables are
@@ -1340,6 +1650,13 @@ function uniqueEmbedTargets(schema) {
1340
1650
  for (const field of Object.values(schema.fields)) if (field.type === "embed" && typeof field.to === "string" && field.to.length > 0) targets.add(field.to);
1341
1651
  return [...targets];
1342
1652
  }
1653
+ /** Slugs of every SOURCE collection a `backlinks` field reverses over —
1654
+ * loaded exactly like ref/embed targets (whole collection, once). */
1655
+ function uniqueBacklinkSources(schema) {
1656
+ const sources = /* @__PURE__ */ new Set();
1657
+ for (const field of Object.values(schema.fields)) if (field.type === "backlinks" && field.from.length > 0) sources.add(field.from);
1658
+ return [...sources];
1659
+ }
1343
1660
  async function loadTarget(slug, opts) {
1344
1661
  const target = await loadCollection(slug, opts);
1345
1662
  if (!target) return null;
@@ -1354,11 +1671,16 @@ async function loadTarget(slug, opts) {
1354
1671
  byId
1355
1672
  };
1356
1673
  }
1357
- /** Load every ref/embed target collection once. Unknown / unloadable
1358
- * targets are simply absent — downstream derefs resolve to null, the
1359
- * same fail-soft the UI renders as an em-dash. */
1674
+ /** Load every ref/embed target and backlink source collection once.
1675
+ * Unknown / unloadable targets are simply absent — downstream derefs
1676
+ * resolve to null (em-dash) and backlinks to an empty row set, the
1677
+ * same fail-soft the UI renders. */
1360
1678
  async function loadLinkedTargets(schema, opts) {
1361
- const slugs = [.../* @__PURE__ */ new Set([...uniqueRefTargets(schema), ...uniqueEmbedTargets(schema)])];
1679
+ const slugs = [.../* @__PURE__ */ new Set([
1680
+ ...uniqueRefTargets(schema),
1681
+ ...uniqueEmbedTargets(schema),
1682
+ ...uniqueBacklinkSources(schema)
1683
+ ])];
1362
1684
  const loaded = {};
1363
1685
  for (const slug of slugs) {
1364
1686
  const target = await loadTarget(slug, opts);
@@ -1369,9 +1691,21 @@ async function loadLinkedTargets(schema, opts) {
1369
1691
  function toRefRecords(linked) {
1370
1692
  return Object.fromEntries(Object.entries(linked).map(([slug, target]) => [slug, target.byId]));
1371
1693
  }
1694
+ /** The matching source rows for one `backlinks` field, projected to the
1695
+ * source primaryKey + `display` columns — so getItems on a
1696
+ * heavily-referenced record stays a summary, not a dump of the source
1697
+ * collection. Missing source ⇒ [] (fail-soft). The rows come from the
1698
+ * DERIVED source records (`byId`), so `display`/`filter` on a derived
1699
+ * source column (an invoice `total`) works. */
1700
+ function projectBacklinks(field, schema, enriched, linked) {
1701
+ const source = linked[field.from];
1702
+ if (!source) return [];
1703
+ return backlinkRows(field, String(enriched[schema.primaryKey] ?? ""), Object.values(source.byId)).map((row) => projectBacklinkRow(row, field.display, source.schema.primaryKey));
1704
+ }
1372
1705
  /** Project the computed (never-stored) field kinds onto one derived
1373
1706
  * record: `toggle` → boolean off its enum, `embed` → the target record
1374
- * (fixed `id` or per-record `idField`), or null when missing. */
1707
+ * (fixed `id` or per-record `idField`), or null when missing,
1708
+ * `backlinks` → the matching source rows (see `projectBacklinks`). */
1375
1709
  function projectComputed(schema, enriched, linked) {
1376
1710
  for (const [key, field] of Object.entries(schema.fields)) {
1377
1711
  if (field.type === "toggle" && field.field) enriched[key] = String(enriched[field.field] ?? "") === field.onValue;
@@ -1379,6 +1713,7 @@ function projectComputed(schema, enriched, linked) {
1379
1713
  const targetId = embedTargetId(field, enriched);
1380
1714
  enriched[key] = targetId && linked[field.to]?.byId[targetId] || null;
1381
1715
  }
1716
+ if (field.type === "backlinks") enriched[key] = projectBacklinks(field, schema, enriched, linked);
1382
1717
  }
1383
1718
  return enriched;
1384
1719
  }
@@ -1915,6 +2250,6 @@ async function deleteCustomView(collection, viewId, opts = {}) {
1915
2250
  };
1916
2251
  }
1917
2252
  //#endregion
1918
- export { listItems as A, isContainedInRoot as B, COMPUTED_TYPES as C, buildCollectionActionSeedPrompt as D, buildActionSeedPrompt as E, readSkillTemplate as F, safeRecordId as G, itemFilePath as H, resolveCreateItemId as I, configureCollectionHost as J, safeSlugName as K, writeItem as L, readCustomViewHtml as M, readCustomViewI18n as N, deleteItem as O, readItem as P, setCollectionChangePublisher as Q, writeFileAtomic as R, toSummary as S, validateRecordObject as T, resolveDataDir as U, isContainedInWorkspace as V, resolveTemplatePath as W, log as X, getWorkspaceRoot as Y, publishCollectionChange as Z, CollectionSchemaZ as _, computeSuccessor as a, loadCollection as b, isTriggerDue as c, resolveEvery as d, successorId as f, enrichItems as g, computeCollectionIcon as h, advanceTriggerDate as i, promptPathsFor as j, generateItemId as k, maybeSpawnSuccessor as l, errorMessage as m, deleteCollection as n, daysInMonth as o, ONE_SECOND_MS as p, collectionsRegistriesConfigPath as q, deleteCollectionRefusalMessage as r, formatCivil as s, deleteCustomView as t, parseCivil as u, acceptParsedSchema as v, validateCollectionRecords as w, toDetail as x, discoverCollections as y, SCHEMA_FILE as z };
2253
+ export { getWorkspaceRoot as $, buildCollectionActionSeedPrompt as A, resolveCreateItemId as B, toSummary as C, compileRecordZ as D, validateRecordObject as E, promptPathsFor as F, isContainedInWorkspace as G, writeFileAtomic as H, readCustomViewHtml as I, resolveTemplatePath as J, itemFilePath as K, readCustomViewI18n as L, generateItemId as M, isRegularFile as N, recordFieldProblem as O, listItems as P, configureCollectionHost as Q, readItem as R, toDetail as S, validateCollectionRecords as T, SCHEMA_FILE as U, writeItem as V, isContainedInRoot as W, safeSlugName as X, safeRecordId as Y, collectionsRegistriesConfigPath as Z, buildWorkspaceOntology as _, computeSuccessor as a, discoverCollections as b, isTriggerDue as c, resolveEvery as d, log as et, successorId as f, enrichItems as g, computeCollectionIcon as h, advanceTriggerDate as i, deleteItem as j, buildActionSeedPrompt as k, maybeSpawnSuccessor as l, errorMessage as m, deleteCollection as n, setCollectionChangePublisher as nt, daysInMonth as o, ONE_SECOND_MS as p, resolveDataDir as q, deleteCollectionRefusalMessage as r, formatCivil as s, deleteCustomView as t, publishCollectionChange as tt, parseCivil as u, schemaRelations as v, CollectionSchemaZ as w, loadCollection as x, acceptParsedSchema as y, readSkillTemplate as z };
1919
2254
 
1920
- //# sourceMappingURL=server-DKXXFTbw.js.map
2255
+ //# sourceMappingURL=server-BNTR7ySK.js.map