@mulmoclaude/core 3.6.0 → 3.7.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 (37) hide show
  1. package/dist/collection/registry/server/index.cjs +19 -19
  2. package/dist/collection/registry/server/index.cjs.map +1 -1
  3. package/dist/collection/registry/server/index.js +2 -2
  4. package/dist/collection/server/host.d.ts +11 -0
  5. package/dist/collection/server/index.cjs +72 -59
  6. package/dist/collection/server/index.d.ts +4 -0
  7. package/dist/collection/server/index.js +3 -3
  8. package/dist/collection/server/manageTool.d.ts +4 -0
  9. package/dist/collection/server/publish.d.ts +56 -0
  10. package/dist/collection/server/publishChecks.d.ts +29 -0
  11. package/dist/collection/server/publishManifest.d.ts +181 -0
  12. package/dist/collection/server/publishProject.d.ts +86 -0
  13. package/dist/collection/server/validate.d.ts +12 -0
  14. package/dist/collection-watchers/index.cjs +15 -15
  15. package/dist/collection-watchers/index.cjs.map +1 -1
  16. package/dist/collection-watchers/index.js +2 -2
  17. package/dist/feeds/server/index.cjs +10 -10
  18. package/dist/feeds/server/index.cjs.map +1 -1
  19. package/dist/feeds/server/index.js +2 -2
  20. package/dist/google/index.cjs +12 -12
  21. package/dist/google/index.cjs.map +1 -1
  22. package/dist/google/index.js +1 -1
  23. package/dist/{server-DdHaX_Uu.js → server-B48Jyxcj.js} +1079 -228
  24. package/dist/server-B48Jyxcj.js.map +1 -0
  25. package/dist/{server-9mFaGLdq.cjs → server-CWZyg8fn.cjs} +1318 -389
  26. package/dist/server-CWZyg8fn.cjs.map +1 -0
  27. package/dist/{discovery-Cw5xWzZD.cjs → store-5_P_NsGa.cjs} +1947 -1947
  28. package/dist/store-5_P_NsGa.cjs.map +1 -0
  29. package/dist/{discovery-CdkaURVY.js → store-_61sO8K8.js} +1948 -1948
  30. package/dist/store-_61sO8K8.js.map +1 -0
  31. package/dist/whisper/index.cjs +1 -1
  32. package/dist/whisper/index.js +1 -1
  33. package/package.json +1 -1
  34. package/dist/discovery-CdkaURVY.js.map +0 -1
  35. package/dist/discovery-Cw5xWzZD.cjs.map +0 -1
  36. package/dist/server-9mFaGLdq.cjs.map +0 -1
  37. package/dist/server-DdHaX_Uu.js.map +0 -1
@@ -9,10 +9,10 @@ let node_path = require("node:path");
9
9
  node_path = require_rolldown_runtime.__toESM(node_path, 1);
10
10
  let node_crypto = require("node:crypto");
11
11
  let node_fs_promises = require("node:fs/promises");
12
+ let zod = require("zod");
12
13
  let node_os = require("node:os");
13
14
  let iconv_lite = require("iconv-lite");
14
15
  iconv_lite = require_rolldown_runtime.__toESM(iconv_lite, 1);
15
- let zod = require("zod");
16
16
  //#region src/host/hostSlot.ts
17
17
  function createHostSlot(name) {
18
18
  let current = null;
@@ -609,1541 +609,363 @@ function appManifestReason(failure, root) {
609
609
  if (failure.kind === "unreadable") return `cannot read ${manifestPath}: ${failure.detail}`;
610
610
  return `${manifestPath} ${failure.detail}`;
611
611
  }
612
- //#endregion
613
- //#region src/collection/server/io.ts
614
- /** True iff `filePath` exists and is a regular file (NOT a symlink).
615
- * Defends `listItems` / `readItem` against `*.json` symlinks placed
616
- * inside an otherwise-contained data dir — without this, a record
617
- * file could symlink to /etc/passwd and the detail endpoint would
618
- * happily serve it. Returns false on ENOENT and on any other lstat
619
- * failure so the caller's "missing" branch covers those cases too.
620
- * Exported so `ontology.ts`'s record COUNT classifies entries with the
621
- * SAME lstat logic — the two must agree on what a record file is. */
622
- async function isRegularFile(filePath) {
623
- try {
624
- return (await (0, node_fs_promises.lstat)(filePath)).isFile();
625
- } catch {
626
- return false;
627
- }
628
- }
629
- /** Read one JSON record file. Returns null when the file is missing,
630
- * is a symlink (file-disclosure defense), parses to a non-object,
631
- * or has a read/parse error. Caller logs the per-entry skip — this
632
- * helper just classifies. Split out to keep `listItems` under the
633
- * `sonarjs/cognitive-complexity` threshold. */
634
- /** Parse a record file's text into a plain-object `CollectionItem`, or
635
- * null when it isn't a JSON object (array / scalar / null). */
636
- function parseRecordJson(raw) {
637
- const parsed = JSON.parse(raw);
638
- return require_dist.isRecord(parsed) ? parsed : null;
639
- }
640
- async function tryReadRecord(filePath) {
641
- if (!await isRegularFile(filePath)) return null;
642
- try {
643
- return parseRecordJson(await (0, node_fs_promises.readFile)(filePath, "utf-8"));
644
- } catch {
645
- return null;
646
- }
612
+ /** The param name a `set` value references, or null when the value is a
613
+ * literal (non-strings can never be references). A bare/empty prefix
614
+ * (`"$params."`) returns the empty string — the schema refine rejects
615
+ * it as an undeclared param, never silently treats it as a literal. */
616
+ function paramRefName(value) {
617
+ if (typeof value !== "string" || !value.startsWith("$params.")) return null;
618
+ return value.slice(8);
647
619
  }
648
- /** Read every record under `dataDir`. Returns [] if the dir doesn't
649
- * exist yet (legitimate first-use state). Malformed JSON files and
650
- * symlinked records are skipped (the latter is a file-disclosure
651
- * defense — see `isRegularFile`). Re-validates the realpath
652
- * containment to defend against a symlinked data dir appearing
653
- * between discovery and use. */
654
- async function listItems(dataDir, opts = {}) {
655
- if (!isContainedInRoot(dataDir, opts.workspaceRoot ?? getWorkspaceRoot())) {
656
- log.warn("collections", "listItems refused: dataDir escapes workspace via symlink", { dataDir });
657
- return [];
658
- }
659
- let entries;
660
- try {
661
- entries = await (0, node_fs_promises.readdir)(dataDir);
662
- } catch (err) {
663
- if (require_dist.isErrorWithCode(err) && err.code === "ENOENT") return [];
664
- throw err;
665
- }
666
- const results = [];
667
- for (const name of entries) {
668
- if (!name.endsWith(".json")) continue;
669
- if (name.startsWith(".")) continue;
670
- const filePath = node_path.default.join(dataDir, name);
671
- const record = await tryReadRecord(filePath);
672
- if (record === null) {
673
- log.warn("collections", "skipping record (missing, symlink, or unreadable)", { path: filePath });
620
+ /** Resolve a mutate action's `set` map against the submitted params:
621
+ * literals pass through, `$params.<name>` reads the param value. An
622
+ * ABSENT referenced param omits the key entirely (merge semantics —
623
+ * the stored value survives), mirroring how the record form omits
624
+ * empty optionals rather than writing empty strings. */
625
+ function resolveMutateSet(set, params) {
626
+ const resolved = {};
627
+ for (const [key, value] of Object.entries(set)) {
628
+ const ref = paramRefName(value);
629
+ if (ref === null) {
630
+ resolved[key] = value;
674
631
  continue;
675
632
  }
676
- results.push(record);
633
+ const paramValue = params[ref];
634
+ if (paramValue !== void 0 && paramValue !== null && paramValue !== "") resolved[key] = paramValue;
677
635
  }
678
- return results;
636
+ return resolved;
679
637
  }
680
- /** Read one record by id. Returns null when the file is missing,
681
- * when the resolved path escapes the workspace via a symlink, or
682
- * when the record file itself is a symlink (file-disclosure
683
- * defense — see `isRegularFile`). */
684
- async function readItem(dataDir, itemId, opts = {}) {
685
- const safeId = safeRecordId(itemId);
686
- if (safeId === null) return null;
687
- if (!isContainedInRoot(dataDir, opts.workspaceRoot ?? getWorkspaceRoot())) return null;
688
- const filePath = itemFilePath(dataDir, safeId);
689
- if (!await isRegularFile(filePath)) return null;
690
- try {
691
- return parseRecordJson(await (0, node_fs_promises.readFile)(filePath, "utf-8"));
692
- } catch (err) {
693
- if (require_dist.isErrorWithCode(err) && err.code === "ENOENT") return null;
694
- throw err;
695
- }
638
+ //#endregion
639
+ //#region src/collection/core/schemaRules.ts
640
+ var declaredField = (fields, name) => Object.hasOwn(fields, name) ? fields[name] : void 0;
641
+ var isDateLike = (type) => type === "date" || type === "datetime";
642
+ var isTimeStringField = (type) => type === "string" || type === "text";
643
+ var CODE_FIELD_TYPES = /* @__PURE__ */ new Set([
644
+ "string",
645
+ "text",
646
+ "enum"
647
+ ]);
648
+ var namesStoredField = (fields, name, primaryKey) => {
649
+ const target = declaredField(fields, name);
650
+ return target !== void 0 && !require_calendarGrid.COMPUTED_TYPES.has(target.type) && name !== primaryKey;
651
+ };
652
+ var hasUniqueIds = (entries) => entries === void 0 || new Set(entries.map((entry) => entry.id)).size === entries.length;
653
+ /** Exactly one storage declaration: native records need `dataPath`, an external
654
+ * data file needs `dataSource`, an alternative backend needs `storage`. Zero
655
+ * (nowhere to read) and several (ambiguous which wins) are equally
656
+ * meaningless — fail loudly at load instead of picking silently. */
657
+ function declaresExactlyOneStore(schema) {
658
+ return [
659
+ schema.dataPath,
660
+ schema.dataSource,
661
+ schema.storage
662
+ ].filter((declared) => declared !== void 0).length === 1;
696
663
  }
697
- /** The symlink-containment refusal every record path shares: one check, one
698
- * warn, one answer. Extracted because this is a security RULE applied at
699
- * three sites (write pre-mkdir, write post-mkdir, delete) — a fix to the
700
- * check must not be able to land at only one of them.
701
- *
702
- * `stage` names the call site so the warn stays as diagnosable as the three
703
- * hand-written copies were.
704
- *
705
- * Scope, stated explicitly because a reviewer asks every time: this catches
706
- * a symlink that EXISTS when we look — `isContainedInRoot` realpaths the
707
- * closest existing ancestor, so a pre-planted escape is refused. It does not
708
- * and cannot close the check-then-use race, where an ancestor is swapped for
709
- * a symlink between this call and the `mkdir` / `open` / `unlink` that
710
- * follows. Closing that needs directory-handle I/O anchored at the workspace
711
- * (`openat` + `O_NOFOLLOW`), which `node:fs` does not expose — it would mean
712
- * a different I/O layer, not a tighter check here.
713
- *
714
- * That race is deliberately outside this app's threat model: the process is
715
- * loopback-bound and bearer-authed, so anyone able to swap directories inside
716
- * the workspace is already the workspace owner — the same trust principal the
717
- * writes belong to. Revisit if collections ever serve a lower-trust caller. */
718
- function escapesWorkspace(dataDir, workspaceRoot, itemId, stage) {
719
- if (isContainedInRoot(dataDir, workspaceRoot)) return false;
720
- log.warn("collections", `${stage} refused: dataDir escapes workspace via symlink`, {
721
- dataDir,
722
- itemId
723
- });
664
+ /** A `dataSource` collection is read-only by definition, so schema-level write
665
+ * machinery can never fire: `singleton` pins CREATES, `ingest` REFILLS
666
+ * records, `spawn` WRITES successor records. Rejecting them at validation
667
+ * kills whole classes of writes before any runtime guard. */
668
+ function dataSourceDeclaresNoWriteMachinery(schema) {
669
+ if (schema.dataSource === void 0) return true;
670
+ return schema.singleton === void 0 && schema.ingest === void 0 && schema.spawn === void 0 && schema.googleCalendar === void 0;
671
+ }
672
+ /** Same rule for declarative host writes: a mutate action writes the record
673
+ * it's invoked on, which a read-only collection has no business doing. */
674
+ function dataSourceDeclaresNoMutateAction(schema) {
675
+ if (schema.dataSource !== void 0) return [...schema.actions ?? [], ...schema.collectionActions ?? []].every((action) => action.kind !== "mutate");
724
676
  return true;
725
677
  }
726
- /** Write a record. Ensures the directory exists, validates the id,
727
- * re-checks symlink containment after mkdir, and writes atomically.
728
- *
729
- * Create path (`refuseOverwrite: true`) uses an O_EXCL `wx` open
730
- * rather than `stat` + `writeFileAtomic` to close a check-then-write
731
- * race: two concurrent POSTs would otherwise both pass the existence
732
- * check and one would silently overwrite the other. The trade-off
733
- * is that the create path is not crash-atomic (a partial file could
734
- * remain if the process dies mid-write); acceptable here because
735
- * records are small JSON blobs and the next read either parses or
736
- * is skipped via the "malformed JSON" branch in `listItems`.
737
- *
738
- * Update path (`refuseOverwrite: false`) uses `writeFileAtomic` so
739
- * PUT remains crash-atomic. No race there — the URL pins the id. */
740
- async function writeItem(dataDir, itemId, item, opts = {}) {
741
- const safeId = safeRecordId(itemId);
742
- if (safeId === null) return {
743
- kind: "invalid-id",
744
- itemId
745
- };
746
- const workspaceRoot = opts.workspaceRoot ?? getWorkspaceRoot();
747
- if (escapesWorkspace(dataDir, workspaceRoot, safeId, "writeItem (pre-mkdir)")) return {
748
- kind: "path-escape",
749
- itemId: safeId
750
- };
751
- await (0, node_fs_promises.mkdir)(dataDir, { recursive: true });
752
- if (escapesWorkspace(dataDir, workspaceRoot, safeId, "writeItem (post-mkdir)")) return {
753
- kind: "path-escape",
754
- itemId: safeId
755
- };
756
- const filePath = itemFilePath(dataDir, safeId);
757
- const payload = `${JSON.stringify(item, null, 2)}\n`;
758
- if (opts.refuseOverwrite) {
759
- let handle;
760
- try {
761
- handle = await (0, node_fs_promises.open)(filePath, "wx");
762
- } catch (err) {
763
- if (require_dist.isErrorWithCode(err) && err.code === "EEXIST") return {
764
- kind: "conflict",
765
- itemId: safeId
766
- };
767
- throw err;
768
- }
769
- try {
770
- await handle.writeFile(payload);
771
- } finally {
772
- await handle.close();
773
- }
774
- } else await require_root.writeFileAtomic(filePath, payload);
775
- if (opts.slug) publishCollectionChange(collectionChangePayload({
776
- slug: opts.slug,
777
- ids: [safeId],
778
- op: "upsert"
779
- }, opts.workspaceRoot));
780
- return {
781
- kind: "ok",
782
- itemId: safeId,
783
- item
784
- };
678
+ /** Action ids must be unique so the dispatch route resolves unambiguously. */
679
+ function actionIdsAreUnique(schema) {
680
+ return hasUniqueIds(schema.actions);
785
681
  }
786
- async function deleteItem(dataDir, itemId, opts = {}) {
787
- const safeId = safeRecordId(itemId);
788
- if (safeId === null) return {
789
- kind: "invalid-id",
790
- itemId
791
- };
792
- if (escapesWorkspace(dataDir, opts.workspaceRoot ?? getWorkspaceRoot(), safeId, "deleteItem")) return {
793
- kind: "path-escape",
794
- itemId: safeId
795
- };
796
- const filePath = itemFilePath(dataDir, safeId);
797
- try {
798
- await (0, node_fs_promises.unlink)(filePath);
799
- if (opts.slug) publishCollectionChange(collectionChangePayload({
800
- slug: opts.slug,
801
- ids: [safeId],
802
- op: "delete"
803
- }, opts.workspaceRoot));
804
- return {
805
- kind: "ok",
806
- itemId: safeId
807
- };
808
- } catch (err) {
809
- if (require_dist.isErrorWithCode(err) && err.code === "ENOENT") return {
810
- kind: "not-found",
811
- itemId: safeId
812
- };
813
- throw err;
814
- }
815
- }
816
- /** Generate a short random hex id. Used by POST when the form doesn't
817
- * carry a primary-key value (UI shortcut — Claude normally derives a
818
- * semantic id from the record's name). */
819
- function generateItemId() {
820
- return (0, node_crypto.randomBytes)(4).toString("hex");
821
- }
822
- /** The item id a CREATE should use for `schema`, or null when the
823
- * caller should generate one. A singleton collection pins every
824
- * create to its fixed `schema.singleton` id, so the "at most one
825
- * record" contract is enforced server-side (a second create targets
826
- * the same file and hits `writeItem`'s refuseOverwrite conflict) —
827
- * not only in the UI. Otherwise the record's own primaryKey value
828
- * wins, falling back to a generated id (null = "generate"). */
829
- function resolveCreateItemId(schema, record) {
830
- if (schema.singleton) return schema.singleton;
831
- const primaryRaw = record[schema.primaryKey];
832
- return typeof primaryRaw === "string" && primaryRaw.length > 0 ? primaryRaw : null;
682
+ /** Collection-level action ids must likewise be unique. */
683
+ function collectionActionIdsAreUnique(schema) {
684
+ return hasUniqueIds(schema.collectionActions);
833
685
  }
834
- //#endregion
835
- //#region src/collection/core/queryZ.ts
836
- /** Result-column aliases double as SQL identifiers and JSON keys — keep
837
- * them to a conservative identifier charset so neither side needs
838
- * escaping gymnastics. */
839
- var SAFE_ALIAS_PATTERN = /^[A-Za-z_]\w{0,63}$/;
840
- /** Hard ceiling on returned rows; `limit` clamps below it. A group-by on
841
- * a near-unique column would otherwise return one row per source row —
842
- * the exact materialization the aggregate path exists to avoid. */
843
- var MAX_QUERY_ROWS = 1e4;
844
- /** Default row cap when the query declares no `limit`. */
845
- var DEFAULT_QUERY_ROWS = 1e3;
846
- /** One aggregate column: `count` (rows; `column` optional to count
847
- * non-null cells) or `sum`/`avg`/`min`/`max` over a named CSV column. */
848
- var QueryAggregateZ = zod.z.object({
849
- op: zod.z.enum([
850
- "count",
851
- "sum",
852
- "avg",
853
- "min",
854
- "max"
855
- ]),
856
- column: zod.z.string().min(1).optional()
857
- }).refine((aggregate) => aggregate.op === "count" || aggregate.column !== void 0, {
858
- message: "`column` is required for every aggregate op except `count`",
859
- path: ["column"]
860
- });
861
- /** One filter condition. Same op vocabulary as the schema-level `where`
862
- * (`core/where.ts`) so authors learn one set; values may be typed
863
- * (number / boolean) since CSV columns are. `in` requires an array
864
- * value, every other op a scalar. */
865
- var QueryWhereZ = zod.z.object({
866
- field: zod.z.string().min(1),
867
- op: zod.z.enum([
868
- "eq",
869
- "ne",
870
- "in",
871
- "gt",
872
- "gte",
873
- "lt",
874
- "lte",
875
- "contains"
876
- ]),
877
- value: zod.z.union([
878
- zod.z.string(),
879
- zod.z.number(),
880
- zod.z.boolean(),
881
- zod.z.array(zod.z.union([
882
- zod.z.string(),
883
- zod.z.number(),
884
- zod.z.boolean()
885
- ])).min(1).max(100)
886
- ])
887
- }).refine((cond) => cond.op === "in" === Array.isArray(cond.value), {
888
- message: "`in` requires an array value (the allowed set); every other op requires a scalar value",
889
- path: ["value"]
890
- });
891
- var QueryOrderZ = zod.z.object({
892
- /** A `groupBy` column or an aggregate alias — membership enforced by
893
- * the whole-query refine below. */
894
- field: zod.z.string().min(1),
895
- dir: zod.z.enum(["asc", "desc"]).optional()
896
- });
897
- /** The whole query. At least one of `groupBy` / `aggregates` must be
898
- * present: bare `groupBy` is a DISTINCT listing, bare `aggregates` a
899
- * whole-file scalar row, together a grouped aggregation. */
900
- var CollectionQueryZ = zod.z.object({
901
- groupBy: zod.z.array(zod.z.string().min(1)).max(8).refine((columns) => new Set(columns.map((column) => column.toLowerCase())).size === columns.length, { message: "`groupBy` columns must be unique (case-insensitively — SQL identifiers ignore case)" }).optional(),
902
- aggregates: zod.z.record(zod.z.string().regex(SAFE_ALIAS_PATTERN, "aggregate aliases must be simple identifiers (letters/digits/underscore)"), QueryAggregateZ).optional(),
903
- where: zod.z.array(QueryWhereZ).max(16).optional(),
904
- orderBy: zod.z.array(QueryOrderZ).max(4).optional(),
905
- limit: zod.z.number().int().min(1).max(MAX_QUERY_ROWS).optional()
906
- }).refine((query) => (query.groupBy?.length ?? 0) > 0 || Object.keys(query.aggregates ?? {}).length > 0, {
907
- message: "declare at least one of `groupBy` (columns to bucket by) or `aggregates` (values to compute)",
908
- path: ["groupBy"]
909
- }).refine((query) => Object.keys(query.aggregates ?? {}).length <= 32, {
910
- message: `\`aggregates\` supports at most 32 entries`,
911
- path: ["aggregates"]
912
- }).refine((query) => {
913
- const groupLower = new Set((query.groupBy ?? []).map((column) => column.toLowerCase()));
914
- const seen = /* @__PURE__ */ new Set();
915
- return Object.keys(query.aggregates ?? {}).every((alias) => {
916
- const lower = alias.toLowerCase();
917
- if (groupLower.has(lower) || seen.has(lower)) return false;
918
- seen.add(lower);
919
- return true;
920
- });
921
- }, {
922
- message: "aggregate aliases must be unique and must not collide with `groupBy` column names (case-insensitively — SQL identifiers ignore case)",
923
- path: ["aggregates"]
924
- }).refine((query) => {
925
- const sortable = /* @__PURE__ */ new Set([...query.groupBy ?? [], ...Object.keys(query.aggregates ?? {})]);
926
- return (query.orderBy ?? []).every((order) => sortable.has(order.field));
927
- }, {
928
- message: "every `orderBy.field` must be a `groupBy` column or an aggregate alias",
929
- path: ["orderBy"]
930
- });
931
- //#endregion
932
- //#region src/collection/server/csvQuery.ts
933
- /** Double-quote a SQL identifier (CSV column name / result alias). */
934
- function quoteIdent(name) {
935
- return `"${name.replaceAll("\"", "\"\"")}"`;
686
+ /** A mutate action's `set` writes real STORED fields: a typo'd key would write
687
+ * a stray value forever, a computed/projected field is never persisted, and
688
+ * the primaryKey is the filename (renaming is not a mutation). */
689
+ function mutateSetKeysNameStoredFields(schema) {
690
+ return (schema.actions ?? []).every((action) => action.kind !== "mutate" || Object.keys(action.set).every((key) => namesStoredField(schema.fields, key, schema.primaryKey)));
936
691
  }
937
- /** Single-quote a SQL string literal (a `types={...}` struct key). */
938
- function quoteLiteral(value) {
939
- return `'${value.replaceAll("'", "''")}'`;
692
+ /** Every `$params.<name>` reference in `set` must name a declared param — an
693
+ * undeclared one would silently no-op the assignment. */
694
+ function mutateParamRefsAreDeclared(schema) {
695
+ return (schema.actions ?? []).every((action) => action.kind !== "mutate" || Object.values(action.set).every((value) => {
696
+ const ref = paramRefName(value);
697
+ return ref === null || (action.params ?? {})[ref] !== void 0;
698
+ }));
940
699
  }
941
- /** The `read_csv` argument list shared by every CSV query: the (prepared)
942
- * path plus a `types` pin forcing the key column to VARCHAR — without it
943
- * DuckDB's sniffer turns `001` into BIGINT 1, so leading zeros vanish
944
- * and distinct keys collapse. */
945
- function readCsvArgs(primaryKey) {
946
- return `?, types={${quoteLiteral(primaryKey)}: 'VARCHAR'}`;
700
+ /** A collection-level action has no record to write. */
701
+ function collectionActionsAreNotMutate(schema) {
702
+ return (schema.collectionActions ?? []).every((action) => action.kind !== "mutate");
947
703
  }
948
- /** One aggregate's SQL expression. `sum`/`avg` TRY_CAST to DOUBLE so a
949
- * column the sniffer kept as VARCHAR (mixed values) aggregates over its
950
- * numeric cells instead of erroring; non-numeric cells become NULL and
951
- * are skipped — standard BI tolerance. `min`/`max` stay native (they are
952
- * meaningful on strings and dates too). */
953
- function aggregateExpr(aggregate) {
954
- const { op, column } = aggregate;
955
- if (op === "count") return column === void 0 ? "count(*)" : `count(${quoteIdent(column)})`;
956
- if (op === "sum" || op === "avg") return `${op}(TRY_CAST(${quoteIdent(column ?? "")} AS DOUBLE))`;
957
- return `${op}(${quoteIdent(column ?? "")})`;
704
+ /** The singleton value becomes a record id (and thus a `<id>.json` filename),
705
+ * so it must satisfy the SAME record-id rule the write path enforces —
706
+ * otherwise the create form would lock the primary key to a value the POST
707
+ * route then rejects, making the collection impossible to initialize. */
708
+ function singletonIsAValidRecordId(schema) {
709
+ return schema.singleton === void 0 || require_calendarGrid.isSafeRecordId(schema.singleton);
958
710
  }
959
- /** One where condition → SQL fragment + its bound parameters. String
960
- * equality compares against `CAST(col AS VARCHAR)` so a sniffer-typed
961
- * column still matches its textual value; numeric/boolean values compare
962
- * natively (DuckDB coerces the column side). */
963
- function whereFragment(cond) {
964
- const column = quoteIdent(cond.field);
965
- const asText = `CAST(${column} AS VARCHAR)`;
966
- if (cond.op === "in") {
967
- const values = arrayValue(cond);
968
- return {
969
- sql: `${values.every((value) => typeof value === "string") ? asText : column} IN (${values.map(() => "?").join(", ")})`,
970
- params: values
971
- };
711
+ function collectCurrencyFieldRefs(fields) {
712
+ const refs = [];
713
+ for (const field of Object.values(fields)) {
714
+ if (typeof field.currencyField === "string" && field.currencyField.length > 0) refs.push(field.currencyField);
715
+ for (const sub of Object.values(field.of ?? {})) if (typeof sub.currencyField === "string" && sub.currencyField.length > 0) refs.push(sub.currencyField);
972
716
  }
973
- if (cond.op === "contains") return {
974
- sql: `contains(${asText}, ?)`,
975
- params: [String(scalarValue(cond))]
976
- };
977
- const operator = {
978
- eq: "=",
979
- ne: "<>",
980
- gt: ">",
981
- gte: ">=",
982
- lt: "<",
983
- lte: "<="
984
- }[cond.op];
985
- return {
986
- sql: `${typeof cond.value === "string" && (cond.op === "eq" || cond.op === "ne") ? asText : column} ${operator} ?`,
987
- params: [scalarValue(cond)]
988
- };
717
+ return refs;
989
718
  }
990
- /** Mirror of `scalarValue` for the one op that takes a set: a scalar under
991
- * `in` also means the query skipped `CollectionQueryZ`. Left unchecked it
992
- * failed as `values.every is not a function`, naming neither the field nor
993
- * the op. */
994
- function arrayValue(cond) {
995
- if (!Array.isArray(cond.value)) throw new Error(`where condition on '${cond.field}' uses op 'in', which requires an array value, not a scalar`);
996
- return cond.value;
719
+ /** A `currencyField` pointer must name a real top-level field that holds a code
720
+ * string — a typo (`curreny`) would otherwise pass the per-field check, then
721
+ * silently fall back to the literal / USD at render and mislabel amounts. */
722
+ function currencyFieldRefsNameCodeFields(schema) {
723
+ return collectCurrencyFieldRefs(schema.fields).every((name) => CODE_FIELD_TYPES.has(declaredField(schema.fields, name)?.type ?? ""));
997
724
  }
998
- /** `CollectionQueryZ` refines "`in` ⇔ array value", so an array reaching a
999
- * scalar op means the query was compiled without being validated first —
1000
- * binding it would send an array to a single `?`. */
1001
- function scalarValue(cond) {
1002
- if (Array.isArray(cond.value)) throw new Error(`where condition on '${cond.field}' uses op '${cond.op}', which requires a scalar value, not an array`);
1003
- return cond.value;
725
+ /** The pair must be declared together — one without the other is meaningless:
726
+ * the host would either never fire (no done values to compare against) or
727
+ * never clear (no field to read).
728
+ *
729
+ * EXCEPTION: when `completionField` names a `flag` field, done ⇔ the flag's
730
+ * `where` matches, so `completionDoneValues` carries no information and MUST
731
+ * be omitted (declaring it would invite a contradictory second source of
732
+ * truth). */
733
+ function completionPairIsCoherent(schema) {
734
+ if (schema.completionField !== void 0 && declaredField(schema.fields, schema.completionField)?.type === "flag") return schema.completionDoneValues === void 0;
735
+ return schema.completionField === void 0 === (schema.completionDoneValues === void 0);
1004
736
  }
1005
- /** Compile a validated query against `fromSql` (a table-function call
1006
- * whose FIRST placeholder is the source path — the executor binds it).
1007
- * Returns the SQL and the where-value parameters that follow the path.
1008
- * Callers MUST have run `CollectionQueryZ` first; this function trusts
1009
- * the shape (aliases already charset-checked, orderBy membership already
1010
- * enforced). */
1011
- function compileQuery(query, fromSql) {
1012
- const groupBy = query.groupBy ?? [];
1013
- const aggregates = Object.entries(query.aggregates ?? {});
1014
- const selectList = [...groupBy.map(quoteIdent), ...aggregates.map(([alias, aggregate]) => `${aggregateExpr(aggregate)} AS ${quoteIdent(alias)}`)];
1015
- const where = (query.where ?? []).map(whereFragment);
1016
- const clauses = [`SELECT ${selectList.join(", ")}`, `FROM ${fromSql}`];
1017
- if (where.length > 0) clauses.push(`WHERE ${where.map((fragment) => fragment.sql).join(" AND ")}`);
1018
- if (groupBy.length > 0) clauses.push(`GROUP BY ${groupBy.map(quoteIdent).join(", ")}`);
1019
- const orderBy = (query.orderBy ?? []).map((order) => quoteIdent(order.field) + (order.dir === "desc" ? " DESC" : " ASC"));
1020
- if (orderBy.length > 0) clauses.push(`ORDER BY ${orderBy.join(", ")}`);
1021
- clauses.push(`LIMIT ${query.limit ?? 1e3}`);
1022
- return {
1023
- sql: clauses.join(" "),
1024
- params: where.flatMap((fragment) => fragment.params)
1025
- };
737
+ /** `completionField` must name a real top-level field — a typo would silently
738
+ * disable the notification mechanism otherwise. */
739
+ function completionFieldIsDeclared(schema) {
740
+ return schema.completionField === void 0 || declaredField(schema.fields, schema.completionField) !== void 0;
1026
741
  }
1027
- /** Compile against a CSV file (the dataSource store's engine). */
1028
- function compileCsvQuery(query, primaryKey) {
1029
- return compileQuery(query, `read_csv(${readCsvArgs(primaryKey)})`);
742
+ /** A flag named by `completionField` is evaluated against the RAW record — the
743
+ * reconciler (and spawn's fallback) read items straight off disk, BEFORE any
744
+ * `deriveAll` enrichment — so its `where` may only reference STORED fields. A
745
+ * condition over a computed sibling would see an absent key: `ne` matches
746
+ * vacuously, every other op reads false, and the bell would clear wrongly /
747
+ * never. General (non-completion) flags keep the full vocabulary — the UI
748
+ * evaluates them post-enrichment. */
749
+ function completionFlagReadsOnlyStoredFields(schema) {
750
+ const spec = schema.completionField === void 0 ? void 0 : declaredField(schema.fields, schema.completionField);
751
+ if (spec?.type !== "flag") return true;
752
+ return spec.where.every((cond) => [cond.field, ...cond.valueFrom ? [cond.valueFrom.field] : []].every((name) => {
753
+ const target = declaredField(schema.fields, name);
754
+ return target !== void 0 && !require_calendarGrid.COMPUTED_TYPES.has(target.type);
755
+ }));
1030
756
  }
1031
- /** Compile against a JSONL file of ENRICHED records — the file-backed
1032
- * collections' engine (see `jsonlQuery.ts`). No VARCHAR key pin needed:
1033
- * enriched record ids are already strings. `sample_size=-1` makes the
1034
- * schema inference scan EVERY line — with the default sample, a sparse
1035
- * optional/derived field first appearing past the sample would not be
1036
- * inferred as a column and the query would binder-error on it (Codex P2
1037
- * on #2165). The full scan costs nothing extra here: aggregation reads
1038
- * the whole file anyway. */
1039
- function compileJsonlQuery(query) {
1040
- return compileQuery(query, `read_json(?, format='newline_delimited', sample_size=-1)`);
757
+ /** `displayField`, like `completionField`, must name a real top-level field —
758
+ * a typo would silently fall back to the primaryKey forever. */
759
+ function displayFieldIsDeclared(schema) {
760
+ return schema.displayField === void 0 || declaredField(schema.fields, schema.displayField) !== void 0;
1041
761
  }
1042
- //#endregion
1043
- //#region src/collection/server/csvStore.ts
1044
- /** `list()` row cap. Over-cap files are truncated with a warn — the v1
1045
- * contract is "browse + per-record views", not full-table analytics. */
1046
- var MAX_CSV_ROWS = 5e3;
1047
- /** Record ids minted from non-safe key values: `id0x` + utf-8 hex. Raw key
1048
- * values that themselves match this pattern are ALSO encoded, so the
1049
- * encoded namespace never collides with a raw value (injective mapping). */
1050
- var ENCODED_ID_PATTERN = /^id0x([0-9a-f]+)$/;
1051
- /** A CSV key value → the record id it's addressed by. Safe values pass
1052
- * through untouched; everything else (and anything shaped like an encoded
1053
- * id) becomes `id0x<hex>`. Pure + exported for unit tests. */
1054
- function encodeCsvRecordId(rawKey) {
1055
- if (safeRecordId(rawKey) === rawKey && !ENCODED_ID_PATTERN.test(rawKey)) return rawKey;
1056
- return `id0x${Buffer.from(rawKey, "utf-8").toString("hex")}`;
762
+ /** A field's `when.field` gates its visibility against a sibling's value, so it
763
+ * must name a real top-level field — a typo would silently keep the field
764
+ * hidden forever (the gate never matches). */
765
+ function fieldVisibilityGatesNameDeclaredFields(schema) {
766
+ return Object.values(schema.fields).every((field) => field.when === void 0 || declaredField(schema.fields, field.when.field) !== void 0);
1057
767
  }
1058
- /** A record id → the CSV key value to look up. Inverse of
1059
- * `encodeCsvRecordId` for encoded ids; anything else is already the raw
1060
- * value. Pure + exported for unit tests. */
1061
- function decodeCsvRecordId(itemId) {
1062
- const hex = ENCODED_ID_PATTERN.exec(itemId)?.[1];
1063
- if (hex === void 0) return itemId;
1064
- return Buffer.from(hex, "hex").toString("utf-8");
768
+ /** A flag's `where` reads sibling fields (both `cond.field` and a same-record
769
+ * `valueFrom.field`), so each must name a real top-level field — a typo would
770
+ * silently pin the flag false forever (`ne`: true forever). */
771
+ function flagConditionsNameDeclaredFields(schema) {
772
+ return Object.values(schema.fields).every((field) => field.type !== "flag" || field.where.every((cond) => declaredField(schema.fields, cond.field) !== void 0 && (cond.valueFrom === void 0 || declaredField(schema.fields, cond.valueFrom.field) !== void 0)));
1065
773
  }
1066
- /** Normalize one DuckDB JS value into a JSON-safe record value: BigInt →
1067
- * number (string beyond the safe range), DATE/TIMESTAMP → ISO string
1068
- * (date-only when the clock is exactly UTC midnight, matching the `date`
1069
- * field contract), exotic DuckDB values → their string form. Pure +
1070
- * exported for unit tests. */
1071
- /** `JSON.stringify` restricted to what a CSV cell can survive. Returns the
1072
- * serialised value, or `String(value)` when serialisation is impossible —
1073
- * losing the content of one cell is bad, failing the entire query is worse. */
1074
- function safeJsonCell(value) {
1075
- try {
1076
- return JSON.stringify(value, (_key, entry) => typeof entry === "bigint" ? entry.toString() : entry) ?? String(value);
1077
- } catch {
1078
- return String(value);
1079
- }
774
+ /** An `embed`'s `idField` resolves the target record id from a sibling's value,
775
+ * so it must name a real top-level field — and one whose stored value is a
776
+ * plain id string. Only `ref` / `string` qualify: the editor writes the picked
777
+ * id into that field, so a non-persisted or composite type would either not
778
+ * round-trip on save or hold no usable id. */
779
+ function embedIdFieldsNameIdBearingFields(schema) {
780
+ return Object.values(schema.fields).every((field) => {
781
+ if (field.type !== "embed" || field.idField === void 0) return true;
782
+ const target = declaredField(schema.fields, field.idField);
783
+ return target !== void 0 && (target.type === "ref" || target.type === "string");
784
+ });
1080
785
  }
1081
- function normalizeCsvValue(value) {
1082
- if (typeof value === "bigint") return value <= BigInt(Number.MAX_SAFE_INTEGER) && value >= BigInt(-Number.MAX_SAFE_INTEGER) ? Number(value) : value.toString();
1083
- if (value instanceof Date) {
1084
- const iso = value.toISOString();
1085
- return iso.endsWith("T00:00:00.000Z") ? iso.slice(0, 10) : iso;
786
+ /** The sync writes each mapped value into a declared field, and puts the Google
787
+ * event id in the primary field — so a map key that names no field (or names
788
+ * the primary) would silently drop data or fight the id. */
789
+ function googleCalendarMapNamesStoredFields(schema) {
790
+ if (schema.googleCalendar === void 0) return true;
791
+ return Object.keys(schema.googleCalendar.map).every((key) => namesStoredField(schema.fields, key, schema.primaryKey));
792
+ }
793
+ /** A `toggle` field projects an `enum` field: its `field` must name a real
794
+ * top-level enum, and `onValue` / `offValue` must be members of that enum's
795
+ * `values` — otherwise toggling would write a value outside the closed set
796
+ * (and never appear "checked"). */
797
+ function togglesProjectValidEnums(schema) {
798
+ const { fields } = schema;
799
+ for (const spec of Object.values(fields)) {
800
+ if (spec.type !== "toggle") continue;
801
+ const target = declaredField(fields, spec.field);
802
+ if (!target || target.type !== "enum") return false;
803
+ const allowed = new Set(target.values);
804
+ if (!allowed.has(spec.onValue) || !allowed.has(spec.offValue)) return false;
1086
805
  }
1087
- if (value !== null && typeof value === "object") return safeJsonCell(value);
1088
- return value;
806
+ return true;
1089
807
  }
1090
- /** One raw DuckDB row → a CollectionItem, or null when the key cell is
1091
- * missing/empty (the row can't be addressed). The primaryKey field is
1092
- * OVERWRITTEN with the (possibly encoded) record id so `item[primaryKey]`
1093
- * and the record's address never drift — same invariant the file store's
1094
- * write path enforces. Pure + exported for unit tests. */
1095
- function csvRowToItem(row, primaryKey) {
1096
- const normalized = Object.fromEntries(Object.entries(row).map(([key, value]) => [key, normalizeCsvValue(value)]));
1097
- const rawKey = normalized[primaryKey];
1098
- const keyText = require_calendarGrid.fieldTextOrNull(rawKey);
1099
- if (keyText === null || keyText === "") return null;
1100
- return {
1101
- ...normalized,
1102
- [primaryKey]: encodeCsvRecordId(keyText)
1103
- };
808
+ /** `triggerField` requires the completion pair: the time gate only suppresses
809
+ * the *completion* bell until the date, and the bell still clears via
810
+ * `completionDoneValues`. Without completion there is no bell to gate. */
811
+ function triggerFieldRequiresCompletion(schema) {
812
+ return schema.triggerField === void 0 || schema.completionField !== void 0;
1104
813
  }
1105
- /** Dedupe by record id, LAST row wins (matches `csvRead`'s last-match
1106
- * pick). Returns the surviving items in first-seen order. Pure +
1107
- * exported for unit tests. */
1108
- function dedupeByRecordId(items, primaryKey) {
1109
- const byId = /* @__PURE__ */ new Map();
1110
- for (const item of items) byId.set(String(item[primaryKey]), item);
1111
- return {
1112
- items: [...byId.values()],
1113
- duplicates: items.length - byId.size
1114
- };
814
+ /** `triggerField` must name a real `date` field — the gate parses its value as
815
+ * `YYYY-MM-DD`; any other type can't be compared to the clock. */
816
+ function triggerFieldIsADateField(schema) {
817
+ return schema.triggerField === void 0 || declaredField(schema.fields, schema.triggerField)?.type === "date";
1115
818
  }
1116
- /** True when a thrown DuckDB error is the `types` pin naming a column the
1117
- * CSV doesn't have — the schema/file-mismatch case the caller downgrades
1118
- * to "empty collection + warn" instead of a 500. */
1119
- function isMissingKeyColumnError(err) {
1120
- return String(err).includes("do not exist in the CSV");
819
+ /** `triggerLeadDays` only means something relative to a trigger date. */
820
+ function triggerLeadDaysRequiresTriggerField(schema) {
821
+ return schema.triggerLeadDays === void 0 || schema.triggerField !== void 0;
1121
822
  }
1122
- /** Bytes sniffed for UTF-8 validity. The trailing 3 bytes of the sample
1123
- * are dropped so a multibyte char split at the boundary can't produce a
1124
- * false negative on a valid file. */
1125
- var SNIFF_BYTES = 1048576;
1126
- function isValidUtf8(buf) {
1127
- try {
1128
- new TextDecoder("utf-8", { fatal: true }).decode(buf);
1129
- return true;
1130
- } catch {
1131
- return false;
1132
- }
823
+ /** `spawn` advances `triggerField` to compute the successor's trigger date, so
824
+ * the schema must declare one. */
825
+ function spawnRequiresTriggerField(schema) {
826
+ return schema.spawn === void 0 || schema.triggerField !== void 0;
1133
827
  }
1134
- /** Detect the (best-effort) encoding of a non-UTF-8 buffer. BOMs decide
1135
- * UTF-16; otherwise cp932 (the Shift_JIS superset — Excel-exported
1136
- * Japanese CSVs are the primary non-UTF-8 case this feature serves). */
1137
- function fallbackEncoding(buf) {
1138
- if (buf.length >= 2 && buf[0] === 255 && buf[1] === 254) return "utf-16le";
1139
- if (buf.length >= 2 && buf[0] === 254 && buf[1] === 255) return "utf-16be";
1140
- return "cp932";
828
+ /** `spawn.when.field` must name a real top-level field — a typo would silently
829
+ * never match. */
830
+ function spawnWhenFieldIsDeclared(schema) {
831
+ return schema.spawn?.when === void 0 || declaredField(schema.fields, schema.spawn.when.field) !== void 0;
1141
832
  }
1142
- function cacheDir() {
1143
- return node_path.default.join((0, node_os.tmpdir)(), "mulmoclaude-csv-utf8");
833
+ /** Every `spawn.carry` entry must name a real top-level field — a typo would
834
+ * silently never copy. */
835
+ function spawnCarryEntriesAreDeclared(schema) {
836
+ return (schema.spawn?.carry ?? []).every((name) => declaredField(schema.fields, name) !== void 0);
1144
837
  }
1145
- /** Read only the first `bytes` of a file — the encoding sniff must not
1146
- * pull a multi-hundred-MB CSV into memory on the (common) UTF-8 path. */
1147
- async function readHead(absPath, bytes) {
1148
- const handle = await (0, node_fs_promises.open)(absPath, "r");
1149
- try {
1150
- const { size } = await handle.stat();
1151
- const buf = Buffer.alloc(Math.min(bytes, size));
1152
- await handle.read(buf, 0, buf.length, 0);
1153
- return buf;
1154
- } finally {
1155
- await handle.close();
1156
- }
838
+ /** A successor must NOT be born already matching its own spawn predicate — it
839
+ * would re-spawn on its first reconcile, fanning out into an unbounded chain
840
+ * of records. The predicate field/values are `spawn.when` when given, else the
841
+ * completion-done pair. The successor's value for that field is `set[field]`
842
+ * if set, else the carried source value (which matched, by definition, when
843
+ * the spawn fired) if carried, else absent (safe). */
844
+ function spawnSuccessorStartsInert(schema) {
845
+ const { spawn } = schema;
846
+ if (!spawn) return true;
847
+ const field = spawn.when?.field ?? schema.completionField;
848
+ const values = spawn.when?.in ?? schema.completionDoneValues;
849
+ if (!field || !values) return true;
850
+ if (spawn.set && Object.prototype.hasOwnProperty.call(spawn.set, field)) return !values.includes(String(spawn.set[field]));
851
+ return !(spawn.carry ?? []).includes(field);
1157
852
  }
1158
- /** Decode the whole file into a UTF-8 cache copy and return its path.
1159
- * Cache key = (path, mtime, size), so a replaced CSV re-decodes and an
1160
- * unchanged one never does. */
1161
- async function pathExists(target) {
1162
- try {
1163
- await (0, node_fs_promises.stat)(target);
1164
- return true;
1165
- } catch {
1166
- return false;
1167
- }
853
+ /** `spawnSuccessorStartsInert` cannot see through a flag's `where` (the
854
+ * predicate would need full record evaluation against `set`/`carry`). So a
855
+ * schema whose completion is flag-form may only spawn with an explicit
856
+ * `spawn.when` — which that check CAN evaluate. */
857
+ function flagCompletionSpawnDeclaresWhen(schema) {
858
+ return schema.spawn === void 0 || schema.spawn.when !== void 0 || declaredField(schema.fields, schema.completionField ?? "")?.type !== "flag";
1168
859
  }
1169
- /** Best-effort removal of older decode-cache entries for the same source
1170
- * path — a frequently-replaced large CSV would otherwise accumulate one
1171
- * full copy per (mtime, size) forever. Runs AFTER the current copy is
1172
- * published; a concurrent reader holding an old fd is unaffected
1173
- * (unlink-while-open is safe on POSIX). */
1174
- async function evictSupersededCache(key, keepBasename) {
1175
- try {
1176
- const entries = await (0, node_fs_promises.readdir)(cacheDir());
1177
- await Promise.all(entries.filter((name) => name.startsWith(`${key}-`) && name !== keepBasename).map((name) => (0, node_fs_promises.unlink)(node_path.default.join(cacheDir(), name)).catch(() => void 0)));
1178
- } catch {}
860
+ function fieldDrivenSpawnEvery(schema) {
861
+ const every = schema.spawn?.every;
862
+ if (!every || !("fromField" in every)) return null;
863
+ return every;
1179
864
  }
1180
- /** Decode the whole file into a UTF-8 cache copy and return its path.
1181
- * Cache key = (path, mtime, size), so a replaced CSV re-decodes and an
1182
- * unchanged one never does; superseded copies are evicted. The cache
1183
- * lives in the SHARED OS tmpdir, so the dir is 0700 and files 0600 —
1184
- * decoded rows must not be readable by other local users. */
1185
- async function decodeToCache(absPath, info) {
1186
- const key = (0, node_crypto.createHash)("sha256").update(absPath).digest("hex").slice(0, 16);
1187
- const cached = node_path.default.join(cacheDir(), `${key}-${Math.trunc(info.mtimeMs)}-${info.size}.csv`);
1188
- if (!await pathExists(cached)) {
1189
- const whole = await (0, node_fs_promises.readFile)(absPath);
1190
- const encoding = fallbackEncoding(whole);
1191
- const text = iconv_lite.default.decode(whole, encoding);
1192
- await (0, node_fs_promises.mkdir)(cacheDir(), {
1193
- recursive: true,
1194
- mode: 448
1195
- });
1196
- const tmp = `${cached}.${(0, node_crypto.randomBytes)(4).toString("hex")}.tmp`;
1197
- await (0, node_fs_promises.writeFile)(tmp, text, {
1198
- encoding: "utf-8",
1199
- mode: 384
1200
- });
1201
- await (0, node_fs_promises.rename)(tmp, cached);
1202
- log.info("collections", "decoded non-UTF-8 dataSource file to cache", {
1203
- path: absPath,
1204
- encoding
1205
- });
1206
- await evictSupersededCache(key, node_path.default.basename(cached));
1207
- }
1208
- return cached;
865
+ /** §4.1 — `fromField` must name a real top-level `enum` field. The `map` keys
866
+ * are only meaningful against a closed value set, and the field renders as a
867
+ * form `<select>`; a non-enum target has no finite values to validate. */
868
+ function fieldDrivenFromFieldIsEnum(schema) {
869
+ const driven = fieldDrivenSpawnEvery(schema);
870
+ if (!driven) return true;
871
+ return declaredField(schema.fields, driven.fromField)?.type === "enum";
1209
872
  }
1210
- /** Re-validate the dataSource file at READ time, mirroring the JSON
1211
- * store's per-read defenses: realpath containment (a symlink swapped in
1212
- * after discovery must not walk out of the workspace) and an lstat
1213
- * regular-file check (a symlink leaf is refused outright, even one
1214
- * pointing inside the workspace — same rule as `isRegularFile` on
1215
- * record files). Returns the stat info, or null for "no readable file"
1216
- * (ENOENT / refused), which callers render as an empty collection. */
1217
- async function safeCsvStat(absPath, workspaceRoot) {
1218
- if (!isContainedInRoot(absPath, workspaceRoot)) {
1219
- log.warn("collections", "dataSource read refused: path escapes workspace", { path: absPath });
1220
- return null;
1221
- }
1222
- let info;
1223
- try {
1224
- info = await (0, node_fs_promises.lstat)(absPath);
1225
- } catch (err) {
1226
- if (require_dist.isErrorWithCode(err) && err.code === "ENOENT") return null;
1227
- throw err;
1228
- }
1229
- if (!info.isFile()) {
1230
- log.warn("collections", "dataSource read refused: not a regular file (symlink?)", { path: absPath });
1231
- return null;
873
+ /** §4.2 — `map` keys must EXACTLY cover the enum's `values` (no missing keys —
874
+ * a record could pick an unmapped frequency and silently stall; no extra keys
875
+ * — a stale map outliving an enum edit). */
876
+ function fieldDrivenMapCoversValues(schema) {
877
+ const driven = fieldDrivenSpawnEvery(schema);
878
+ if (!driven) return true;
879
+ const target = declaredField(schema.fields, driven.fromField);
880
+ if (target?.type !== "enum") return true;
881
+ const values = new Set(target.values);
882
+ const keys = Object.keys(driven.map);
883
+ return keys.length === values.size && keys.every((key) => values.has(key));
884
+ }
885
+ /** §4.5 — `fromField` must reach the successor (via `carry` or `set`);
886
+ * otherwise the successor loses its frequency and the NEXT spawn along the
887
+ * chain can't resolve an interval, silently halting the recurrence.
888
+ *
889
+ * `set` writes a FIXED value, so it must itself be a key of `map` (else the
890
+ * successor is born with an unresolvable driver and `resolveEvery` skips it —
891
+ * the exact silent-halt §4.5 exists to prevent). `carry` copies the source's
892
+ * own value, which — for a record that matched the spawn — is one of the
893
+ * enum's values, all of which `map` covers by §4.2; so a carried driver is
894
+ * always resolvable and needs no value check here. */
895
+ function fieldDrivenFromFieldCarried(schema) {
896
+ const driven = fieldDrivenSpawnEvery(schema);
897
+ if (!driven) return true;
898
+ const { carry, set } = schema.spawn ?? {};
899
+ if (set && Object.prototype.hasOwnProperty.call(set, driven.fromField)) {
900
+ const raw = set[driven.fromField];
901
+ if (raw === void 0 || raw === null || raw === "") return false;
902
+ const key = require_calendarGrid.fieldTextOrNull(raw);
903
+ return key !== null && Object.prototype.hasOwnProperty.call(driven.map, key);
1232
904
  }
1233
- return info;
905
+ return (carry ?? []).includes(driven.fromField);
1234
906
  }
1235
- /** Return a path DuckDB can read as UTF-8: the original file when it
1236
- * already is UTF-8 (the cheap, common case — only the head is sniffed),
1237
- * else a decoded cache copy (see `decodeToCache`). Returns null when
1238
- * there is no readable file (missing, symlink, or containment-refused —
1239
- * see `safeCsvStat`), which callers render as an empty collection. */
1240
- async function ensureUtf8CsvPath(absPath, workspaceRoot) {
1241
- const info = await safeCsvStat(absPath, workspaceRoot);
1242
- if (info === null) return null;
1243
- const head = await readHead(absPath, SNIFF_BYTES);
1244
- const sample = head.length === SNIFF_BYTES ? head.subarray(0, 1048573) : head;
1245
- if (!(head.length >= 2 && (head[0] === 255 && head[1] === 254 || head[0] === 254 && head[1] === 255)) && isValidUtf8(sample)) return absPath;
1246
- return decodeToCache(absPath, info);
907
+ /** `calendarField` must name a real `date`/`datetime` field — the calendar view
908
+ * parses its value to place records on the month grid (a `datetime` anchor
909
+ * also carries the clock for the day view). */
910
+ function calendarFieldIsDateLike(schema) {
911
+ return schema.calendarField === void 0 || isDateLike(declaredField(schema.fields, schema.calendarField)?.type);
1247
912
  }
1248
- var instancePromise = null;
1249
- /** Lazily create one shared in-memory DuckDB instance. The dynamic import
1250
- * keeps the native module OUT of core's load path — a platform where the
1251
- * prebuilt binding is missing degrades to a per-query error on dataSource
1252
- * collections only, never a broken core. A failed init is retried on the
1253
- * next call (the promise is reset). */
1254
- async function duckDbInstance() {
1255
- if (instancePromise === null) instancePromise = import("@duckdb/node-api").then((mod) => mod.DuckDBInstance.create(":memory:"));
1256
- try {
1257
- return await instancePromise;
1258
- } catch (err) {
1259
- instancePromise = null;
1260
- throw new BackendUnavailableError(`DuckDB is unavailable on this host (@duckdb/node-api failed to load: ${String(err)}) — dataSource collections cannot be read`);
1261
- }
913
+ /** `calendarEndField` marks the end of a multi-day span, so it only means
914
+ * something alongside a start anchor. */
915
+ function calendarEndFieldRequiresCalendarField(schema) {
916
+ return schema.calendarEndField === void 0 || schema.calendarField !== void 0;
1262
917
  }
1263
- async function queryCsv(sql, params) {
1264
- const connection = await (await duckDbInstance()).connect();
1265
- try {
1266
- return (await connection.runAndReadAll(sql, params)).getRowObjectsJS();
1267
- } finally {
1268
- connection.disconnectSync();
1269
- }
918
+ /** `calendarEndField` must also name a real `date`/`datetime` field — same parse. */
919
+ function calendarEndFieldIsDateLike(schema) {
920
+ return schema.calendarEndField === void 0 || isDateLike(declaredField(schema.fields, schema.calendarEndField)?.type);
1270
921
  }
1271
- async function csvList(absPath, primaryKey, workspaceRoot) {
1272
- const utf8Path = await ensureUtf8CsvPath(absPath, workspaceRoot ?? getWorkspaceRoot());
1273
- if (utf8Path === null) return {
1274
- items: [],
1275
- truncated: false
1276
- };
1277
- let rows;
1278
- try {
1279
- rows = await queryCsv(`SELECT * FROM read_csv(${readCsvArgs(primaryKey)}) LIMIT 5001`, [utf8Path]);
1280
- } catch (err) {
1281
- if (!isMissingKeyColumnError(err)) throw err;
1282
- log.warn("collections", "dataSource CSV has no primaryKey column — every row is skipped", {
1283
- path: absPath,
1284
- primaryKey
1285
- });
1286
- return {
1287
- items: [],
1288
- truncated: false
1289
- };
1290
- }
1291
- const truncated = rows.length > MAX_CSV_ROWS;
1292
- if (truncated) {
1293
- log.warn("collections", "dataSource CSV truncated to row cap", {
1294
- path: absPath,
1295
- cap: MAX_CSV_ROWS
1296
- });
1297
- rows.length = MAX_CSV_ROWS;
1298
- }
1299
- const items = rows.map((row) => csvRowToItem(row, primaryKey)).filter((item) => item !== null);
1300
- const skipped = rows.length - items.length;
1301
- if (skipped > 0) log.warn("collections", "dataSource CSV rows skipped (empty key cell)", {
1302
- path: absPath,
1303
- skipped
1304
- });
1305
- const deduped = dedupeByRecordId(items, primaryKey);
1306
- if (deduped.duplicates > 0) log.warn("collections", "dataSource CSV has duplicate key values (last row wins)", {
1307
- path: absPath,
1308
- duplicates: deduped.duplicates
1309
- });
1310
- return {
1311
- items: deduped.items,
1312
- truncated
1313
- };
922
+ /** `calendarTimeField` places records on the day view, so it only means
923
+ * something alongside a start anchor. */
924
+ function calendarTimeFieldRequiresCalendarField(schema) {
925
+ return schema.calendarTimeField === void 0 || schema.calendarField !== void 0;
1314
926
  }
1315
- /** The scan-order ordinal column the last-match read adds. Underscore
1316
- * prefix keeps it out of any plausible CSV header namespace; it is
1317
- * stripped from the returned record either way. */
1318
- var ROW_ORDINAL = "__mc_row";
1319
- /** One record by id. The comparison value rides as a prepared-statement
1320
- * parameter, and the LAST matching row is selected IN DuckDB (scan-order
1321
- * ordinal + LIMIT 1) — a CSV with thousands of duplicate keys must not
1322
- * materialize them all for one detail read. Consistent with csvList's
1323
- * last-wins dedupe. */
1324
- async function csvRead(absPath, primaryKey, itemId, workspaceRoot) {
1325
- const utf8Path = await ensureUtf8CsvPath(absPath, workspaceRoot ?? getWorkspaceRoot());
1326
- if (utf8Path === null) return null;
1327
- const rawKey = decodeCsvRecordId(itemId);
1328
- const last = (await queryCsv(`SELECT * FROM (SELECT *, row_number() OVER () AS ${quoteIdent(ROW_ORDINAL)} FROM read_csv(${readCsvArgs(primaryKey)})) WHERE CAST(${quoteIdent(primaryKey)} AS VARCHAR) = ? ORDER BY ${quoteIdent(ROW_ORDINAL)} DESC LIMIT 1`, [utf8Path, rawKey])).at(0);
1329
- if (last === void 0) return null;
1330
- const { [ROW_ORDINAL]: __ordinal, ...record } = last;
1331
- return csvRowToItem(record, primaryKey);
927
+ /** `calendarTimeField` must name a real top-level field (a free-form time
928
+ * string the day view parses). */
929
+ function calendarTimeFieldIsDeclared(schema) {
930
+ return schema.calendarTimeField === void 0 || declaredField(schema.fields, schema.calendarTimeField) !== void 0;
1332
931
  }
1333
- /** Run a validated aggregation query (the structured DSL — see
1334
- * `core/queryZ.ts`) over the WHOLE file: no row cap on the scan (a
1335
- * capped aggregate would be a wrong number), only the result-row LIMIT
1336
- * the compiler emits. Values are normalized like list/read rows so a
1337
- * chart consumer gets plain JSON scalars. */
1338
- async function csvRunQuery(absPath, primaryKey, query, workspaceRoot) {
1339
- const utf8Path = await ensureUtf8CsvPath(absPath, workspaceRoot ?? getWorkspaceRoot());
1340
- if (utf8Path === null) return [];
1341
- const { sql, params } = compileCsvQuery(query, primaryKey);
1342
- return (await queryCsv(sql, [utf8Path, ...params])).map((row) => Object.fromEntries(Object.entries(row).map(([key, value]) => [key, normalizeCsvValue(value)])));
932
+ /** …and that field must be string-backed — the day view parses its value as a
933
+ * time string, so a number/enum/date column can't drive it. */
934
+ function calendarTimeFieldIsStringBacked(schema) {
935
+ return schema.calendarTimeField === void 0 || isTimeStringField(declaredField(schema.fields, schema.calendarTimeField)?.type);
936
+ }
937
+ /** `kanbanField` must name a real `enum` field — the board groups records into
938
+ * one column per declared enum value; any other type has no closed set of
939
+ * columns to group by. */
940
+ function kanbanFieldIsAnEnum(schema) {
941
+ return schema.kanbanField === void 0 || declaredField(schema.fields, schema.kanbanField)?.type === "enum";
942
+ }
943
+ /** `notifyWhen` narrows the completion bell, so it only means something with
944
+ * completion tracking. */
945
+ function notifyWhenRequiresCompletion(schema) {
946
+ return schema.notifyWhen === void 0 || schema.completionField !== void 0;
947
+ }
948
+ /** `notifyWhen.field` must name a real top-level field. */
949
+ function notifyWhenFieldIsDeclared(schema) {
950
+ return schema.notifyWhen === void 0 || declaredField(schema.fields, schema.notifyWhen.field) !== void 0;
951
+ }
952
+ /** Every custom view `id` must be a valid slug — it doubles as the view-mode
953
+ * selector key (`custom:<id>`) and the capability-token clamp key, both of
954
+ * which expect a path-safe token. */
955
+ function viewIdsAreSlugs(schema) {
956
+ return schema.views === void 0 || schema.views.every((view) => require_calendarGrid.isSafeSlug(view.id));
957
+ }
958
+ /** Custom view ids must be unique so the selector + token clamp resolve
959
+ * unambiguously. */
960
+ function viewIdsAreUnique(schema) {
961
+ return hasUniqueIds(schema.views);
1343
962
  }
1344
963
  //#endregion
1345
- //#region src/collection/server/watchFs.ts
1346
- /** An atomic file replace (editor save, `mv` over the target) surfaces as
1347
- * 2-3 events. Collapse them so one user action reports one change. */
1348
- var REPLACE_DEBOUNCE_MS = 300;
1349
- /** The path to hand `watch()`, with Windows 8.3 short names resolved away.
1350
- *
1351
- * ReadDirectoryChangesW reports filenames against the LONG path, but a watch
1352
- * opened on a short path (`C:\Users\RUNNER~1\…` — what `os.tmpdir()` returns
1353
- * on GitHub's Windows runners) keeps the short form. libuv's
1354
- * `assert(!_wcsnicmp(filename, dir, dirlen))` in `src/win/fs-event.c` then
1355
- * aborts the PROCESS on the first event — a native assert, so neither
1356
- * `watcher.on("error")` nor a try/catch can contain it.
1357
- *
1358
- * POSIX is deliberately left alone: `realpath` there also collapses symlinks
1359
- * (`/var` → `/private/var` on macOS), which we neither need nor want to
1360
- * change. A failure falls back to the original path — worst case we are no
1361
- * worse off than before. */
1362
- function watchablePath(dir) {
1363
- if (process.platform !== "win32") return dir;
1364
- try {
1365
- return node_fs.realpathSync.native(dir);
1366
- } catch {
1367
- return dir;
1368
- }
1369
- }
1370
- /** Watch `dir`, reporting each accepted filename. `accept` decides what is
1371
- * noise; a null filename always passes (the platform didn't tell us which
1372
- * file, so the caller must assume the worst). */
1373
- async function watchDirectory(dir, accept, onHit) {
1374
- try {
1375
- await (0, node_fs_promises.mkdir)(dir, { recursive: true });
1376
- const watcher = (0, node_fs.watch)(watchablePath(dir), { persistent: false }, (_eventType, rawFilename) => {
1377
- const filename = rawFilename === null ? null : String(rawFilename);
1378
- if (filename !== null && !accept(filename)) return;
1379
- onHit(filename);
1380
- });
1381
- watcher.on("error", (err) => {
1382
- log.warn("collections", "fs watch error", {
1383
- dir,
1384
- error: String(err)
1385
- });
1386
- });
1387
- return { close: () => watcher.close() };
1388
- } catch (err) {
1389
- log.warn("collections", "fs watch start failed", {
1390
- dir,
1391
- error: String(err)
1392
- });
1393
- return null;
1394
- }
1395
- }
1396
- /** Watch the single file `absPath` by watching its PARENT directory, so an
1397
- * atomic replace can't strand the watch on a dead inode. `alsoAccept`
1398
- * widens the filter beyond the exact basename (sqlite's `-wal`/`-journal`
1399
- * sidecars). Reports are debounced: one replace, one call. */
1400
- async function watchSingleFile(absPath, alsoAccept, onChange) {
1401
- const dir = node_path.default.dirname(absPath);
1402
- const base = node_path.default.basename(absPath);
1403
- let timer = null;
1404
- const fire = () => {
1405
- if (timer) clearTimeout(timer);
1406
- timer = setTimeout(() => {
1407
- timer = null;
1408
- onChange();
1409
- }, REPLACE_DEBOUNCE_MS);
1410
- timer.unref?.();
1411
- };
1412
- const handle = await watchDirectory(dir, (filename) => filename === base || alsoAccept(base, filename), fire);
1413
- if (!handle) return null;
1414
- return { close: () => {
1415
- if (timer) clearTimeout(timer);
1416
- timer = null;
1417
- handle.close();
1418
- } };
1419
- }
1420
- /** An `FsWatchHandle` as a bare unsubscribe — `null` straight through, so an
1421
- * unarmed watch stays distinguishable from an armed one. Lives here rather
1422
- * than beside the store contract so both `store.ts` and the backends it
1423
- * registers can reach it without importing each other. */
1424
- function closerFor(handle) {
1425
- return handle === null ? null : () => handle.close();
1426
- }
1427
- //#endregion
1428
- //#region src/collection/server/sqliteStore.ts
1429
- /** A constructor's parameter and return types are not observable at runtime,
1430
- * so the check stops at "DatabaseSync is constructible" — the only member of
1431
- * the module this store ever touches. */
1432
- function isSqliteModule(mod) {
1433
- return require_dist.isRecord(mod) && typeof mod.DatabaseSync === "function";
1434
- }
1435
- var sqliteModule = null;
1436
- /** Drops the memo first so a later call can retry (e.g. tests stubbing the
1437
- * runtime), then reports why the backend is unusable. */
1438
- function sqliteUnavailable(reason) {
1439
- sqliteModule = null;
1440
- throw new BackendUnavailableError(`sqlite storage needs the node:sqlite module (Node.js >= 22.5) — this runtime cannot load it: ${reason}`);
1441
- }
1442
- /** Lazy-load node:sqlite once. A runtime without it (Node < 22.5) throws a
1443
- * clearly-worded error the caller surfaces — never a bare MODULE_NOT_FOUND. */
1444
- function loadSqlite() {
1445
- sqliteModule ??= import("node:sqlite").then((mod) => isSqliteModule(mod) ? mod : sqliteUnavailable("the module exposes no DatabaseSync constructor"), (err) => sqliteUnavailable(String(err)));
1446
- return sqliteModule;
1447
- }
1448
- /** The db file's on-disk state. A symlink or non-regular file is refused
1449
- * (file-disclosure defense, same rule as io.ts record files); ENOENT is
1450
- * just "no records yet". Any OTHER lstat failure (EACCES, EIO, …) is
1451
- * rethrown so reads surface a real filesystem problem instead of
1452
- * silently reporting an empty collection. */
1453
- async function dbFileState(absPath) {
1454
- try {
1455
- return (await (0, node_fs_promises.lstat)(absPath)).isFile() ? "file" : "refused";
1456
- } catch (err) {
1457
- if (require_dist.isErrorWithCode(err) && err.code === "ENOENT") return "missing";
1458
- throw err;
1459
- }
1460
- }
1461
- var CREATE_TABLE = "CREATE TABLE IF NOT EXISTS records (id TEXT PRIMARY KEY, record TEXT NOT NULL)";
1462
- /** Open the database for one operation, classifying the two unavailable
1463
- * states so callers can map them honestly (`refused` ⇒ path-escape,
1464
- * `missing` ⇒ empty / not-found — conflating them would misreport a
1465
- * containment escape as "item not found"). The containment pre-check runs
1466
- * BEFORE mkdir even when the file is missing — `isContainedInRoot`
1467
- * resolves through the closest existing ancestor, so a symlinked-away
1468
- * parent can never make the recursive mkdir create directories outside
1469
- * the workspace (same pre/post belt-and-suspenders as io.ts writes). */
1470
- async function openDb(absPath, workspaceRoot, mode) {
1471
- const state = await dbFileState(absPath);
1472
- if (state === "refused") {
1473
- log.warn("collections", "sqlite database refused: not a regular file", { path: absPath });
1474
- return { kind: "refused" };
1475
- }
1476
- if (!isContainedInRoot(node_path.default.dirname(absPath), workspaceRoot)) {
1477
- log.warn("collections", "sqlite refused: database dir escapes workspace via symlink", { path: absPath });
1478
- return { kind: "refused" };
1479
- }
1480
- if (mode === "read" && state === "missing") return { kind: "missing" };
1481
- if (mode === "write") {
1482
- await (0, node_fs_promises.mkdir)(node_path.default.dirname(absPath), { recursive: true });
1483
- if (!isContainedInRoot(node_path.default.dirname(absPath), workspaceRoot)) {
1484
- log.warn("collections", "sqlite write refused: database dir escapes workspace via symlink (post-mkdir)", { path: absPath });
1485
- return { kind: "refused" };
1486
- }
1487
- }
1488
- const { DatabaseSync } = await loadSqlite();
1489
- const database = new DatabaseSync(absPath);
1490
- database.exec("PRAGMA busy_timeout = 5000");
1491
- database.exec(CREATE_TABLE);
1492
- return {
1493
- kind: "ok",
1494
- database
1495
- };
1496
- }
1497
- /** Run `operation` against the database and always close it; unavailable
1498
- * states resolve through `onUnavailable` so each caller maps `missing`
1499
- * vs `refused` to its own result kind. */
1500
- async function withDb(absPath, workspaceRoot, mode, onUnavailable, operation) {
1501
- const handle = await openDb(absPath, workspaceRoot, mode);
1502
- if (handle.kind !== "ok") return onUnavailable(handle.kind);
1503
- try {
1504
- return await operation(handle.database);
1505
- } finally {
1506
- handle.database.close();
1507
- }
1508
- }
1509
- var SQLITE_CONSTRAINT_PRIMARYKEY = 1555;
1510
- var SQLITE_CONSTRAINT_UNIQUE = 2067;
1511
- /** node:sqlite throws ERR_SQLITE_ERROR with the SQLite extended result
1512
- * code on `errcode`. Checked structurally (message text kept only as a
1513
- * fallback for runtimes that don't expose `errcode`). */
1514
- function isUniqueConstraintError(err) {
1515
- if (require_dist.hasNumberProp(err, "errcode")) return err.errcode === SQLITE_CONSTRAINT_PRIMARYKEY || err.errcode === SQLITE_CONSTRAINT_UNIQUE;
1516
- return String(err).includes("UNIQUE constraint");
1517
- }
1518
- function parseRow(raw) {
1519
- if (typeof raw !== "string") return null;
1520
- try {
1521
- const parsed = JSON.parse(raw);
1522
- return require_dist.isRecord(parsed) ? parsed : null;
1523
- } catch {
1524
- return null;
1525
- }
1526
- }
1527
- /** One column of a result row. node:sqlite types rows as `unknown`, so a
1528
- * value that is not a row object yields no column at all. */
1529
- function readColumn(row, column) {
1530
- return require_dist.isRecord(row) ? row[column] : void 0;
1531
- }
1532
- function rowsToItems(rows) {
1533
- return rows.map((row) => parseRow(readColumn(row, "record"))).filter((item) => item !== null);
1534
- }
1535
- /** node:sqlite hands back an integer column as `number`, or as `bigint` once
1536
- * it leaves the safe-integer range — COUNT(*) can be either. */
1537
- function countRecords(database) {
1538
- const count = readColumn(database.prepare("SELECT COUNT(*) AS n FROM records").get(), "n");
1539
- if (typeof count === "number") return count;
1540
- if (typeof count === "bigint") return Number(count);
1541
- throw new Error(`sqlite COUNT(*) returned no numeric row count (got ${typeof count})`);
1542
- }
1543
- async function sqliteList(absPath, workspaceRoot) {
1544
- return withDb(absPath, workspaceRoot, "read", () => [], (database) => rowsToItems(database.prepare("SELECT record FROM records ORDER BY id").all()));
1545
- }
1546
- async function sqlitePage(absPath, primaryKey, opts, workspaceRoot) {
1547
- const emptyPage = {
1548
- items: [],
1549
- total: 0,
1550
- truncated: false
1551
- };
1552
- return withDb(absPath, workspaceRoot, "read", () => emptyPage, (database) => {
1553
- const total = countRecords(database);
1554
- const offset = Math.max(0, opts.offset ?? 0);
1555
- const limit = opts.limit === void 0 ? -1 : Math.max(0, opts.limit);
1556
- return {
1557
- items: projectItemFields(rowsToItems(database.prepare("SELECT record FROM records ORDER BY id LIMIT ? OFFSET ?").all(limit, offset)), opts.fields, primaryKey),
1558
- total,
1559
- truncated: false
1560
- };
1561
- });
1562
- }
1563
- async function sqliteRead(absPath, itemId, workspaceRoot) {
1564
- const safeId = safeRecordId(itemId);
1565
- if (safeId === null) return null;
1566
- return withDb(absPath, workspaceRoot, "read", () => null, (database) => {
1567
- return parseRow(readColumn(database.prepare("SELECT record FROM records WHERE id = ?").get(safeId), "record"));
1568
- });
1569
- }
1570
- async function sqliteWrite(absPath, itemId, item, opts) {
1571
- const safeId = safeRecordId(itemId);
1572
- if (safeId === null) return {
1573
- kind: "invalid-id",
1574
- itemId
1575
- };
1576
- const outcome = await withDb(absPath, opts.workspaceRoot, "write", () => ({
1577
- kind: "path-escape",
1578
- itemId: safeId
1579
- }), (database) => {
1580
- const payload = JSON.stringify(item);
1581
- if (opts.refuseOverwrite) try {
1582
- database.prepare("INSERT INTO records (id, record) VALUES (?, ?)").run(safeId, payload);
1583
- } catch (err) {
1584
- if (isUniqueConstraintError(err)) return {
1585
- kind: "conflict",
1586
- itemId: safeId
1587
- };
1588
- throw err;
1589
- }
1590
- else database.prepare("INSERT INTO records (id, record) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET record = excluded.record").run(safeId, payload);
1591
- return {
1592
- kind: "ok",
1593
- itemId: safeId,
1594
- item
1595
- };
1596
- });
1597
- if (outcome.kind === "ok" && opts.slug) publishCollectionChange(collectionChangePayload({
1598
- slug: opts.slug,
1599
- ids: [safeId],
1600
- op: "upsert"
1601
- }, opts.publishRoot));
1602
- return outcome;
1603
- }
1604
- async function sqliteDelete(absPath, itemId, opts) {
1605
- const safeId = safeRecordId(itemId);
1606
- if (safeId === null) return {
1607
- kind: "invalid-id",
1608
- itemId
1609
- };
1610
- const outcome = await withDb(absPath, opts.workspaceRoot, "read", (reason) => reason === "refused" ? {
1611
- kind: "path-escape",
1612
- itemId: safeId
1613
- } : {
1614
- kind: "not-found",
1615
- itemId: safeId
1616
- }, (database) => {
1617
- const { changes } = database.prepare("DELETE FROM records WHERE id = ?").run(safeId);
1618
- return Number(changes) === 0 ? {
1619
- kind: "not-found",
1620
- itemId: safeId
1621
- } : {
1622
- kind: "ok",
1623
- itemId: safeId
1624
- };
1625
- });
1626
- if (outcome.kind === "ok" && opts.slug) publishCollectionChange(collectionChangePayload({
1627
- slug: opts.slug,
1628
- ids: [safeId],
1629
- op: "delete"
1630
- }, opts.publishRoot));
1631
- return outcome;
1632
- }
1633
- /** Best-effort full WAL checkpoint so the MAIN db file alone is a
1634
- * complete snapshot (committed pages in `<db>-wal` are folded in and the
1635
- * WAL truncated). Used by `deleteCollection` before archiving. Returns
1636
- * false on any failure (runtime without node:sqlite, locked db, missing
1637
- * file) — the caller then archives the sidecar files alongside the db so
1638
- * no committed data is lost either way. */
1639
- async function checkpointSqliteDatabase(absPath) {
1640
- try {
1641
- const { DatabaseSync } = await loadSqlite();
1642
- const database = new DatabaseSync(absPath);
1643
- try {
1644
- database.exec("PRAGMA wal_checkpoint(TRUNCATE)");
1645
- } finally {
1646
- database.close();
1647
- }
1648
- return true;
1649
- } catch {
1650
- return false;
1651
- }
1652
- }
1653
- /** A `storage: sqlite` store over `collection.storageFile`. A schema whose
1654
- * `storageFile` failed to resolve yields a read-only EMPTY store rather
1655
- * than a writable one — same fail-closed rule as the CSV store. */
1656
- function sqliteStoreFor(collection, opts) {
1657
- const file = collection.storageFile;
1658
- const key = collection.schema.primaryKey;
1659
- const slug = opts.slug ?? collection.slug;
1660
- const root = () => opts.workspaceRoot ?? getWorkspaceRoot();
1661
- const publishRoot = opts.workspaceRoot;
1662
- if (file === void 0) return {
1663
- capabilities: {
1664
- writable: false,
1665
- nativeQuery: false,
1666
- nativePaging: false
1667
- },
1668
- list: () => Promise.resolve([]),
1669
- page: () => Promise.resolve({
1670
- items: [],
1671
- total: 0,
1672
- truncated: false
1673
- }),
1674
- read: () => Promise.resolve(null)
1675
- };
1676
- return {
1677
- capabilities: {
1678
- writable: true,
1679
- nativeQuery: false,
1680
- nativePaging: true
1681
- },
1682
- list: () => sqliteList(file, root()),
1683
- page: (pageOpts = {}) => sqlitePage(file, key, pageOpts, root()),
1684
- read: (itemId) => sqliteRead(file, itemId, root()),
1685
- write: (itemId, item, writeOpts = {}) => sqliteWrite(file, itemId, item, {
1686
- workspaceRoot: root(),
1687
- publishRoot,
1688
- slug,
1689
- refuseOverwrite: writeOpts.refuseOverwrite
1690
- }),
1691
- delete: (itemId) => sqliteDelete(file, itemId, {
1692
- workspaceRoot: root(),
1693
- publishRoot,
1694
- slug
1695
- }),
1696
- watch: async (onChange) => closerFor(await watchSingleFile(file, (base, name) => name.startsWith(base), () => onChange({ kind: "collection" })))
1697
- };
1698
- }
1699
- //#endregion
1700
- //#region src/collection/server/store.ts
1701
- /** The file store's stable order: lexicographic by record id (codepoint
1702
- * compare — locale-independent). `listItems` returns readdir order, which
1703
- * is filesystem-dependent; paging needs determinism. */
1704
- function sortByRecordId(items, primaryKey) {
1705
- return [...items].sort((left, right) => {
1706
- const leftId = require_calendarGrid.fieldText(left[primaryKey]);
1707
- const rightId = require_calendarGrid.fieldText(right[primaryKey]);
1708
- if (leftId < rightId) return -1;
1709
- return leftId > rightId ? 1 : 0;
1710
- });
1711
- }
1712
- /** True when the collection accepts UI/tool writes. A `dataSource`
1713
- * collection is read-only: updates happen by editing/replacing the
1714
- * data file itself. Every write entry point checks this BEFORE calling
1715
- * `writeItem`/`deleteItem` — server-enforced, not just UI-hidden. */
1716
- function collectionWritable(collection) {
1717
- return !require_calendarGrid.isReadOnlySchema(collection.schema);
1718
- }
1719
- /** The one-line refusal write paths surface (HTTP 405 / MCP error text). */
1720
- function readOnlyRefusal(slug) {
1721
- return `collection '${slug}' is read-only (backed by an external dataSource) — update the data file itself instead`;
1722
- }
1723
- /** A `dataSource` store over `file` (CSV row order; DuckDB-native query).
1724
- * A schema whose `dataSourceFile` failed to resolve yields a read-only
1725
- * EMPTY store rather than falling back to the (writable) file store — a
1726
- * half-loaded read-only collection must never become writable. */
1727
- function csvStoreFor(collection, opts) {
1728
- const file = collection.dataSourceFile;
1729
- const key = collection.schema.primaryKey;
1730
- const listAll = () => file === void 0 ? Promise.resolve({
1731
- items: [],
1732
- truncated: false
1733
- }) : csvList(file, key, opts.workspaceRoot);
1734
- return {
1735
- capabilities: {
1736
- writable: false,
1737
- nativeQuery: true,
1738
- nativePaging: false
1739
- },
1740
- list: () => listAll().then((result) => result.items),
1741
- page: (pageOpts = {}) => listAll().then((result) => pageFromFullRead(result.items, pageOpts, key, result.truncated)),
1742
- read: (itemId) => file === void 0 ? Promise.resolve(null) : csvRead(file, key, itemId, opts.workspaceRoot),
1743
- query: (query) => file === void 0 ? Promise.resolve([]) : csvRunQuery(file, key, query, opts.workspaceRoot),
1744
- ...file === void 0 ? {} : { watch: async (onChange) => closerFor(await watchSingleFile(file, () => false, () => onChange({ kind: "collection" }))) }
1745
- };
1746
- }
1747
- /** The classic file store over `<dataDir>/<itemId>.json` records. */
1748
- function fileStoreFor(collection, opts) {
1749
- const key = collection.schema.primaryKey;
1750
- const ioOpts = {
1751
- ...opts,
1752
- slug: opts.slug ?? collection.slug
1753
- };
1754
- return {
1755
- capabilities: {
1756
- writable: true,
1757
- nativeQuery: false,
1758
- nativePaging: false
1759
- },
1760
- list: () => listItems(collection.dataDir, opts),
1761
- page: async (pageOpts = {}) => pageFromFullRead(sortByRecordId(await listItems(collection.dataDir, opts), key), pageOpts, key, false),
1762
- read: (itemId) => readItem(collection.dataDir, itemId, opts),
1763
- write: (itemId, item, writeOpts = {}) => writeItem(collection.dataDir, itemId, item, {
1764
- ...ioOpts,
1765
- refuseOverwrite: writeOpts.refuseOverwrite
1766
- }),
1767
- delete: (itemId) => deleteItem(collection.dataDir, itemId, ioOpts),
1768
- watch: async (onChange) => closerFor(await watchDirectory(collection.dataDir, (name) => name.endsWith(".json") && !name.startsWith("."), (filename) => onChange(filename === null ? { kind: "collection" } : {
1769
- kind: "item",
1770
- itemId: filename.slice(0, -5)
1771
- })))
1772
- };
1773
- }
1774
- var storeFactories = /* @__PURE__ */ new Map([
1775
- ["file", fileStoreFor],
1776
- ["csv", csvStoreFor],
1777
- ["sqlite", sqliteStoreFor],
1778
- ["firestore", firestoreStoreFor]
1779
- ]);
1780
- /** Pick the store implementation for a discovered collection via the
1781
- * factory registry. An unknown kind cannot normally reach here (the
1782
- * schema's `StorageZ` union gates it), so the throw is a loud invariant
1783
- * breach, not a user-facing path. */
1784
- function storeFor(collection, opts = {}) {
1785
- const kind = require_calendarGrid.storageKindFor(collection.schema);
1786
- const factory = storeFactories.get(kind);
1787
- if (!factory) throw new Error(`no store factory registered for storage kind '${kind}'`);
1788
- return factory(collection, opts);
1789
- }
1790
- /** The param name a `set` value references, or null when the value is a
1791
- * literal (non-strings can never be references). A bare/empty prefix
1792
- * (`"$params."`) returns the empty string — the schema refine rejects
1793
- * it as an undeclared param, never silently treats it as a literal. */
1794
- function paramRefName(value) {
1795
- if (typeof value !== "string" || !value.startsWith("$params.")) return null;
1796
- return value.slice(8);
1797
- }
1798
- /** Resolve a mutate action's `set` map against the submitted params:
1799
- * literals pass through, `$params.<name>` reads the param value. An
1800
- * ABSENT referenced param omits the key entirely (merge semantics —
1801
- * the stored value survives), mirroring how the record form omits
1802
- * empty optionals rather than writing empty strings. */
1803
- function resolveMutateSet(set, params) {
1804
- const resolved = {};
1805
- for (const [key, value] of Object.entries(set)) {
1806
- const ref = paramRefName(value);
1807
- if (ref === null) {
1808
- resolved[key] = value;
1809
- continue;
1810
- }
1811
- const paramValue = params[ref];
1812
- if (paramValue !== void 0 && paramValue !== null && paramValue !== "") resolved[key] = paramValue;
1813
- }
1814
- return resolved;
1815
- }
1816
- //#endregion
1817
- //#region src/collection/core/schemaRules.ts
1818
- var declaredField = (fields, name) => Object.hasOwn(fields, name) ? fields[name] : void 0;
1819
- var isDateLike = (type) => type === "date" || type === "datetime";
1820
- var isTimeStringField = (type) => type === "string" || type === "text";
1821
- var CODE_FIELD_TYPES = /* @__PURE__ */ new Set([
1822
- "string",
1823
- "text",
1824
- "enum"
1825
- ]);
1826
- var namesStoredField = (fields, name, primaryKey) => {
1827
- const target = declaredField(fields, name);
1828
- return target !== void 0 && !require_calendarGrid.COMPUTED_TYPES.has(target.type) && name !== primaryKey;
1829
- };
1830
- var hasUniqueIds = (entries) => entries === void 0 || new Set(entries.map((entry) => entry.id)).size === entries.length;
1831
- /** Exactly one storage declaration: native records need `dataPath`, an external
1832
- * data file needs `dataSource`, an alternative backend needs `storage`. Zero
1833
- * (nowhere to read) and several (ambiguous which wins) are equally
1834
- * meaningless — fail loudly at load instead of picking silently. */
1835
- function declaresExactlyOneStore(schema) {
1836
- return [
1837
- schema.dataPath,
1838
- schema.dataSource,
1839
- schema.storage
1840
- ].filter((declared) => declared !== void 0).length === 1;
1841
- }
1842
- /** A `dataSource` collection is read-only by definition, so schema-level write
1843
- * machinery can never fire: `singleton` pins CREATES, `ingest` REFILLS
1844
- * records, `spawn` WRITES successor records. Rejecting them at validation
1845
- * kills whole classes of writes before any runtime guard. */
1846
- function dataSourceDeclaresNoWriteMachinery(schema) {
1847
- if (schema.dataSource === void 0) return true;
1848
- return schema.singleton === void 0 && schema.ingest === void 0 && schema.spawn === void 0 && schema.googleCalendar === void 0;
1849
- }
1850
- /** Same rule for declarative host writes: a mutate action writes the record
1851
- * it's invoked on, which a read-only collection has no business doing. */
1852
- function dataSourceDeclaresNoMutateAction(schema) {
1853
- if (schema.dataSource !== void 0) return [...schema.actions ?? [], ...schema.collectionActions ?? []].every((action) => action.kind !== "mutate");
1854
- return true;
1855
- }
1856
- /** Action ids must be unique so the dispatch route resolves unambiguously. */
1857
- function actionIdsAreUnique(schema) {
1858
- return hasUniqueIds(schema.actions);
1859
- }
1860
- /** Collection-level action ids must likewise be unique. */
1861
- function collectionActionIdsAreUnique(schema) {
1862
- return hasUniqueIds(schema.collectionActions);
1863
- }
1864
- /** A mutate action's `set` writes real STORED fields: a typo'd key would write
1865
- * a stray value forever, a computed/projected field is never persisted, and
1866
- * the primaryKey is the filename (renaming is not a mutation). */
1867
- function mutateSetKeysNameStoredFields(schema) {
1868
- return (schema.actions ?? []).every((action) => action.kind !== "mutate" || Object.keys(action.set).every((key) => namesStoredField(schema.fields, key, schema.primaryKey)));
1869
- }
1870
- /** Every `$params.<name>` reference in `set` must name a declared param — an
1871
- * undeclared one would silently no-op the assignment. */
1872
- function mutateParamRefsAreDeclared(schema) {
1873
- return (schema.actions ?? []).every((action) => action.kind !== "mutate" || Object.values(action.set).every((value) => {
1874
- const ref = paramRefName(value);
1875
- return ref === null || (action.params ?? {})[ref] !== void 0;
1876
- }));
1877
- }
1878
- /** A collection-level action has no record to write. */
1879
- function collectionActionsAreNotMutate(schema) {
1880
- return (schema.collectionActions ?? []).every((action) => action.kind !== "mutate");
1881
- }
1882
- /** The singleton value becomes a record id (and thus a `<id>.json` filename),
1883
- * so it must satisfy the SAME record-id rule the write path enforces —
1884
- * otherwise the create form would lock the primary key to a value the POST
1885
- * route then rejects, making the collection impossible to initialize. */
1886
- function singletonIsAValidRecordId(schema) {
1887
- return schema.singleton === void 0 || require_calendarGrid.isSafeRecordId(schema.singleton);
1888
- }
1889
- function collectCurrencyFieldRefs(fields) {
1890
- const refs = [];
1891
- for (const field of Object.values(fields)) {
1892
- if (typeof field.currencyField === "string" && field.currencyField.length > 0) refs.push(field.currencyField);
1893
- for (const sub of Object.values(field.of ?? {})) if (typeof sub.currencyField === "string" && sub.currencyField.length > 0) refs.push(sub.currencyField);
1894
- }
1895
- return refs;
1896
- }
1897
- /** A `currencyField` pointer must name a real top-level field that holds a code
1898
- * string — a typo (`curreny`) would otherwise pass the per-field check, then
1899
- * silently fall back to the literal / USD at render and mislabel amounts. */
1900
- function currencyFieldRefsNameCodeFields(schema) {
1901
- return collectCurrencyFieldRefs(schema.fields).every((name) => CODE_FIELD_TYPES.has(declaredField(schema.fields, name)?.type ?? ""));
1902
- }
1903
- /** The pair must be declared together — one without the other is meaningless:
1904
- * the host would either never fire (no done values to compare against) or
1905
- * never clear (no field to read).
1906
- *
1907
- * EXCEPTION: when `completionField` names a `flag` field, done ⇔ the flag's
1908
- * `where` matches, so `completionDoneValues` carries no information and MUST
1909
- * be omitted (declaring it would invite a contradictory second source of
1910
- * truth). */
1911
- function completionPairIsCoherent(schema) {
1912
- if (schema.completionField !== void 0 && declaredField(schema.fields, schema.completionField)?.type === "flag") return schema.completionDoneValues === void 0;
1913
- return schema.completionField === void 0 === (schema.completionDoneValues === void 0);
1914
- }
1915
- /** `completionField` must name a real top-level field — a typo would silently
1916
- * disable the notification mechanism otherwise. */
1917
- function completionFieldIsDeclared(schema) {
1918
- return schema.completionField === void 0 || declaredField(schema.fields, schema.completionField) !== void 0;
1919
- }
1920
- /** A flag named by `completionField` is evaluated against the RAW record — the
1921
- * reconciler (and spawn's fallback) read items straight off disk, BEFORE any
1922
- * `deriveAll` enrichment — so its `where` may only reference STORED fields. A
1923
- * condition over a computed sibling would see an absent key: `ne` matches
1924
- * vacuously, every other op reads false, and the bell would clear wrongly /
1925
- * never. General (non-completion) flags keep the full vocabulary — the UI
1926
- * evaluates them post-enrichment. */
1927
- function completionFlagReadsOnlyStoredFields(schema) {
1928
- const spec = schema.completionField === void 0 ? void 0 : declaredField(schema.fields, schema.completionField);
1929
- if (spec?.type !== "flag") return true;
1930
- return spec.where.every((cond) => [cond.field, ...cond.valueFrom ? [cond.valueFrom.field] : []].every((name) => {
1931
- const target = declaredField(schema.fields, name);
1932
- return target !== void 0 && !require_calendarGrid.COMPUTED_TYPES.has(target.type);
1933
- }));
1934
- }
1935
- /** `displayField`, like `completionField`, must name a real top-level field —
1936
- * a typo would silently fall back to the primaryKey forever. */
1937
- function displayFieldIsDeclared(schema) {
1938
- return schema.displayField === void 0 || declaredField(schema.fields, schema.displayField) !== void 0;
1939
- }
1940
- /** A field's `when.field` gates its visibility against a sibling's value, so it
1941
- * must name a real top-level field — a typo would silently keep the field
1942
- * hidden forever (the gate never matches). */
1943
- function fieldVisibilityGatesNameDeclaredFields(schema) {
1944
- return Object.values(schema.fields).every((field) => field.when === void 0 || declaredField(schema.fields, field.when.field) !== void 0);
1945
- }
1946
- /** A flag's `where` reads sibling fields (both `cond.field` and a same-record
1947
- * `valueFrom.field`), so each must name a real top-level field — a typo would
1948
- * silently pin the flag false forever (`ne`: true forever). */
1949
- function flagConditionsNameDeclaredFields(schema) {
1950
- return Object.values(schema.fields).every((field) => field.type !== "flag" || field.where.every((cond) => declaredField(schema.fields, cond.field) !== void 0 && (cond.valueFrom === void 0 || declaredField(schema.fields, cond.valueFrom.field) !== void 0)));
1951
- }
1952
- /** An `embed`'s `idField` resolves the target record id from a sibling's value,
1953
- * so it must name a real top-level field — and one whose stored value is a
1954
- * plain id string. Only `ref` / `string` qualify: the editor writes the picked
1955
- * id into that field, so a non-persisted or composite type would either not
1956
- * round-trip on save or hold no usable id. */
1957
- function embedIdFieldsNameIdBearingFields(schema) {
1958
- return Object.values(schema.fields).every((field) => {
1959
- if (field.type !== "embed" || field.idField === void 0) return true;
1960
- const target = declaredField(schema.fields, field.idField);
1961
- return target !== void 0 && (target.type === "ref" || target.type === "string");
1962
- });
1963
- }
1964
- /** The sync writes each mapped value into a declared field, and puts the Google
1965
- * event id in the primary field — so a map key that names no field (or names
1966
- * the primary) would silently drop data or fight the id. */
1967
- function googleCalendarMapNamesStoredFields(schema) {
1968
- if (schema.googleCalendar === void 0) return true;
1969
- return Object.keys(schema.googleCalendar.map).every((key) => namesStoredField(schema.fields, key, schema.primaryKey));
1970
- }
1971
- /** A `toggle` field projects an `enum` field: its `field` must name a real
1972
- * top-level enum, and `onValue` / `offValue` must be members of that enum's
1973
- * `values` — otherwise toggling would write a value outside the closed set
1974
- * (and never appear "checked"). */
1975
- function togglesProjectValidEnums(schema) {
1976
- const { fields } = schema;
1977
- for (const spec of Object.values(fields)) {
1978
- if (spec.type !== "toggle") continue;
1979
- const target = declaredField(fields, spec.field);
1980
- if (!target || target.type !== "enum") return false;
1981
- const allowed = new Set(target.values);
1982
- if (!allowed.has(spec.onValue) || !allowed.has(spec.offValue)) return false;
1983
- }
1984
- return true;
1985
- }
1986
- /** `triggerField` requires the completion pair: the time gate only suppresses
1987
- * the *completion* bell until the date, and the bell still clears via
1988
- * `completionDoneValues`. Without completion there is no bell to gate. */
1989
- function triggerFieldRequiresCompletion(schema) {
1990
- return schema.triggerField === void 0 || schema.completionField !== void 0;
1991
- }
1992
- /** `triggerField` must name a real `date` field — the gate parses its value as
1993
- * `YYYY-MM-DD`; any other type can't be compared to the clock. */
1994
- function triggerFieldIsADateField(schema) {
1995
- return schema.triggerField === void 0 || declaredField(schema.fields, schema.triggerField)?.type === "date";
1996
- }
1997
- /** `triggerLeadDays` only means something relative to a trigger date. */
1998
- function triggerLeadDaysRequiresTriggerField(schema) {
1999
- return schema.triggerLeadDays === void 0 || schema.triggerField !== void 0;
2000
- }
2001
- /** `spawn` advances `triggerField` to compute the successor's trigger date, so
2002
- * the schema must declare one. */
2003
- function spawnRequiresTriggerField(schema) {
2004
- return schema.spawn === void 0 || schema.triggerField !== void 0;
2005
- }
2006
- /** `spawn.when.field` must name a real top-level field — a typo would silently
2007
- * never match. */
2008
- function spawnWhenFieldIsDeclared(schema) {
2009
- return schema.spawn?.when === void 0 || declaredField(schema.fields, schema.spawn.when.field) !== void 0;
2010
- }
2011
- /** Every `spawn.carry` entry must name a real top-level field — a typo would
2012
- * silently never copy. */
2013
- function spawnCarryEntriesAreDeclared(schema) {
2014
- return (schema.spawn?.carry ?? []).every((name) => declaredField(schema.fields, name) !== void 0);
2015
- }
2016
- /** A successor must NOT be born already matching its own spawn predicate — it
2017
- * would re-spawn on its first reconcile, fanning out into an unbounded chain
2018
- * of records. The predicate field/values are `spawn.when` when given, else the
2019
- * completion-done pair. The successor's value for that field is `set[field]`
2020
- * if set, else the carried source value (which matched, by definition, when
2021
- * the spawn fired) if carried, else absent (safe). */
2022
- function spawnSuccessorStartsInert(schema) {
2023
- const { spawn } = schema;
2024
- if (!spawn) return true;
2025
- const field = spawn.when?.field ?? schema.completionField;
2026
- const values = spawn.when?.in ?? schema.completionDoneValues;
2027
- if (!field || !values) return true;
2028
- if (spawn.set && Object.prototype.hasOwnProperty.call(spawn.set, field)) return !values.includes(String(spawn.set[field]));
2029
- return !(spawn.carry ?? []).includes(field);
2030
- }
2031
- /** `spawnSuccessorStartsInert` cannot see through a flag's `where` (the
2032
- * predicate would need full record evaluation against `set`/`carry`). So a
2033
- * schema whose completion is flag-form may only spawn with an explicit
2034
- * `spawn.when` — which that check CAN evaluate. */
2035
- function flagCompletionSpawnDeclaresWhen(schema) {
2036
- return schema.spawn === void 0 || schema.spawn.when !== void 0 || declaredField(schema.fields, schema.completionField ?? "")?.type !== "flag";
2037
- }
2038
- function fieldDrivenSpawnEvery(schema) {
2039
- const every = schema.spawn?.every;
2040
- if (!every || !("fromField" in every)) return null;
2041
- return every;
2042
- }
2043
- /** §4.1 — `fromField` must name a real top-level `enum` field. The `map` keys
2044
- * are only meaningful against a closed value set, and the field renders as a
2045
- * form `<select>`; a non-enum target has no finite values to validate. */
2046
- function fieldDrivenFromFieldIsEnum(schema) {
2047
- const driven = fieldDrivenSpawnEvery(schema);
2048
- if (!driven) return true;
2049
- return declaredField(schema.fields, driven.fromField)?.type === "enum";
2050
- }
2051
- /** §4.2 — `map` keys must EXACTLY cover the enum's `values` (no missing keys —
2052
- * a record could pick an unmapped frequency and silently stall; no extra keys
2053
- * — a stale map outliving an enum edit). */
2054
- function fieldDrivenMapCoversValues(schema) {
2055
- const driven = fieldDrivenSpawnEvery(schema);
2056
- if (!driven) return true;
2057
- const target = declaredField(schema.fields, driven.fromField);
2058
- if (target?.type !== "enum") return true;
2059
- const values = new Set(target.values);
2060
- const keys = Object.keys(driven.map);
2061
- return keys.length === values.size && keys.every((key) => values.has(key));
2062
- }
2063
- /** §4.5 — `fromField` must reach the successor (via `carry` or `set`);
2064
- * otherwise the successor loses its frequency and the NEXT spawn along the
2065
- * chain can't resolve an interval, silently halting the recurrence.
2066
- *
2067
- * `set` writes a FIXED value, so it must itself be a key of `map` (else the
2068
- * successor is born with an unresolvable driver and `resolveEvery` skips it —
2069
- * the exact silent-halt §4.5 exists to prevent). `carry` copies the source's
2070
- * own value, which — for a record that matched the spawn — is one of the
2071
- * enum's values, all of which `map` covers by §4.2; so a carried driver is
2072
- * always resolvable and needs no value check here. */
2073
- function fieldDrivenFromFieldCarried(schema) {
2074
- const driven = fieldDrivenSpawnEvery(schema);
2075
- if (!driven) return true;
2076
- const { carry, set } = schema.spawn ?? {};
2077
- if (set && Object.prototype.hasOwnProperty.call(set, driven.fromField)) {
2078
- const raw = set[driven.fromField];
2079
- if (raw === void 0 || raw === null || raw === "") return false;
2080
- const key = require_calendarGrid.fieldTextOrNull(raw);
2081
- return key !== null && Object.prototype.hasOwnProperty.call(driven.map, key);
2082
- }
2083
- return (carry ?? []).includes(driven.fromField);
2084
- }
2085
- /** `calendarField` must name a real `date`/`datetime` field — the calendar view
2086
- * parses its value to place records on the month grid (a `datetime` anchor
2087
- * also carries the clock for the day view). */
2088
- function calendarFieldIsDateLike(schema) {
2089
- return schema.calendarField === void 0 || isDateLike(declaredField(schema.fields, schema.calendarField)?.type);
2090
- }
2091
- /** `calendarEndField` marks the end of a multi-day span, so it only means
2092
- * something alongside a start anchor. */
2093
- function calendarEndFieldRequiresCalendarField(schema) {
2094
- return schema.calendarEndField === void 0 || schema.calendarField !== void 0;
2095
- }
2096
- /** `calendarEndField` must also name a real `date`/`datetime` field — same parse. */
2097
- function calendarEndFieldIsDateLike(schema) {
2098
- return schema.calendarEndField === void 0 || isDateLike(declaredField(schema.fields, schema.calendarEndField)?.type);
2099
- }
2100
- /** `calendarTimeField` places records on the day view, so it only means
2101
- * something alongside a start anchor. */
2102
- function calendarTimeFieldRequiresCalendarField(schema) {
2103
- return schema.calendarTimeField === void 0 || schema.calendarField !== void 0;
2104
- }
2105
- /** `calendarTimeField` must name a real top-level field (a free-form time
2106
- * string the day view parses). */
2107
- function calendarTimeFieldIsDeclared(schema) {
2108
- return schema.calendarTimeField === void 0 || declaredField(schema.fields, schema.calendarTimeField) !== void 0;
2109
- }
2110
- /** …and that field must be string-backed — the day view parses its value as a
2111
- * time string, so a number/enum/date column can't drive it. */
2112
- function calendarTimeFieldIsStringBacked(schema) {
2113
- return schema.calendarTimeField === void 0 || isTimeStringField(declaredField(schema.fields, schema.calendarTimeField)?.type);
2114
- }
2115
- /** `kanbanField` must name a real `enum` field — the board groups records into
2116
- * one column per declared enum value; any other type has no closed set of
2117
- * columns to group by. */
2118
- function kanbanFieldIsAnEnum(schema) {
2119
- return schema.kanbanField === void 0 || declaredField(schema.fields, schema.kanbanField)?.type === "enum";
2120
- }
2121
- /** `notifyWhen` narrows the completion bell, so it only means something with
2122
- * completion tracking. */
2123
- function notifyWhenRequiresCompletion(schema) {
2124
- return schema.notifyWhen === void 0 || schema.completionField !== void 0;
2125
- }
2126
- /** `notifyWhen.field` must name a real top-level field. */
2127
- function notifyWhenFieldIsDeclared(schema) {
2128
- return schema.notifyWhen === void 0 || declaredField(schema.fields, schema.notifyWhen.field) !== void 0;
2129
- }
2130
- /** Every custom view `id` must be a valid slug — it doubles as the view-mode
2131
- * selector key (`custom:<id>`) and the capability-token clamp key, both of
2132
- * which expect a path-safe token. */
2133
- function viewIdsAreSlugs(schema) {
2134
- return schema.views === void 0 || schema.views.every((view) => require_calendarGrid.isSafeSlug(view.id));
2135
- }
2136
- /** Custom view ids must be unique so the selector + token clamp resolve
2137
- * unambiguously. */
2138
- function viewIdsAreUnique(schema) {
2139
- return hasUniqueIds(schema.views);
2140
- }
2141
- //#endregion
2142
- //#region src/collection/core/schemaZ.ts
2143
- /** Optional visibility predicate shared by actions and fields: the target
2144
- * shows only when the open record's `field` (stringified) is one of `in`.
2145
- * Domain-free — `field` is any non-empty key, `in` a non-empty array of
2146
- * non-empty values; the host never interprets the meaning.
964
+ //#region src/collection/core/schemaZ.ts
965
+ /** Optional visibility predicate shared by actions and fields: the target
966
+ * shows only when the open record's `field` (stringified) is one of `in`.
967
+ * Domain-free — `field` is any non-empty key, `in` a non-empty array of
968
+ * non-empty values; the host never interprets the meaning.
2147
969
  *
2148
970
  * `trim().min(1)` rather than bare `min(1)` so a whitespace-only string
2149
971
  * (" ") fails validation — otherwise the cell formatter / dropdown would
@@ -2628,510 +1450,1688 @@ var DataSourceZ = zod.z.object({
2628
1450
  type: zod.z.literal("csv"),
2629
1451
  path: zod.z.string().min(1)
2630
1452
  });
2631
- /** Alternative WRITABLE storage backend for a collection's records —
2632
- * unlike `dataSource` (external read-only file), a `storage` collection
2633
- * behaves like a normal writable collection; only where the rows live
2634
- * changes. The store factory registry (`server/store.ts`) picks the
2635
- * implementation by `type` (plans/done/refactor-storage-virtualization.md).
1453
+ /** Alternative WRITABLE storage backend for a collection's records —
1454
+ * unlike `dataSource` (external read-only file), a `storage` collection
1455
+ * behaves like a normal writable collection; only where the rows live
1456
+ * changes. The store factory registry (`server/store.ts`) picks the
1457
+ * implementation by `type` (plans/done/refactor-storage-virtualization.md).
1458
+ *
1459
+ * A discriminated union rather than one shape with optional keys, because
1460
+ * only the sqlite variant is a workspace FILE: its `path` is
1461
+ * workspace-relative and containment-checked exactly like `dataPath`, while
1462
+ * the firestore variant has no path to check — its records are not on this
1463
+ * machine at all. Optional keys would let each arm accept the other's, and
1464
+ * the compiler would stop being the thing that tells you which. */
1465
+ var StorageZ = zod.z.discriminatedUnion("type", [zod.z.object({
1466
+ type: zod.z.literal("sqlite"),
1467
+ path: zod.z.string().min(1)
1468
+ }), zod.z.object({ type: zod.z.literal("firestore") }).strict()]);
1469
+ var BareCollectionSchemaZ = zod.z.object({
1470
+ title: zod.z.string().min(1),
1471
+ icon: zod.z.string().min(1),
1472
+ dataPath: zod.z.string().min(1).optional(),
1473
+ dataSource: DataSourceZ.optional(),
1474
+ storage: StorageZ.optional(),
1475
+ primaryKey: zod.z.string().min(1),
1476
+ singleton: zod.z.string().trim().min(1).optional(),
1477
+ fields: zod.z.record(zod.z.string(), FieldSpecZ),
1478
+ actions: zod.z.array(ActionSpecZ).optional(),
1479
+ collectionActions: zod.z.array(ActionSpecZ).optional(),
1480
+ completionField: zod.z.string().trim().min(1).optional(),
1481
+ completionDoneValues: zod.z.array(zod.z.string().trim().min(1)).min(1).optional(),
1482
+ displayField: zod.z.string().trim().min(1).optional(),
1483
+ triggerField: zod.z.string().trim().min(1).optional(),
1484
+ triggerLeadDays: zod.z.number().int().min(0).optional(),
1485
+ spawn: SpawnZ.optional(),
1486
+ calendarField: zod.z.string().trim().min(1).optional(),
1487
+ calendarEndField: zod.z.string().trim().min(1).optional(),
1488
+ calendarTimeField: zod.z.string().trim().min(1).optional(),
1489
+ kanbanField: zod.z.string().trim().min(1).optional(),
1490
+ views: zod.z.array(CustomViewZ).optional(),
1491
+ notifyWhen: WhenZ.optional(),
1492
+ ingest: IngestZ.optional(),
1493
+ googleCalendar: GoogleCalendarSyncZ.optional(),
1494
+ dynamicIcon: DynamicIconSpecZ.optional()
1495
+ }).refine(declaresExactlyOneStore, {
1496
+ message: "declare exactly one of `dataPath` (native JSON records), `dataSource` (external read-only data file), or `storage` (alternative writable backend)",
1497
+ path: ["dataPath"]
1498
+ }).refine(dataSourceDeclaresNoWriteMachinery, {
1499
+ message: "a `dataSource` collection is read-only — it cannot declare `singleton`, `ingest`, `spawn`, or `googleCalendar` (all of them write records)",
1500
+ path: ["dataSource"]
1501
+ }).refine(googleCalendarMapNamesStoredFields, {
1502
+ message: "a `googleCalendar` map key must name a declared, non-computed field, and never the primaryKey (that always holds the Google event id)",
1503
+ path: ["googleCalendar"]
1504
+ }).refine(dataSourceDeclaresNoMutateAction, {
1505
+ message: "a `dataSource` collection is read-only — its actions cannot use `kind: \"mutate\"` (a host write); use `chat`/`agent` actions instead",
1506
+ path: ["dataSource"]
1507
+ }).refine(singletonIsAValidRecordId, {
1508
+ message: "schema `singleton` must be a valid item id (alphanumeric / hyphen / underscore / interior dot, no `..` or path separators)",
1509
+ path: ["singleton"]
1510
+ }).refine(actionIdsAreUnique, {
1511
+ message: "schema `actions` must have unique `id`s",
1512
+ path: ["actions"]
1513
+ }).refine(collectionActionIdsAreUnique, {
1514
+ message: "schema `collectionActions` must have unique `id`s",
1515
+ path: ["collectionActions"]
1516
+ }).refine(mutateSetKeysNameStoredFields, {
1517
+ message: "a mutate action's `set` keys must name declared, non-computed fields (and never the primaryKey)",
1518
+ path: ["actions"]
1519
+ }).refine(mutateParamRefsAreDeclared, {
1520
+ message: "a mutate action's `$params.<name>` references must name keys declared in its `params`",
1521
+ path: ["actions"]
1522
+ }).refine(collectionActionsAreNotMutate, {
1523
+ message: "`collectionActions` cannot contain `kind: \"mutate\"` — a collection-level action has no record to write",
1524
+ path: ["collectionActions"]
1525
+ }).refine(currencyFieldRefsNameCodeFields, {
1526
+ message: "a money field's `currencyField` must name a top-level `string`, `text`, or `enum` field that holds the currency code",
1527
+ path: ["fields"]
1528
+ }).refine(completionPairIsCoherent, {
1529
+ message: "schema `completionField` and `completionDoneValues` must be declared together (both set, or both omitted) — unless `completionField` names a `flag` field, in which case `completionDoneValues` must be omitted (done ⇔ the flag matches)",
1530
+ path: ["completionField"]
1531
+ }).refine(completionFieldIsDeclared, {
1532
+ message: "schema `completionField` must name a top-level field declared in `fields`",
1533
+ path: ["completionField"]
1534
+ }).refine(displayFieldIsDeclared, {
1535
+ message: "schema `displayField` must name a top-level field declared in `fields`",
1536
+ path: ["displayField"]
1537
+ }).refine(fieldVisibilityGatesNameDeclaredFields, {
1538
+ message: "a field's `when.field` must name a top-level field declared in `fields`",
1539
+ path: ["fields"]
1540
+ }).refine(flagConditionsNameDeclaredFields, {
1541
+ message: "a flag field's `where` conditions must name top-level fields declared in `fields` (both `field` and a same-record `valueFrom.field`)",
1542
+ path: ["fields"]
1543
+ }).refine(completionFlagReadsOnlyStoredFields, {
1544
+ message: "a `flag` named by `completionField` may only reference STORED fields in its `where` — completion is evaluated against the raw record (before deriveAll), where computed values (derived/rollup/toggle/flag/embed/backlinks) are absent",
1545
+ path: ["completionField"]
1546
+ }).refine(flagCompletionSpawnDeclaresWhen, {
1547
+ message: "a schema whose `completionField` names a `flag` field must declare an explicit `spawn.when` (the spawn-inert check cannot statically evaluate a flag's `where`)",
1548
+ path: ["spawn"]
1549
+ }).refine(embedIdFieldsNameIdBearingFields, {
1550
+ message: "an embed field's `idField` must name a top-level `ref` or `string` field declared in `fields`",
1551
+ path: ["fields"]
1552
+ }).refine(triggerFieldRequiresCompletion, {
1553
+ message: "schema `triggerField` requires `completionField` / `completionDoneValues` (the gated bell still clears via the done value)",
1554
+ path: ["triggerField"]
1555
+ }).refine(triggerFieldIsADateField, {
1556
+ message: "schema `triggerField` must name a top-level `date` field declared in `fields`",
1557
+ path: ["triggerField"]
1558
+ }).refine(triggerLeadDaysRequiresTriggerField, {
1559
+ message: "schema `triggerLeadDays` requires `triggerField` (it shifts when that field's bell fires)",
1560
+ path: ["triggerLeadDays"]
1561
+ }).refine(spawnRequiresTriggerField, {
1562
+ message: "schema `spawn` requires `triggerField` (the successor's trigger date is `triggerField` advanced by `spawn.every`)",
1563
+ path: ["spawn"]
1564
+ }).refine(spawnWhenFieldIsDeclared, {
1565
+ message: "schema `spawn.when.field` must name a top-level field declared in `fields`",
1566
+ path: ["spawn"]
1567
+ }).refine(spawnCarryEntriesAreDeclared, {
1568
+ message: "every `spawn.carry` entry must name a top-level field declared in `fields`",
1569
+ path: ["spawn"]
1570
+ }).refine(spawnSuccessorStartsInert, {
1571
+ message: "`spawn` must leave the successor in a non-matching state (e.g. `set` the status to a pending value); seeding the predicate field to a matching value via `set`/`carry` would respawn forever",
1572
+ path: ["spawn"]
1573
+ }).refine(fieldDrivenFromFieldIsEnum, {
1574
+ message: "`spawn.every.fromField` must name a top-level `enum` field declared in `fields`",
1575
+ path: ["spawn"]
1576
+ }).refine(fieldDrivenMapCoversValues, {
1577
+ message: "`spawn.every.map` keys must exactly cover the `values` of the `enum` named by `fromField` (no missing or extra keys)",
1578
+ path: ["spawn"]
1579
+ }).refine(fieldDrivenFromFieldCarried, {
1580
+ message: "`spawn.every.fromField` must appear in `spawn.carry`, or be written by `spawn.set` to a value present in `spawn.every.map`, so the successor keeps a resolvable recurrence interval",
1581
+ path: ["spawn"]
1582
+ }).refine(calendarFieldIsDateLike, {
1583
+ message: "schema `calendarField` must name a top-level `date` or `datetime` field declared in `fields`",
1584
+ path: ["calendarField"]
1585
+ }).refine(calendarEndFieldRequiresCalendarField, {
1586
+ message: "schema `calendarEndField` requires `calendarField` (it marks the end of the span that starts at `calendarField`)",
1587
+ path: ["calendarEndField"]
1588
+ }).refine(calendarEndFieldIsDateLike, {
1589
+ message: "schema `calendarEndField` must name a top-level `date` or `datetime` field declared in `fields`",
1590
+ path: ["calendarEndField"]
1591
+ }).refine(calendarTimeFieldRequiresCalendarField, {
1592
+ message: "schema `calendarTimeField` requires `calendarField` (it supplies the time-of-day for the calendar's day view)",
1593
+ path: ["calendarTimeField"]
1594
+ }).refine(calendarTimeFieldIsDeclared, {
1595
+ message: "schema `calendarTimeField` must name a top-level field declared in `fields`",
1596
+ path: ["calendarTimeField"]
1597
+ }).refine(calendarTimeFieldIsStringBacked, {
1598
+ message: "schema `calendarTimeField` must name a top-level `string` or `text` field declared in `fields`",
1599
+ path: ["calendarTimeField"]
1600
+ }).refine(kanbanFieldIsAnEnum, {
1601
+ message: "schema `kanbanField` must name a top-level `enum` field declared in `fields`",
1602
+ path: ["kanbanField"]
1603
+ }).refine(togglesProjectValidEnums, {
1604
+ message: "a `toggle` field's `field` must name a top-level `enum` field, and its `onValue`/`offValue` must be values of that enum",
1605
+ path: ["fields"]
1606
+ }).refine(notifyWhenRequiresCompletion, {
1607
+ message: "schema `notifyWhen` requires `completionField` (it narrows that bell)",
1608
+ path: ["notifyWhen"]
1609
+ }).refine(notifyWhenFieldIsDeclared, {
1610
+ message: "schema `notifyWhen.field` must name a top-level field declared in `fields`",
1611
+ path: ["notifyWhen"]
1612
+ }).refine(viewIdsAreSlugs, {
1613
+ message: "every `views[].id` must be a valid slug (alphanumeric / hyphen / underscore, no path separators)",
1614
+ path: ["views"]
1615
+ }).refine(viewIdsAreUnique, {
1616
+ message: "schema `views` must have unique `id`s",
1617
+ path: ["views"]
1618
+ });
1619
+ var PROTOTYPE_KEYS = [
1620
+ "__proto__",
1621
+ "constructor",
1622
+ "prototype"
1623
+ ];
1624
+ /** The first own prototype-sensitive key of `value`, or null. */
1625
+ function ownPrototypeKey(value) {
1626
+ if (value === null || typeof value !== "object") return null;
1627
+ for (const key of PROTOTYPE_KEYS) if (Object.hasOwn(value, key)) return key;
1628
+ return null;
1629
+ }
1630
+ /** Own enumerable entries of an object (arrays keyed by index), none for
1631
+ * anything else — the raw input is unvalidated, so `fields` may be junk. */
1632
+ function ownEntries(value) {
1633
+ if (require_dist.isUnknownArray(value)) return value.map((entry, index) => [String(index), entry]);
1634
+ return require_dist.isRecord(value) ? Object.entries(value) : [];
1635
+ }
1636
+ /** The name-defining sub-record a raw field spec (`of`) or action (`params`)
1637
+ * carries, or undefined when the holder isn't an object at all. */
1638
+ function nameDefiningSubRecord(holder, key) {
1639
+ return require_dist.isRecord(holder) ? holder[key] : void 0;
1640
+ }
1641
+ /** Dotted path of the first prototype-sensitive `params` name across both
1642
+ * action lists, or null. */
1643
+ function prototypeActionParamPath(input) {
1644
+ for (const [listName, list] of [["actions", input.actions], ["collectionActions", input.collectionActions]]) for (const action of require_dist.isUnknownArray(list) ? list : []) {
1645
+ const badParam = ownPrototypeKey(nameDefiningSubRecord(action, "params"));
1646
+ if (badParam !== null) return `${listName}.params.${badParam}`;
1647
+ }
1648
+ return null;
1649
+ }
1650
+ /** Dotted path of the first prototype-sensitive field name in the raw
1651
+ * schema input — top-level `fields`, each table field's `of`, and each
1652
+ * action's `params` (the three records that DEFINE names) — or null. */
1653
+ function prototypeFieldKeyPath(input) {
1654
+ if (!require_dist.isRecord(input)) return null;
1655
+ const bad = ownPrototypeKey(input.fields);
1656
+ if (bad !== null) return `fields.${bad}`;
1657
+ for (const [key, spec] of ownEntries(input.fields)) {
1658
+ const badSub = ownPrototypeKey(nameDefiningSubRecord(spec, "of"));
1659
+ if (badSub !== null) return `fields.${key}.of.${badSub}`;
1660
+ }
1661
+ return prototypeActionParamPath(input);
1662
+ }
1663
+ var CollectionSchemaZ = zod.z.preprocess((input, ctx) => {
1664
+ const bad = prototypeFieldKeyPath(input);
1665
+ if (bad !== null) {
1666
+ ctx.addIssue({
1667
+ code: "custom",
1668
+ message: `'${bad}': field names must not be prototype-sensitive keys (\`__proto__\`, \`constructor\`, \`prototype\`)`
1669
+ });
1670
+ return zod.z.NEVER;
1671
+ }
1672
+ return input;
1673
+ }, BareCollectionSchemaZ);
1674
+ //#endregion
1675
+ //#region src/collection/server/discovery.ts
1676
+ function applyFeedSchemaDefaults(parsed, slug) {
1677
+ if (!require_dist.isRecord(parsed)) return parsed;
1678
+ const icon = typeof parsed.icon === "string" && parsed.icon.trim().length > 0 ? parsed.icon : "dynamic_feed";
1679
+ return {
1680
+ ...parsed,
1681
+ icon,
1682
+ dataPath: `data/feeds/${slug}`
1683
+ };
1684
+ }
1685
+ /** The conventional per-slug records dir a `dataSource` / `storage` collection
1686
+ * gets as its `dataDir` (records never live there, but archive/delete paths
1687
+ * stay well-defined — same shape the registry's R3 normalization uses).
1688
+ *
1689
+ * INVARIANT — this is NOT a default `dataPath`, and must not be used as one.
1690
+ * It applies only to the two backends whose records are not per-file JSON. A
1691
+ * normal collection declares its own location and exactly one of `dataPath` /
1692
+ * `dataSource` / `storage`; a schema with none of the three is REJECTED, not
1693
+ * quietly pointed here. Handing a per-file collection this path would silently
1694
+ * relocate its records away from the folder the user (and its SKILL.md) sees. */
1695
+ function conventionalDataPath(slug) {
1696
+ return `data/collections/${slug}/items`;
1697
+ }
1698
+ /** The declared field named by `primaryKey`, or `undefined` when the schema
1699
+ * declares no such field. Own-property guarded: a `primaryKey` of `toString`
1700
+ * / `constructor` / `__proto__` must miss here, not read an Object.prototype
1701
+ * member and slip past the "is it a declared field?" gate into the wrong
1702
+ * "add `primary: true`" advice. Shared with manageCollection's putSchema
1703
+ * gate so both report the SAME reason. */
1704
+ function resolvePrimaryField(fields, primaryKey) {
1705
+ return Object.hasOwn(fields, primaryKey) ? fields[primaryKey] : void 0;
1706
+ }
1707
+ /** The acceptance gates discovery applies AFTER `CollectionSchemaZ` parses,
1708
+ * before a schema becomes a live collection:
1709
+ *
1710
+ * - the `primaryKey` must be a declared field flagged `primary: true` —
1711
+ * without the flag CollectionView renders the field editable, and a
1712
+ * rename is silently pinned back to the URL itemId on save, so the user's
1713
+ * edit is dropped with no error;
1714
+ * - a `feed` schema must declare an `ingest` block (else it's a dead,
1715
+ * non-refreshable card);
1716
+ * - `dataPath` — or a `dataSource`'s `path` — must resolve INSIDE the
1717
+ * workspace (same realpath containment for both).
1718
+ *
1719
+ * Exported so `manageCollection`'s `putSchema` can run the SAME gates before
1720
+ * it reports success — a schema that passes `CollectionSchemaZ` but fails one
1721
+ * of these would otherwise write cleanly yet be skipped on the next discovery,
1722
+ * hiding the collection (the exact failure that tool exists to prevent). */
1723
+ function acceptParsedSchema(schema, opts) {
1724
+ const primaryField = resolvePrimaryField(schema.fields, schema.primaryKey);
1725
+ if (!primaryField) return {
1726
+ ok: false,
1727
+ reason: `primaryKey '${schema.primaryKey}' is not one of the declared fields`
1728
+ };
1729
+ if (primaryField.primary !== true) return {
1730
+ ok: false,
1731
+ reason: `the primaryKey field '${schema.primaryKey}' must be flagged \`primary: true\``
1732
+ };
1733
+ if (opts.source === "feed" && !schema.ingest) return {
1734
+ ok: false,
1735
+ reason: "a feed schema must declare an `ingest` block"
1736
+ };
1737
+ if (schema.dataSource !== void 0) {
1738
+ const dataSourceFile = resolveDataDir(schema.dataSource.path, opts.workspaceRoot);
1739
+ if (dataSourceFile === null) return {
1740
+ ok: false,
1741
+ reason: `dataSource.path '${schema.dataSource.path}' escapes the workspace`
1742
+ };
1743
+ const dataDir = resolveDataDir(conventionalDataPath(opts.slug), opts.workspaceRoot);
1744
+ if (dataDir === null) return {
1745
+ ok: false,
1746
+ reason: `slug '${opts.slug}' yields no workspace-contained data dir`
1747
+ };
1748
+ return {
1749
+ ok: true,
1750
+ dataDir,
1751
+ dataSourceFile
1752
+ };
1753
+ }
1754
+ if (schema.storage !== void 0) return acceptStorageSchema(schema.storage, opts);
1755
+ const dataDir = resolveDataDir(schema.dataPath ?? "", opts.workspaceRoot);
1756
+ if (dataDir === null) return {
1757
+ ok: false,
1758
+ reason: `dataPath '${schema.dataPath}' escapes the workspace`
1759
+ };
1760
+ return {
1761
+ ok: true,
1762
+ dataDir
1763
+ };
1764
+ }
1765
+ /** The `storage` arm of the acceptance gate. Every storage backend gets the
1766
+ * conventional phantom dataDir; what differs is what else has to resolve
1767
+ * before the collection can exist at all.
1768
+ *
1769
+ * A FILE-backed backend (sqlite) resolves and containment-checks a
1770
+ * `storageFile`. A SHARED one (firestore) has no path on this machine — it
1771
+ * resolves an IDENTITY instead: the `aid` from the repository's `app.json`,
1772
+ * which together with the slug as `cid` names `apps/{aid}/collections/{cid}`.
1773
+ *
1774
+ * Resolving it HERE, once, is the point. The store then receives a settled
1775
+ * `(aid, cid)` and never reads `app.json` itself — otherwise the questions of
1776
+ * caching, staleness and what to do when the file is missing would be decided
1777
+ * inside a read path, where the only cheap answer is to return nothing, and
1778
+ * "this collection is misconfigured" would reach the user as "this collection
1779
+ * is empty". A missing or malformed `app.json` is a CONFIGURATION error, so it
1780
+ * is reported the same way an escaping `storage.path` is: the schema is
1781
+ * refused, with a reason naming the file to create. */
1782
+ function acceptStorageSchema(storage, opts) {
1783
+ const dataDir = resolveDataDir(conventionalDataPath(opts.slug), opts.workspaceRoot);
1784
+ if (dataDir === null) return {
1785
+ ok: false,
1786
+ reason: `slug '${opts.slug}' yields no workspace-contained data dir`
1787
+ };
1788
+ if (storage.type === "sqlite") {
1789
+ const storageFile = resolveDataDir(storage.path, opts.workspaceRoot);
1790
+ if (storageFile === null) return {
1791
+ ok: false,
1792
+ reason: `storage.path '${storage.path}' escapes the workspace`
1793
+ };
1794
+ return {
1795
+ ok: true,
1796
+ dataDir,
1797
+ storageFile
1798
+ };
1799
+ }
1800
+ const manifest = loadAppManifest(opts.workspaceRoot);
1801
+ if (!manifest.ok) return {
1802
+ ok: false,
1803
+ reason: appManifestReason(manifest, opts.workspaceRoot)
1804
+ };
1805
+ return {
1806
+ ok: true,
1807
+ dataDir,
1808
+ appId: manifest.manifest.aid
1809
+ };
1810
+ }
1811
+ async function loadOneCollection(skillsRoot, slug, source, workspaceRoot) {
1812
+ const safeName = safeSlugName(slug);
1813
+ if (safeName === null) return null;
1814
+ const schemaPath = node_path.default.join(skillsRoot, safeName, SCHEMA_FILE);
1815
+ let raw;
1816
+ try {
1817
+ if (!(await (0, node_fs_promises.stat)(schemaPath)).isFile()) return null;
1818
+ raw = await (0, node_fs_promises.readFile)(schemaPath, "utf-8");
1819
+ } catch (err) {
1820
+ if (!require_dist.isErrorWithCode(err) || err.code !== "ENOENT") log.warn("collections", "failed to read schema.json, skipping", {
1821
+ slug: safeName,
1822
+ path: schemaPath,
1823
+ error: String(err)
1824
+ });
1825
+ return null;
1826
+ }
1827
+ let parsedJson;
1828
+ try {
1829
+ parsedJson = JSON.parse(raw);
1830
+ } catch (err) {
1831
+ log.warn("collections", "schema.json is not valid JSON, skipping", {
1832
+ slug: safeName,
1833
+ error: String(err)
1834
+ });
1835
+ return null;
1836
+ }
1837
+ const candidate = source === "feed" ? applyFeedSchemaDefaults(parsedJson, safeName) : parsedJson;
1838
+ const parsed = CollectionSchemaZ.safeParse(candidate);
1839
+ if (!parsed.success) {
1840
+ log.warn("collections", "schema.json failed validation, skipping", {
1841
+ slug: safeName,
1842
+ issues: parsed.error.issues
1843
+ });
1844
+ return null;
1845
+ }
1846
+ const schema = parsed.data;
1847
+ const acceptance = acceptParsedSchema(schema, {
1848
+ source,
1849
+ workspaceRoot,
1850
+ slug: safeName
1851
+ });
1852
+ if (!acceptance.ok) {
1853
+ log.warn("collections", "schema.json rejected after validation, skipping", {
1854
+ slug: safeName,
1855
+ reason: acceptance.reason
1856
+ });
1857
+ return null;
1858
+ }
1859
+ return {
1860
+ slug: safeName,
1861
+ source,
1862
+ schema,
1863
+ dataDir: acceptance.dataDir,
1864
+ ...acceptance.dataSourceFile !== void 0 ? { dataSourceFile: acceptance.dataSourceFile } : {},
1865
+ ...acceptance.storageFile !== void 0 ? { storageFile: acceptance.storageFile } : {},
1866
+ ...acceptance.appId !== void 0 ? { appId: acceptance.appId } : {},
1867
+ skillDir: node_path.default.join(skillsRoot, safeName)
1868
+ };
1869
+ }
1870
+ async function collectFromDir(skillsRoot, source, workspaceRoot) {
1871
+ let entries;
1872
+ try {
1873
+ entries = await (0, node_fs_promises.readdir)(skillsRoot);
1874
+ } catch (err) {
1875
+ if (require_dist.isErrorWithCode(err) && err.code === "ENOENT") return [];
1876
+ log.warn("collections", "failed to list skills dir, returning empty", {
1877
+ root: skillsRoot,
1878
+ error: String(err)
1879
+ });
1880
+ return [];
1881
+ }
1882
+ const results = [];
1883
+ for (const name of entries) {
1884
+ if (name.startsWith(".")) continue;
1885
+ const safeName = safeSlugName(name);
1886
+ if (safeName === null) continue;
1887
+ const dirPath = node_path.default.join(skillsRoot, safeName);
1888
+ let dirStat;
1889
+ try {
1890
+ dirStat = await (0, node_fs_promises.stat)(dirPath);
1891
+ } catch {
1892
+ continue;
1893
+ }
1894
+ if (!dirStat.isDirectory()) continue;
1895
+ const collection = await loadOneCollection(skillsRoot, safeName, source, workspaceRoot);
1896
+ if (collection) results.push(collection);
1897
+ }
1898
+ return results;
1899
+ }
1900
+ /** The user-scope dir this call should scan, or `null` for none. The single
1901
+ * place the "explicit override beats the host binding, and either may say
1902
+ * none" rule is spelled — `??` cannot express it, because `undefined` there
1903
+ * means "ask the host" and would silently re-enable a scope the caller
1904
+ * passed `null` to switch off. */
1905
+ function resolveUserDir(opts, workspaceRoot) {
1906
+ return opts.userSkillsDir !== void 0 ? opts.userSkillsDir : userSkillsDir(workspaceRoot);
1907
+ }
1908
+ /** Discover every schema-driven collection available to this
1909
+ * workspace. Project-scope collections override user-scope on slug
1910
+ * collision. The `workspaceRoot` override also flows into each
1911
+ * collection's dataDir resolution so a tmpdir-scoped test gets
1912
+ * dataDirs under the same tmpdir (Codex P1 review on PR #1489 —
1913
+ * previously dataDir was always rooted at the live workspacePath
1914
+ * regardless of override). */
1915
+ async function discoverCollections(opts = {}) {
1916
+ const workspaceRoot = opts.workspaceRoot ?? getWorkspaceRoot();
1917
+ const userDir = resolveUserDir(opts, workspaceRoot);
1918
+ const projectDir = projectSkillsDir(workspaceRoot);
1919
+ const feedCollections = await collectFromDir(feedsRoot(workspaceRoot), "feed", workspaceRoot);
1920
+ const userCollections = userDir === null ? [] : await collectFromDir(userDir, "user", workspaceRoot);
1921
+ const projectCollections = await collectFromDir(projectDir, "project", workspaceRoot);
1922
+ const merged = /* @__PURE__ */ new Map();
1923
+ for (const entry of feedCollections) merged.set(entry.slug, entry);
1924
+ for (const entry of userCollections) merged.set(entry.slug, entry);
1925
+ for (const entry of projectCollections) merged.set(entry.slug, entry);
1926
+ return [...merged.values()].sort((left, right) => left.slug.localeCompare(right.slug));
1927
+ }
1928
+ /** Load one collection by slug. Returns null if the slug is invalid,
1929
+ * no matching skill exists, or the schema is malformed. */
1930
+ async function loadCollection(slug, opts = {}) {
1931
+ const safeName = safeSlugName(slug);
1932
+ if (safeName === null) return null;
1933
+ const workspaceRoot = opts.workspaceRoot ?? getWorkspaceRoot();
1934
+ const userDir = resolveUserDir(opts, workspaceRoot);
1935
+ const projectCollection = await loadOneCollection(projectSkillsDir(workspaceRoot), safeName, "project", workspaceRoot);
1936
+ if (projectCollection) return projectCollection;
1937
+ const userCollection = userDir === null ? null : await loadOneCollection(userDir, safeName, "user", workspaceRoot);
1938
+ if (userCollection) return userCollection;
1939
+ return loadOneCollection(feedsRoot(workspaceRoot), safeName, "feed", workspaceRoot);
1940
+ }
1941
+ function toSummary(collection) {
1942
+ return {
1943
+ slug: collection.slug,
1944
+ title: collection.schema.title,
1945
+ icon: collection.schema.icon,
1946
+ source: collection.source,
1947
+ ...collection.schema.dataSource !== void 0 ? { readonly: true } : {},
1948
+ ...collection.appId !== void 0 ? { appId: collection.appId } : {}
1949
+ };
1950
+ }
1951
+ function toDetail(collection) {
1952
+ return {
1953
+ ...toSummary(collection),
1954
+ schema: collection.schema
1955
+ };
1956
+ }
1957
+ //#endregion
1958
+ //#region src/collection/server/io.ts
1959
+ /** True iff `filePath` exists and is a regular file (NOT a symlink).
1960
+ * Defends `listItems` / `readItem` against `*.json` symlinks placed
1961
+ * inside an otherwise-contained data dir — without this, a record
1962
+ * file could symlink to /etc/passwd and the detail endpoint would
1963
+ * happily serve it. Returns false on ENOENT and on any other lstat
1964
+ * failure so the caller's "missing" branch covers those cases too.
1965
+ * Exported so `ontology.ts`'s record COUNT classifies entries with the
1966
+ * SAME lstat logic — the two must agree on what a record file is. */
1967
+ async function isRegularFile(filePath) {
1968
+ try {
1969
+ return (await (0, node_fs_promises.lstat)(filePath)).isFile();
1970
+ } catch {
1971
+ return false;
1972
+ }
1973
+ }
1974
+ /** Read one JSON record file. Returns null when the file is missing,
1975
+ * is a symlink (file-disclosure defense), parses to a non-object,
1976
+ * or has a read/parse error. Caller logs the per-entry skip — this
1977
+ * helper just classifies. Split out to keep `listItems` under the
1978
+ * `sonarjs/cognitive-complexity` threshold. */
1979
+ /** Parse a record file's text into a plain-object `CollectionItem`, or
1980
+ * null when it isn't a JSON object (array / scalar / null). */
1981
+ function parseRecordJson(raw) {
1982
+ const parsed = JSON.parse(raw);
1983
+ return require_dist.isRecord(parsed) ? parsed : null;
1984
+ }
1985
+ async function tryReadRecord(filePath) {
1986
+ if (!await isRegularFile(filePath)) return null;
1987
+ try {
1988
+ return parseRecordJson(await (0, node_fs_promises.readFile)(filePath, "utf-8"));
1989
+ } catch {
1990
+ return null;
1991
+ }
1992
+ }
1993
+ /** Read every record under `dataDir`. Returns [] if the dir doesn't
1994
+ * exist yet (legitimate first-use state). Malformed JSON files and
1995
+ * symlinked records are skipped (the latter is a file-disclosure
1996
+ * defense — see `isRegularFile`). Re-validates the realpath
1997
+ * containment to defend against a symlinked data dir appearing
1998
+ * between discovery and use. */
1999
+ async function listItems(dataDir, opts = {}) {
2000
+ if (!isContainedInRoot(dataDir, opts.workspaceRoot ?? getWorkspaceRoot())) {
2001
+ log.warn("collections", "listItems refused: dataDir escapes workspace via symlink", { dataDir });
2002
+ return [];
2003
+ }
2004
+ let entries;
2005
+ try {
2006
+ entries = await (0, node_fs_promises.readdir)(dataDir);
2007
+ } catch (err) {
2008
+ if (require_dist.isErrorWithCode(err) && err.code === "ENOENT") return [];
2009
+ throw err;
2010
+ }
2011
+ const results = [];
2012
+ for (const name of entries) {
2013
+ if (!name.endsWith(".json")) continue;
2014
+ if (name.startsWith(".")) continue;
2015
+ const filePath = node_path.default.join(dataDir, name);
2016
+ const record = await tryReadRecord(filePath);
2017
+ if (record === null) {
2018
+ log.warn("collections", "skipping record (missing, symlink, or unreadable)", { path: filePath });
2019
+ continue;
2020
+ }
2021
+ results.push(record);
2022
+ }
2023
+ return results;
2024
+ }
2025
+ /** Read one record by id. Returns null when the file is missing,
2026
+ * when the resolved path escapes the workspace via a symlink, or
2027
+ * when the record file itself is a symlink (file-disclosure
2028
+ * defense — see `isRegularFile`). */
2029
+ async function readItem(dataDir, itemId, opts = {}) {
2030
+ const safeId = safeRecordId(itemId);
2031
+ if (safeId === null) return null;
2032
+ if (!isContainedInRoot(dataDir, opts.workspaceRoot ?? getWorkspaceRoot())) return null;
2033
+ const filePath = itemFilePath(dataDir, safeId);
2034
+ if (!await isRegularFile(filePath)) return null;
2035
+ try {
2036
+ return parseRecordJson(await (0, node_fs_promises.readFile)(filePath, "utf-8"));
2037
+ } catch (err) {
2038
+ if (require_dist.isErrorWithCode(err) && err.code === "ENOENT") return null;
2039
+ throw err;
2040
+ }
2041
+ }
2042
+ /** The symlink-containment refusal every record path shares: one check, one
2043
+ * warn, one answer. Extracted because this is a security RULE applied at
2044
+ * three sites (write pre-mkdir, write post-mkdir, delete) — a fix to the
2045
+ * check must not be able to land at only one of them.
2636
2046
  *
2637
- * A discriminated union rather than one shape with optional keys, because
2638
- * only the sqlite variant is a workspace FILE: its `path` is
2639
- * workspace-relative and containment-checked exactly like `dataPath`, while
2640
- * the firestore variant has no path to check — its records are not on this
2641
- * machine at all. Optional keys would let each arm accept the other's, and
2642
- * the compiler would stop being the thing that tells you which. */
2643
- var StorageZ = zod.z.discriminatedUnion("type", [zod.z.object({
2644
- type: zod.z.literal("sqlite"),
2645
- path: zod.z.string().min(1)
2646
- }), zod.z.object({ type: zod.z.literal("firestore") }).strict()]);
2647
- var BareCollectionSchemaZ = zod.z.object({
2648
- title: zod.z.string().min(1),
2649
- icon: zod.z.string().min(1),
2650
- dataPath: zod.z.string().min(1).optional(),
2651
- dataSource: DataSourceZ.optional(),
2652
- storage: StorageZ.optional(),
2653
- primaryKey: zod.z.string().min(1),
2654
- singleton: zod.z.string().trim().min(1).optional(),
2655
- fields: zod.z.record(zod.z.string(), FieldSpecZ),
2656
- actions: zod.z.array(ActionSpecZ).optional(),
2657
- collectionActions: zod.z.array(ActionSpecZ).optional(),
2658
- completionField: zod.z.string().trim().min(1).optional(),
2659
- completionDoneValues: zod.z.array(zod.z.string().trim().min(1)).min(1).optional(),
2660
- displayField: zod.z.string().trim().min(1).optional(),
2661
- triggerField: zod.z.string().trim().min(1).optional(),
2662
- triggerLeadDays: zod.z.number().int().min(0).optional(),
2663
- spawn: SpawnZ.optional(),
2664
- calendarField: zod.z.string().trim().min(1).optional(),
2665
- calendarEndField: zod.z.string().trim().min(1).optional(),
2666
- calendarTimeField: zod.z.string().trim().min(1).optional(),
2667
- kanbanField: zod.z.string().trim().min(1).optional(),
2668
- views: zod.z.array(CustomViewZ).optional(),
2669
- notifyWhen: WhenZ.optional(),
2670
- ingest: IngestZ.optional(),
2671
- googleCalendar: GoogleCalendarSyncZ.optional(),
2672
- dynamicIcon: DynamicIconSpecZ.optional()
2673
- }).refine(declaresExactlyOneStore, {
2674
- message: "declare exactly one of `dataPath` (native JSON records), `dataSource` (external read-only data file), or `storage` (alternative writable backend)",
2675
- path: ["dataPath"]
2676
- }).refine(dataSourceDeclaresNoWriteMachinery, {
2677
- message: "a `dataSource` collection is read-only — it cannot declare `singleton`, `ingest`, `spawn`, or `googleCalendar` (all of them write records)",
2678
- path: ["dataSource"]
2679
- }).refine(googleCalendarMapNamesStoredFields, {
2680
- message: "a `googleCalendar` map key must name a declared, non-computed field, and never the primaryKey (that always holds the Google event id)",
2681
- path: ["googleCalendar"]
2682
- }).refine(dataSourceDeclaresNoMutateAction, {
2683
- message: "a `dataSource` collection is read-only — its actions cannot use `kind: \"mutate\"` (a host write); use `chat`/`agent` actions instead",
2684
- path: ["dataSource"]
2685
- }).refine(singletonIsAValidRecordId, {
2686
- message: "schema `singleton` must be a valid item id (alphanumeric / hyphen / underscore / interior dot, no `..` or path separators)",
2687
- path: ["singleton"]
2688
- }).refine(actionIdsAreUnique, {
2689
- message: "schema `actions` must have unique `id`s",
2690
- path: ["actions"]
2691
- }).refine(collectionActionIdsAreUnique, {
2692
- message: "schema `collectionActions` must have unique `id`s",
2693
- path: ["collectionActions"]
2694
- }).refine(mutateSetKeysNameStoredFields, {
2695
- message: "a mutate action's `set` keys must name declared, non-computed fields (and never the primaryKey)",
2696
- path: ["actions"]
2697
- }).refine(mutateParamRefsAreDeclared, {
2698
- message: "a mutate action's `$params.<name>` references must name keys declared in its `params`",
2699
- path: ["actions"]
2700
- }).refine(collectionActionsAreNotMutate, {
2701
- message: "`collectionActions` cannot contain `kind: \"mutate\"` — a collection-level action has no record to write",
2702
- path: ["collectionActions"]
2703
- }).refine(currencyFieldRefsNameCodeFields, {
2704
- message: "a money field's `currencyField` must name a top-level `string`, `text`, or `enum` field that holds the currency code",
2705
- path: ["fields"]
2706
- }).refine(completionPairIsCoherent, {
2707
- message: "schema `completionField` and `completionDoneValues` must be declared together (both set, or both omitted) — unless `completionField` names a `flag` field, in which case `completionDoneValues` must be omitted (done ⇔ the flag matches)",
2708
- path: ["completionField"]
2709
- }).refine(completionFieldIsDeclared, {
2710
- message: "schema `completionField` must name a top-level field declared in `fields`",
2711
- path: ["completionField"]
2712
- }).refine(displayFieldIsDeclared, {
2713
- message: "schema `displayField` must name a top-level field declared in `fields`",
2714
- path: ["displayField"]
2715
- }).refine(fieldVisibilityGatesNameDeclaredFields, {
2716
- message: "a field's `when.field` must name a top-level field declared in `fields`",
2717
- path: ["fields"]
2718
- }).refine(flagConditionsNameDeclaredFields, {
2719
- message: "a flag field's `where` conditions must name top-level fields declared in `fields` (both `field` and a same-record `valueFrom.field`)",
2720
- path: ["fields"]
2721
- }).refine(completionFlagReadsOnlyStoredFields, {
2722
- message: "a `flag` named by `completionField` may only reference STORED fields in its `where` — completion is evaluated against the raw record (before deriveAll), where computed values (derived/rollup/toggle/flag/embed/backlinks) are absent",
2723
- path: ["completionField"]
2724
- }).refine(flagCompletionSpawnDeclaresWhen, {
2725
- message: "a schema whose `completionField` names a `flag` field must declare an explicit `spawn.when` (the spawn-inert check cannot statically evaluate a flag's `where`)",
2726
- path: ["spawn"]
2727
- }).refine(embedIdFieldsNameIdBearingFields, {
2728
- message: "an embed field's `idField` must name a top-level `ref` or `string` field declared in `fields`",
2729
- path: ["fields"]
2730
- }).refine(triggerFieldRequiresCompletion, {
2731
- message: "schema `triggerField` requires `completionField` / `completionDoneValues` (the gated bell still clears via the done value)",
2732
- path: ["triggerField"]
2733
- }).refine(triggerFieldIsADateField, {
2734
- message: "schema `triggerField` must name a top-level `date` field declared in `fields`",
2735
- path: ["triggerField"]
2736
- }).refine(triggerLeadDaysRequiresTriggerField, {
2737
- message: "schema `triggerLeadDays` requires `triggerField` (it shifts when that field's bell fires)",
2738
- path: ["triggerLeadDays"]
2739
- }).refine(spawnRequiresTriggerField, {
2740
- message: "schema `spawn` requires `triggerField` (the successor's trigger date is `triggerField` advanced by `spawn.every`)",
2741
- path: ["spawn"]
2742
- }).refine(spawnWhenFieldIsDeclared, {
2743
- message: "schema `spawn.when.field` must name a top-level field declared in `fields`",
2744
- path: ["spawn"]
2745
- }).refine(spawnCarryEntriesAreDeclared, {
2746
- message: "every `spawn.carry` entry must name a top-level field declared in `fields`",
2747
- path: ["spawn"]
2748
- }).refine(spawnSuccessorStartsInert, {
2749
- message: "`spawn` must leave the successor in a non-matching state (e.g. `set` the status to a pending value); seeding the predicate field to a matching value via `set`/`carry` would respawn forever",
2750
- path: ["spawn"]
2751
- }).refine(fieldDrivenFromFieldIsEnum, {
2752
- message: "`spawn.every.fromField` must name a top-level `enum` field declared in `fields`",
2753
- path: ["spawn"]
2754
- }).refine(fieldDrivenMapCoversValues, {
2755
- message: "`spawn.every.map` keys must exactly cover the `values` of the `enum` named by `fromField` (no missing or extra keys)",
2756
- path: ["spawn"]
2757
- }).refine(fieldDrivenFromFieldCarried, {
2758
- message: "`spawn.every.fromField` must appear in `spawn.carry`, or be written by `spawn.set` to a value present in `spawn.every.map`, so the successor keeps a resolvable recurrence interval",
2759
- path: ["spawn"]
2760
- }).refine(calendarFieldIsDateLike, {
2761
- message: "schema `calendarField` must name a top-level `date` or `datetime` field declared in `fields`",
2762
- path: ["calendarField"]
2763
- }).refine(calendarEndFieldRequiresCalendarField, {
2764
- message: "schema `calendarEndField` requires `calendarField` (it marks the end of the span that starts at `calendarField`)",
2765
- path: ["calendarEndField"]
2766
- }).refine(calendarEndFieldIsDateLike, {
2767
- message: "schema `calendarEndField` must name a top-level `date` or `datetime` field declared in `fields`",
2768
- path: ["calendarEndField"]
2769
- }).refine(calendarTimeFieldRequiresCalendarField, {
2770
- message: "schema `calendarTimeField` requires `calendarField` (it supplies the time-of-day for the calendar's day view)",
2771
- path: ["calendarTimeField"]
2772
- }).refine(calendarTimeFieldIsDeclared, {
2773
- message: "schema `calendarTimeField` must name a top-level field declared in `fields`",
2774
- path: ["calendarTimeField"]
2775
- }).refine(calendarTimeFieldIsStringBacked, {
2776
- message: "schema `calendarTimeField` must name a top-level `string` or `text` field declared in `fields`",
2777
- path: ["calendarTimeField"]
2778
- }).refine(kanbanFieldIsAnEnum, {
2779
- message: "schema `kanbanField` must name a top-level `enum` field declared in `fields`",
2780
- path: ["kanbanField"]
2781
- }).refine(togglesProjectValidEnums, {
2782
- message: "a `toggle` field's `field` must name a top-level `enum` field, and its `onValue`/`offValue` must be values of that enum",
2783
- path: ["fields"]
2784
- }).refine(notifyWhenRequiresCompletion, {
2785
- message: "schema `notifyWhen` requires `completionField` (it narrows that bell)",
2786
- path: ["notifyWhen"]
2787
- }).refine(notifyWhenFieldIsDeclared, {
2788
- message: "schema `notifyWhen.field` must name a top-level field declared in `fields`",
2789
- path: ["notifyWhen"]
2790
- }).refine(viewIdsAreSlugs, {
2791
- message: "every `views[].id` must be a valid slug (alphanumeric / hyphen / underscore, no path separators)",
2792
- path: ["views"]
2793
- }).refine(viewIdsAreUnique, {
2794
- message: "schema `views` must have unique `id`s",
2795
- path: ["views"]
2047
+ * `stage` names the call site so the warn stays as diagnosable as the three
2048
+ * hand-written copies were.
2049
+ *
2050
+ * Scope, stated explicitly because a reviewer asks every time: this catches
2051
+ * a symlink that EXISTS when we look — `isContainedInRoot` realpaths the
2052
+ * closest existing ancestor, so a pre-planted escape is refused. It does not
2053
+ * and cannot close the check-then-use race, where an ancestor is swapped for
2054
+ * a symlink between this call and the `mkdir` / `open` / `unlink` that
2055
+ * follows. Closing that needs directory-handle I/O anchored at the workspace
2056
+ * (`openat` + `O_NOFOLLOW`), which `node:fs` does not expose — it would mean
2057
+ * a different I/O layer, not a tighter check here.
2058
+ *
2059
+ * That race is deliberately outside this app's threat model: the process is
2060
+ * loopback-bound and bearer-authed, so anyone able to swap directories inside
2061
+ * the workspace is already the workspace owner — the same trust principal the
2062
+ * writes belong to. Revisit if collections ever serve a lower-trust caller. */
2063
+ function escapesWorkspace(dataDir, workspaceRoot, itemId, stage) {
2064
+ if (isContainedInRoot(dataDir, workspaceRoot)) return false;
2065
+ log.warn("collections", `${stage} refused: dataDir escapes workspace via symlink`, {
2066
+ dataDir,
2067
+ itemId
2068
+ });
2069
+ return true;
2070
+ }
2071
+ /** Write a record. Ensures the directory exists, validates the id,
2072
+ * re-checks symlink containment after mkdir, and writes atomically.
2073
+ *
2074
+ * Create path (`refuseOverwrite: true`) uses an O_EXCL `wx` open
2075
+ * rather than `stat` + `writeFileAtomic` to close a check-then-write
2076
+ * race: two concurrent POSTs would otherwise both pass the existence
2077
+ * check and one would silently overwrite the other. The trade-off
2078
+ * is that the create path is not crash-atomic (a partial file could
2079
+ * remain if the process dies mid-write); acceptable here because
2080
+ * records are small JSON blobs and the next read either parses or
2081
+ * is skipped via the "malformed JSON" branch in `listItems`.
2082
+ *
2083
+ * Update path (`refuseOverwrite: false`) uses `writeFileAtomic` so
2084
+ * PUT remains crash-atomic. No race there — the URL pins the id. */
2085
+ async function writeItem(dataDir, itemId, item, opts = {}) {
2086
+ const safeId = safeRecordId(itemId);
2087
+ if (safeId === null) return {
2088
+ kind: "invalid-id",
2089
+ itemId
2090
+ };
2091
+ const workspaceRoot = opts.workspaceRoot ?? getWorkspaceRoot();
2092
+ if (escapesWorkspace(dataDir, workspaceRoot, safeId, "writeItem (pre-mkdir)")) return {
2093
+ kind: "path-escape",
2094
+ itemId: safeId
2095
+ };
2096
+ await (0, node_fs_promises.mkdir)(dataDir, { recursive: true });
2097
+ if (escapesWorkspace(dataDir, workspaceRoot, safeId, "writeItem (post-mkdir)")) return {
2098
+ kind: "path-escape",
2099
+ itemId: safeId
2100
+ };
2101
+ const filePath = itemFilePath(dataDir, safeId);
2102
+ const payload = `${JSON.stringify(item, null, 2)}\n`;
2103
+ if (opts.refuseOverwrite) {
2104
+ let handle;
2105
+ try {
2106
+ handle = await (0, node_fs_promises.open)(filePath, "wx");
2107
+ } catch (err) {
2108
+ if (require_dist.isErrorWithCode(err) && err.code === "EEXIST") return {
2109
+ kind: "conflict",
2110
+ itemId: safeId
2111
+ };
2112
+ throw err;
2113
+ }
2114
+ try {
2115
+ await handle.writeFile(payload);
2116
+ } finally {
2117
+ await handle.close();
2118
+ }
2119
+ } else await require_root.writeFileAtomic(filePath, payload);
2120
+ if (opts.slug) publishCollectionChange(collectionChangePayload({
2121
+ slug: opts.slug,
2122
+ ids: [safeId],
2123
+ op: "upsert"
2124
+ }, opts.workspaceRoot));
2125
+ return {
2126
+ kind: "ok",
2127
+ itemId: safeId,
2128
+ item
2129
+ };
2130
+ }
2131
+ async function deleteItem(dataDir, itemId, opts = {}) {
2132
+ const safeId = safeRecordId(itemId);
2133
+ if (safeId === null) return {
2134
+ kind: "invalid-id",
2135
+ itemId
2136
+ };
2137
+ if (escapesWorkspace(dataDir, opts.workspaceRoot ?? getWorkspaceRoot(), safeId, "deleteItem")) return {
2138
+ kind: "path-escape",
2139
+ itemId: safeId
2140
+ };
2141
+ const filePath = itemFilePath(dataDir, safeId);
2142
+ try {
2143
+ await (0, node_fs_promises.unlink)(filePath);
2144
+ if (opts.slug) publishCollectionChange(collectionChangePayload({
2145
+ slug: opts.slug,
2146
+ ids: [safeId],
2147
+ op: "delete"
2148
+ }, opts.workspaceRoot));
2149
+ return {
2150
+ kind: "ok",
2151
+ itemId: safeId
2152
+ };
2153
+ } catch (err) {
2154
+ if (require_dist.isErrorWithCode(err) && err.code === "ENOENT") return {
2155
+ kind: "not-found",
2156
+ itemId: safeId
2157
+ };
2158
+ throw err;
2159
+ }
2160
+ }
2161
+ /** Generate a short random hex id. Used by POST when the form doesn't
2162
+ * carry a primary-key value (UI shortcut — Claude normally derives a
2163
+ * semantic id from the record's name). */
2164
+ function generateItemId() {
2165
+ return (0, node_crypto.randomBytes)(4).toString("hex");
2166
+ }
2167
+ /** The item id a CREATE should use for `schema`, or null when the
2168
+ * caller should generate one. A singleton collection pins every
2169
+ * create to its fixed `schema.singleton` id, so the "at most one
2170
+ * record" contract is enforced server-side (a second create targets
2171
+ * the same file and hits `writeItem`'s refuseOverwrite conflict) —
2172
+ * not only in the UI. Otherwise the record's own primaryKey value
2173
+ * wins, falling back to a generated id (null = "generate"). */
2174
+ function resolveCreateItemId(schema, record) {
2175
+ if (schema.singleton) return schema.singleton;
2176
+ const primaryRaw = record[schema.primaryKey];
2177
+ return typeof primaryRaw === "string" && primaryRaw.length > 0 ? primaryRaw : null;
2178
+ }
2179
+ //#endregion
2180
+ //#region src/collection/core/queryZ.ts
2181
+ /** Result-column aliases double as SQL identifiers and JSON keys — keep
2182
+ * them to a conservative identifier charset so neither side needs
2183
+ * escaping gymnastics. */
2184
+ var SAFE_ALIAS_PATTERN = /^[A-Za-z_]\w{0,63}$/;
2185
+ /** Hard ceiling on returned rows; `limit` clamps below it. A group-by on
2186
+ * a near-unique column would otherwise return one row per source row —
2187
+ * the exact materialization the aggregate path exists to avoid. */
2188
+ var MAX_QUERY_ROWS = 1e4;
2189
+ /** Default row cap when the query declares no `limit`. */
2190
+ var DEFAULT_QUERY_ROWS = 1e3;
2191
+ /** One aggregate column: `count` (rows; `column` optional to count
2192
+ * non-null cells) or `sum`/`avg`/`min`/`max` over a named CSV column. */
2193
+ var QueryAggregateZ = zod.z.object({
2194
+ op: zod.z.enum([
2195
+ "count",
2196
+ "sum",
2197
+ "avg",
2198
+ "min",
2199
+ "max"
2200
+ ]),
2201
+ column: zod.z.string().min(1).optional()
2202
+ }).refine((aggregate) => aggregate.op === "count" || aggregate.column !== void 0, {
2203
+ message: "`column` is required for every aggregate op except `count`",
2204
+ path: ["column"]
2796
2205
  });
2797
- var PROTOTYPE_KEYS = [
2798
- "__proto__",
2799
- "constructor",
2800
- "prototype"
2801
- ];
2802
- /** The first own prototype-sensitive key of `value`, or null. */
2803
- function ownPrototypeKey(value) {
2804
- if (value === null || typeof value !== "object") return null;
2805
- for (const key of PROTOTYPE_KEYS) if (Object.hasOwn(value, key)) return key;
2806
- return null;
2206
+ /** One filter condition. Same op vocabulary as the schema-level `where`
2207
+ * (`core/where.ts`) so authors learn one set; values may be typed
2208
+ * (number / boolean) since CSV columns are. `in` requires an array
2209
+ * value, every other op a scalar. */
2210
+ var QueryWhereZ = zod.z.object({
2211
+ field: zod.z.string().min(1),
2212
+ op: zod.z.enum([
2213
+ "eq",
2214
+ "ne",
2215
+ "in",
2216
+ "gt",
2217
+ "gte",
2218
+ "lt",
2219
+ "lte",
2220
+ "contains"
2221
+ ]),
2222
+ value: zod.z.union([
2223
+ zod.z.string(),
2224
+ zod.z.number(),
2225
+ zod.z.boolean(),
2226
+ zod.z.array(zod.z.union([
2227
+ zod.z.string(),
2228
+ zod.z.number(),
2229
+ zod.z.boolean()
2230
+ ])).min(1).max(100)
2231
+ ])
2232
+ }).refine((cond) => cond.op === "in" === Array.isArray(cond.value), {
2233
+ message: "`in` requires an array value (the allowed set); every other op requires a scalar value",
2234
+ path: ["value"]
2235
+ });
2236
+ var QueryOrderZ = zod.z.object({
2237
+ /** A `groupBy` column or an aggregate alias — membership enforced by
2238
+ * the whole-query refine below. */
2239
+ field: zod.z.string().min(1),
2240
+ dir: zod.z.enum(["asc", "desc"]).optional()
2241
+ });
2242
+ /** The whole query. At least one of `groupBy` / `aggregates` must be
2243
+ * present: bare `groupBy` is a DISTINCT listing, bare `aggregates` a
2244
+ * whole-file scalar row, together a grouped aggregation. */
2245
+ var CollectionQueryZ = zod.z.object({
2246
+ groupBy: zod.z.array(zod.z.string().min(1)).max(8).refine((columns) => new Set(columns.map((column) => column.toLowerCase())).size === columns.length, { message: "`groupBy` columns must be unique (case-insensitively — SQL identifiers ignore case)" }).optional(),
2247
+ aggregates: zod.z.record(zod.z.string().regex(SAFE_ALIAS_PATTERN, "aggregate aliases must be simple identifiers (letters/digits/underscore)"), QueryAggregateZ).optional(),
2248
+ where: zod.z.array(QueryWhereZ).max(16).optional(),
2249
+ orderBy: zod.z.array(QueryOrderZ).max(4).optional(),
2250
+ limit: zod.z.number().int().min(1).max(MAX_QUERY_ROWS).optional()
2251
+ }).refine((query) => (query.groupBy?.length ?? 0) > 0 || Object.keys(query.aggregates ?? {}).length > 0, {
2252
+ message: "declare at least one of `groupBy` (columns to bucket by) or `aggregates` (values to compute)",
2253
+ path: ["groupBy"]
2254
+ }).refine((query) => Object.keys(query.aggregates ?? {}).length <= 32, {
2255
+ message: `\`aggregates\` supports at most 32 entries`,
2256
+ path: ["aggregates"]
2257
+ }).refine((query) => {
2258
+ const groupLower = new Set((query.groupBy ?? []).map((column) => column.toLowerCase()));
2259
+ const seen = /* @__PURE__ */ new Set();
2260
+ return Object.keys(query.aggregates ?? {}).every((alias) => {
2261
+ const lower = alias.toLowerCase();
2262
+ if (groupLower.has(lower) || seen.has(lower)) return false;
2263
+ seen.add(lower);
2264
+ return true;
2265
+ });
2266
+ }, {
2267
+ message: "aggregate aliases must be unique and must not collide with `groupBy` column names (case-insensitively — SQL identifiers ignore case)",
2268
+ path: ["aggregates"]
2269
+ }).refine((query) => {
2270
+ const sortable = /* @__PURE__ */ new Set([...query.groupBy ?? [], ...Object.keys(query.aggregates ?? {})]);
2271
+ return (query.orderBy ?? []).every((order) => sortable.has(order.field));
2272
+ }, {
2273
+ message: "every `orderBy.field` must be a `groupBy` column or an aggregate alias",
2274
+ path: ["orderBy"]
2275
+ });
2276
+ //#endregion
2277
+ //#region src/collection/server/csvQuery.ts
2278
+ /** Double-quote a SQL identifier (CSV column name / result alias). */
2279
+ function quoteIdent(name) {
2280
+ return `"${name.replaceAll("\"", "\"\"")}"`;
2281
+ }
2282
+ /** Single-quote a SQL string literal (a `types={...}` struct key). */
2283
+ function quoteLiteral(value) {
2284
+ return `'${value.replaceAll("'", "''")}'`;
2285
+ }
2286
+ /** The `read_csv` argument list shared by every CSV query: the (prepared)
2287
+ * path plus a `types` pin forcing the key column to VARCHAR — without it
2288
+ * DuckDB's sniffer turns `001` into BIGINT 1, so leading zeros vanish
2289
+ * and distinct keys collapse. */
2290
+ function readCsvArgs(primaryKey) {
2291
+ return `?, types={${quoteLiteral(primaryKey)}: 'VARCHAR'}`;
2292
+ }
2293
+ /** One aggregate's SQL expression. `sum`/`avg` TRY_CAST to DOUBLE so a
2294
+ * column the sniffer kept as VARCHAR (mixed values) aggregates over its
2295
+ * numeric cells instead of erroring; non-numeric cells become NULL and
2296
+ * are skipped — standard BI tolerance. `min`/`max` stay native (they are
2297
+ * meaningful on strings and dates too). */
2298
+ function aggregateExpr(aggregate) {
2299
+ const { op, column } = aggregate;
2300
+ if (op === "count") return column === void 0 ? "count(*)" : `count(${quoteIdent(column)})`;
2301
+ if (op === "sum" || op === "avg") return `${op}(TRY_CAST(${quoteIdent(column ?? "")} AS DOUBLE))`;
2302
+ return `${op}(${quoteIdent(column ?? "")})`;
2303
+ }
2304
+ /** One where condition → SQL fragment + its bound parameters. String
2305
+ * equality compares against `CAST(col AS VARCHAR)` so a sniffer-typed
2306
+ * column still matches its textual value; numeric/boolean values compare
2307
+ * natively (DuckDB coerces the column side). */
2308
+ function whereFragment(cond) {
2309
+ const column = quoteIdent(cond.field);
2310
+ const asText = `CAST(${column} AS VARCHAR)`;
2311
+ if (cond.op === "in") {
2312
+ const values = arrayValue(cond);
2313
+ return {
2314
+ sql: `${values.every((value) => typeof value === "string") ? asText : column} IN (${values.map(() => "?").join(", ")})`,
2315
+ params: values
2316
+ };
2317
+ }
2318
+ if (cond.op === "contains") return {
2319
+ sql: `contains(${asText}, ?)`,
2320
+ params: [String(scalarValue(cond))]
2321
+ };
2322
+ const operator = {
2323
+ eq: "=",
2324
+ ne: "<>",
2325
+ gt: ">",
2326
+ gte: ">=",
2327
+ lt: "<",
2328
+ lte: "<="
2329
+ }[cond.op];
2330
+ return {
2331
+ sql: `${typeof cond.value === "string" && (cond.op === "eq" || cond.op === "ne") ? asText : column} ${operator} ?`,
2332
+ params: [scalarValue(cond)]
2333
+ };
2334
+ }
2335
+ /** Mirror of `scalarValue` for the one op that takes a set: a scalar under
2336
+ * `in` also means the query skipped `CollectionQueryZ`. Left unchecked it
2337
+ * failed as `values.every is not a function`, naming neither the field nor
2338
+ * the op. */
2339
+ function arrayValue(cond) {
2340
+ if (!Array.isArray(cond.value)) throw new Error(`where condition on '${cond.field}' uses op 'in', which requires an array value, not a scalar`);
2341
+ return cond.value;
2342
+ }
2343
+ /** `CollectionQueryZ` refines "`in` ⇔ array value", so an array reaching a
2344
+ * scalar op means the query was compiled without being validated first —
2345
+ * binding it would send an array to a single `?`. */
2346
+ function scalarValue(cond) {
2347
+ if (Array.isArray(cond.value)) throw new Error(`where condition on '${cond.field}' uses op '${cond.op}', which requires a scalar value, not an array`);
2348
+ return cond.value;
2807
2349
  }
2808
- /** Own enumerable entries of an object (arrays keyed by index), none for
2809
- * anything else — the raw input is unvalidated, so `fields` may be junk. */
2810
- function ownEntries(value) {
2811
- if (require_dist.isUnknownArray(value)) return value.map((entry, index) => [String(index), entry]);
2812
- return require_dist.isRecord(value) ? Object.entries(value) : [];
2350
+ /** Compile a validated query against `fromSql` (a table-function call
2351
+ * whose FIRST placeholder is the source path — the executor binds it).
2352
+ * Returns the SQL and the where-value parameters that follow the path.
2353
+ * Callers MUST have run `CollectionQueryZ` first; this function trusts
2354
+ * the shape (aliases already charset-checked, orderBy membership already
2355
+ * enforced). */
2356
+ function compileQuery(query, fromSql) {
2357
+ const groupBy = query.groupBy ?? [];
2358
+ const aggregates = Object.entries(query.aggregates ?? {});
2359
+ const selectList = [...groupBy.map(quoteIdent), ...aggregates.map(([alias, aggregate]) => `${aggregateExpr(aggregate)} AS ${quoteIdent(alias)}`)];
2360
+ const where = (query.where ?? []).map(whereFragment);
2361
+ const clauses = [`SELECT ${selectList.join(", ")}`, `FROM ${fromSql}`];
2362
+ if (where.length > 0) clauses.push(`WHERE ${where.map((fragment) => fragment.sql).join(" AND ")}`);
2363
+ if (groupBy.length > 0) clauses.push(`GROUP BY ${groupBy.map(quoteIdent).join(", ")}`);
2364
+ const orderBy = (query.orderBy ?? []).map((order) => quoteIdent(order.field) + (order.dir === "desc" ? " DESC" : " ASC"));
2365
+ if (orderBy.length > 0) clauses.push(`ORDER BY ${orderBy.join(", ")}`);
2366
+ clauses.push(`LIMIT ${query.limit ?? 1e3}`);
2367
+ return {
2368
+ sql: clauses.join(" "),
2369
+ params: where.flatMap((fragment) => fragment.params)
2370
+ };
2813
2371
  }
2814
- /** The name-defining sub-record a raw field spec (`of`) or action (`params`)
2815
- * carries, or undefined when the holder isn't an object at all. */
2816
- function nameDefiningSubRecord(holder, key) {
2817
- return require_dist.isRecord(holder) ? holder[key] : void 0;
2372
+ /** Compile against a CSV file (the dataSource store's engine). */
2373
+ function compileCsvQuery(query, primaryKey) {
2374
+ return compileQuery(query, `read_csv(${readCsvArgs(primaryKey)})`);
2818
2375
  }
2819
- /** Dotted path of the first prototype-sensitive `params` name across both
2820
- * action lists, or null. */
2821
- function prototypeActionParamPath(input) {
2822
- for (const [listName, list] of [["actions", input.actions], ["collectionActions", input.collectionActions]]) for (const action of require_dist.isUnknownArray(list) ? list : []) {
2823
- const badParam = ownPrototypeKey(nameDefiningSubRecord(action, "params"));
2824
- if (badParam !== null) return `${listName}.params.${badParam}`;
2376
+ /** Compile against a JSONL file of ENRICHED records — the file-backed
2377
+ * collections' engine (see `jsonlQuery.ts`). No VARCHAR key pin needed:
2378
+ * enriched record ids are already strings. `sample_size=-1` makes the
2379
+ * schema inference scan EVERY line — with the default sample, a sparse
2380
+ * optional/derived field first appearing past the sample would not be
2381
+ * inferred as a column and the query would binder-error on it (Codex P2
2382
+ * on #2165). The full scan costs nothing extra here: aggregation reads
2383
+ * the whole file anyway. */
2384
+ function compileJsonlQuery(query) {
2385
+ return compileQuery(query, `read_json(?, format='newline_delimited', sample_size=-1)`);
2386
+ }
2387
+ //#endregion
2388
+ //#region src/collection/server/csvStore.ts
2389
+ /** `list()` row cap. Over-cap files are truncated with a warn — the v1
2390
+ * contract is "browse + per-record views", not full-table analytics. */
2391
+ var MAX_CSV_ROWS = 5e3;
2392
+ /** Record ids minted from non-safe key values: `id0x` + utf-8 hex. Raw key
2393
+ * values that themselves match this pattern are ALSO encoded, so the
2394
+ * encoded namespace never collides with a raw value (injective mapping). */
2395
+ var ENCODED_ID_PATTERN = /^id0x([0-9a-f]+)$/;
2396
+ /** A CSV key value → the record id it's addressed by. Safe values pass
2397
+ * through untouched; everything else (and anything shaped like an encoded
2398
+ * id) becomes `id0x<hex>`. Pure + exported for unit tests. */
2399
+ function encodeCsvRecordId(rawKey) {
2400
+ if (safeRecordId(rawKey) === rawKey && !ENCODED_ID_PATTERN.test(rawKey)) return rawKey;
2401
+ return `id0x${Buffer.from(rawKey, "utf-8").toString("hex")}`;
2402
+ }
2403
+ /** A record id → the CSV key value to look up. Inverse of
2404
+ * `encodeCsvRecordId` for encoded ids; anything else is already the raw
2405
+ * value. Pure + exported for unit tests. */
2406
+ function decodeCsvRecordId(itemId) {
2407
+ const hex = ENCODED_ID_PATTERN.exec(itemId)?.[1];
2408
+ if (hex === void 0) return itemId;
2409
+ return Buffer.from(hex, "hex").toString("utf-8");
2410
+ }
2411
+ /** Normalize one DuckDB JS value into a JSON-safe record value: BigInt →
2412
+ * number (string beyond the safe range), DATE/TIMESTAMP → ISO string
2413
+ * (date-only when the clock is exactly UTC midnight, matching the `date`
2414
+ * field contract), exotic DuckDB values → their string form. Pure +
2415
+ * exported for unit tests. */
2416
+ /** `JSON.stringify` restricted to what a CSV cell can survive. Returns the
2417
+ * serialised value, or `String(value)` when serialisation is impossible —
2418
+ * losing the content of one cell is bad, failing the entire query is worse. */
2419
+ function safeJsonCell(value) {
2420
+ try {
2421
+ return JSON.stringify(value, (_key, entry) => typeof entry === "bigint" ? entry.toString() : entry) ?? String(value);
2422
+ } catch {
2423
+ return String(value);
2825
2424
  }
2826
- return null;
2827
2425
  }
2828
- /** Dotted path of the first prototype-sensitive field name in the raw
2829
- * schema input — top-level `fields`, each table field's `of`, and each
2830
- * action's `params` (the three records that DEFINE names) — or null. */
2831
- function prototypeFieldKeyPath(input) {
2832
- if (!require_dist.isRecord(input)) return null;
2833
- const bad = ownPrototypeKey(input.fields);
2834
- if (bad !== null) return `fields.${bad}`;
2835
- for (const [key, spec] of ownEntries(input.fields)) {
2836
- const badSub = ownPrototypeKey(nameDefiningSubRecord(spec, "of"));
2837
- if (badSub !== null) return `fields.${key}.of.${badSub}`;
2426
+ function normalizeCsvValue(value) {
2427
+ if (typeof value === "bigint") return value <= BigInt(Number.MAX_SAFE_INTEGER) && value >= BigInt(-Number.MAX_SAFE_INTEGER) ? Number(value) : value.toString();
2428
+ if (value instanceof Date) {
2429
+ const iso = value.toISOString();
2430
+ return iso.endsWith("T00:00:00.000Z") ? iso.slice(0, 10) : iso;
2431
+ }
2432
+ if (value !== null && typeof value === "object") return safeJsonCell(value);
2433
+ return value;
2434
+ }
2435
+ /** One raw DuckDB row → a CollectionItem, or null when the key cell is
2436
+ * missing/empty (the row can't be addressed). The primaryKey field is
2437
+ * OVERWRITTEN with the (possibly encoded) record id so `item[primaryKey]`
2438
+ * and the record's address never drift — same invariant the file store's
2439
+ * write path enforces. Pure + exported for unit tests. */
2440
+ function csvRowToItem(row, primaryKey) {
2441
+ const normalized = Object.fromEntries(Object.entries(row).map(([key, value]) => [key, normalizeCsvValue(value)]));
2442
+ const rawKey = normalized[primaryKey];
2443
+ const keyText = require_calendarGrid.fieldTextOrNull(rawKey);
2444
+ if (keyText === null || keyText === "") return null;
2445
+ return {
2446
+ ...normalized,
2447
+ [primaryKey]: encodeCsvRecordId(keyText)
2448
+ };
2449
+ }
2450
+ /** Dedupe by record id, LAST row wins (matches `csvRead`'s last-match
2451
+ * pick). Returns the surviving items in first-seen order. Pure +
2452
+ * exported for unit tests. */
2453
+ function dedupeByRecordId(items, primaryKey) {
2454
+ const byId = /* @__PURE__ */ new Map();
2455
+ for (const item of items) byId.set(String(item[primaryKey]), item);
2456
+ return {
2457
+ items: [...byId.values()],
2458
+ duplicates: items.length - byId.size
2459
+ };
2460
+ }
2461
+ /** True when a thrown DuckDB error is the `types` pin naming a column the
2462
+ * CSV doesn't have — the schema/file-mismatch case the caller downgrades
2463
+ * to "empty collection + warn" instead of a 500. */
2464
+ function isMissingKeyColumnError(err) {
2465
+ return String(err).includes("do not exist in the CSV");
2466
+ }
2467
+ /** Bytes sniffed for UTF-8 validity. The trailing 3 bytes of the sample
2468
+ * are dropped so a multibyte char split at the boundary can't produce a
2469
+ * false negative on a valid file. */
2470
+ var SNIFF_BYTES = 1048576;
2471
+ function isValidUtf8(buf) {
2472
+ try {
2473
+ new TextDecoder("utf-8", { fatal: true }).decode(buf);
2474
+ return true;
2475
+ } catch {
2476
+ return false;
2477
+ }
2478
+ }
2479
+ /** Detect the (best-effort) encoding of a non-UTF-8 buffer. BOMs decide
2480
+ * UTF-16; otherwise cp932 (the Shift_JIS superset — Excel-exported
2481
+ * Japanese CSVs are the primary non-UTF-8 case this feature serves). */
2482
+ function fallbackEncoding(buf) {
2483
+ if (buf.length >= 2 && buf[0] === 255 && buf[1] === 254) return "utf-16le";
2484
+ if (buf.length >= 2 && buf[0] === 254 && buf[1] === 255) return "utf-16be";
2485
+ return "cp932";
2486
+ }
2487
+ function cacheDir() {
2488
+ return node_path.default.join((0, node_os.tmpdir)(), "mulmoclaude-csv-utf8");
2489
+ }
2490
+ /** Read only the first `bytes` of a file — the encoding sniff must not
2491
+ * pull a multi-hundred-MB CSV into memory on the (common) UTF-8 path. */
2492
+ async function readHead(absPath, bytes) {
2493
+ const handle = await (0, node_fs_promises.open)(absPath, "r");
2494
+ try {
2495
+ const { size } = await handle.stat();
2496
+ const buf = Buffer.alloc(Math.min(bytes, size));
2497
+ await handle.read(buf, 0, buf.length, 0);
2498
+ return buf;
2499
+ } finally {
2500
+ await handle.close();
2501
+ }
2502
+ }
2503
+ /** Decode the whole file into a UTF-8 cache copy and return its path.
2504
+ * Cache key = (path, mtime, size), so a replaced CSV re-decodes and an
2505
+ * unchanged one never does. */
2506
+ async function pathExists(target) {
2507
+ try {
2508
+ await (0, node_fs_promises.stat)(target);
2509
+ return true;
2510
+ } catch {
2511
+ return false;
2512
+ }
2513
+ }
2514
+ /** Best-effort removal of older decode-cache entries for the same source
2515
+ * path — a frequently-replaced large CSV would otherwise accumulate one
2516
+ * full copy per (mtime, size) forever. Runs AFTER the current copy is
2517
+ * published; a concurrent reader holding an old fd is unaffected
2518
+ * (unlink-while-open is safe on POSIX). */
2519
+ async function evictSupersededCache(key, keepBasename) {
2520
+ try {
2521
+ const entries = await (0, node_fs_promises.readdir)(cacheDir());
2522
+ await Promise.all(entries.filter((name) => name.startsWith(`${key}-`) && name !== keepBasename).map((name) => (0, node_fs_promises.unlink)(node_path.default.join(cacheDir(), name)).catch(() => void 0)));
2523
+ } catch {}
2524
+ }
2525
+ /** Decode the whole file into a UTF-8 cache copy and return its path.
2526
+ * Cache key = (path, mtime, size), so a replaced CSV re-decodes and an
2527
+ * unchanged one never does; superseded copies are evicted. The cache
2528
+ * lives in the SHARED OS tmpdir, so the dir is 0700 and files 0600 —
2529
+ * decoded rows must not be readable by other local users. */
2530
+ async function decodeToCache(absPath, info) {
2531
+ const key = (0, node_crypto.createHash)("sha256").update(absPath).digest("hex").slice(0, 16);
2532
+ const cached = node_path.default.join(cacheDir(), `${key}-${Math.trunc(info.mtimeMs)}-${info.size}.csv`);
2533
+ if (!await pathExists(cached)) {
2534
+ const whole = await (0, node_fs_promises.readFile)(absPath);
2535
+ const encoding = fallbackEncoding(whole);
2536
+ const text = iconv_lite.default.decode(whole, encoding);
2537
+ await (0, node_fs_promises.mkdir)(cacheDir(), {
2538
+ recursive: true,
2539
+ mode: 448
2540
+ });
2541
+ const tmp = `${cached}.${(0, node_crypto.randomBytes)(4).toString("hex")}.tmp`;
2542
+ await (0, node_fs_promises.writeFile)(tmp, text, {
2543
+ encoding: "utf-8",
2544
+ mode: 384
2545
+ });
2546
+ await (0, node_fs_promises.rename)(tmp, cached);
2547
+ log.info("collections", "decoded non-UTF-8 dataSource file to cache", {
2548
+ path: absPath,
2549
+ encoding
2550
+ });
2551
+ await evictSupersededCache(key, node_path.default.basename(cached));
2552
+ }
2553
+ return cached;
2554
+ }
2555
+ /** Re-validate the dataSource file at READ time, mirroring the JSON
2556
+ * store's per-read defenses: realpath containment (a symlink swapped in
2557
+ * after discovery must not walk out of the workspace) and an lstat
2558
+ * regular-file check (a symlink leaf is refused outright, even one
2559
+ * pointing inside the workspace — same rule as `isRegularFile` on
2560
+ * record files). Returns the stat info, or null for "no readable file"
2561
+ * (ENOENT / refused), which callers render as an empty collection. */
2562
+ async function safeCsvStat(absPath, workspaceRoot) {
2563
+ if (!isContainedInRoot(absPath, workspaceRoot)) {
2564
+ log.warn("collections", "dataSource read refused: path escapes workspace", { path: absPath });
2565
+ return null;
2566
+ }
2567
+ let info;
2568
+ try {
2569
+ info = await (0, node_fs_promises.lstat)(absPath);
2570
+ } catch (err) {
2571
+ if (require_dist.isErrorWithCode(err) && err.code === "ENOENT") return null;
2572
+ throw err;
2573
+ }
2574
+ if (!info.isFile()) {
2575
+ log.warn("collections", "dataSource read refused: not a regular file (symlink?)", { path: absPath });
2576
+ return null;
2577
+ }
2578
+ return info;
2579
+ }
2580
+ /** Return a path DuckDB can read as UTF-8: the original file when it
2581
+ * already is UTF-8 (the cheap, common case — only the head is sniffed),
2582
+ * else a decoded cache copy (see `decodeToCache`). Returns null when
2583
+ * there is no readable file (missing, symlink, or containment-refused —
2584
+ * see `safeCsvStat`), which callers render as an empty collection. */
2585
+ async function ensureUtf8CsvPath(absPath, workspaceRoot) {
2586
+ const info = await safeCsvStat(absPath, workspaceRoot);
2587
+ if (info === null) return null;
2588
+ const head = await readHead(absPath, SNIFF_BYTES);
2589
+ const sample = head.length === SNIFF_BYTES ? head.subarray(0, 1048573) : head;
2590
+ if (!(head.length >= 2 && (head[0] === 255 && head[1] === 254 || head[0] === 254 && head[1] === 255)) && isValidUtf8(sample)) return absPath;
2591
+ return decodeToCache(absPath, info);
2592
+ }
2593
+ var instancePromise = null;
2594
+ /** Lazily create one shared in-memory DuckDB instance. The dynamic import
2595
+ * keeps the native module OUT of core's load path — a platform where the
2596
+ * prebuilt binding is missing degrades to a per-query error on dataSource
2597
+ * collections only, never a broken core. A failed init is retried on the
2598
+ * next call (the promise is reset). */
2599
+ async function duckDbInstance() {
2600
+ if (instancePromise === null) instancePromise = import("@duckdb/node-api").then((mod) => mod.DuckDBInstance.create(":memory:"));
2601
+ try {
2602
+ return await instancePromise;
2603
+ } catch (err) {
2604
+ instancePromise = null;
2605
+ throw new BackendUnavailableError(`DuckDB is unavailable on this host (@duckdb/node-api failed to load: ${String(err)}) — dataSource collections cannot be read`);
2838
2606
  }
2839
- return prototypeActionParamPath(input);
2840
2607
  }
2841
- var CollectionSchemaZ = zod.z.preprocess((input, ctx) => {
2842
- const bad = prototypeFieldKeyPath(input);
2843
- if (bad !== null) {
2844
- ctx.addIssue({
2845
- code: "custom",
2846
- message: `'${bad}': field names must not be prototype-sensitive keys (\`__proto__\`, \`constructor\`, \`prototype\`)`
2847
- });
2848
- return zod.z.NEVER;
2608
+ async function queryCsv(sql, params) {
2609
+ const connection = await (await duckDbInstance()).connect();
2610
+ try {
2611
+ return (await connection.runAndReadAll(sql, params)).getRowObjectsJS();
2612
+ } finally {
2613
+ connection.disconnectSync();
2849
2614
  }
2850
- return input;
2851
- }, BareCollectionSchemaZ);
2852
- //#endregion
2853
- //#region src/collection/server/discovery.ts
2854
- function applyFeedSchemaDefaults(parsed, slug) {
2855
- if (!require_dist.isRecord(parsed)) return parsed;
2856
- const icon = typeof parsed.icon === "string" && parsed.icon.trim().length > 0 ? parsed.icon : "dynamic_feed";
2857
- return {
2858
- ...parsed,
2859
- icon,
2860
- dataPath: `data/feeds/${slug}`
2861
- };
2862
- }
2863
- /** The conventional per-slug records dir a `dataSource` / `storage` collection
2864
- * gets as its `dataDir` (records never live there, but archive/delete paths
2865
- * stay well-defined — same shape the registry's R3 normalization uses).
2866
- *
2867
- * INVARIANT — this is NOT a default `dataPath`, and must not be used as one.
2868
- * It applies only to the two backends whose records are not per-file JSON. A
2869
- * normal collection declares its own location and exactly one of `dataPath` /
2870
- * `dataSource` / `storage`; a schema with none of the three is REJECTED, not
2871
- * quietly pointed here. Handing a per-file collection this path would silently
2872
- * relocate its records away from the folder the user (and its SKILL.md) sees. */
2873
- function conventionalDataPath(slug) {
2874
- return `data/collections/${slug}/items`;
2875
- }
2876
- /** The declared field named by `primaryKey`, or `undefined` when the schema
2877
- * declares no such field. Own-property guarded: a `primaryKey` of `toString`
2878
- * / `constructor` / `__proto__` must miss here, not read an Object.prototype
2879
- * member and slip past the "is it a declared field?" gate into the wrong
2880
- * "add `primary: true`" advice. Shared with manageCollection's putSchema
2881
- * gate so both report the SAME reason. */
2882
- function resolvePrimaryField(fields, primaryKey) {
2883
- return Object.hasOwn(fields, primaryKey) ? fields[primaryKey] : void 0;
2884
2615
  }
2885
- /** The acceptance gates discovery applies AFTER `CollectionSchemaZ` parses,
2886
- * before a schema becomes a live collection:
2887
- *
2888
- * - the `primaryKey` must be a declared field flagged `primary: true` —
2889
- * without the flag CollectionView renders the field editable, and a
2890
- * rename is silently pinned back to the URL itemId on save, so the user's
2891
- * edit is dropped with no error;
2892
- * - a `feed` schema must declare an `ingest` block (else it's a dead,
2893
- * non-refreshable card);
2894
- * - `dataPath` — or a `dataSource`'s `path` — must resolve INSIDE the
2895
- * workspace (same realpath containment for both).
2896
- *
2897
- * Exported so `manageCollection`'s `putSchema` can run the SAME gates before
2898
- * it reports success — a schema that passes `CollectionSchemaZ` but fails one
2899
- * of these would otherwise write cleanly yet be skipped on the next discovery,
2900
- * hiding the collection (the exact failure that tool exists to prevent). */
2901
- function acceptParsedSchema(schema, opts) {
2902
- const primaryField = resolvePrimaryField(schema.fields, schema.primaryKey);
2903
- if (!primaryField) return {
2904
- ok: false,
2905
- reason: `primaryKey '${schema.primaryKey}' is not one of the declared fields`
2906
- };
2907
- if (primaryField.primary !== true) return {
2908
- ok: false,
2909
- reason: `the primaryKey field '${schema.primaryKey}' must be flagged \`primary: true\``
2910
- };
2911
- if (opts.source === "feed" && !schema.ingest) return {
2912
- ok: false,
2913
- reason: "a feed schema must declare an `ingest` block"
2616
+ async function csvList(absPath, primaryKey, workspaceRoot) {
2617
+ const utf8Path = await ensureUtf8CsvPath(absPath, workspaceRoot ?? getWorkspaceRoot());
2618
+ if (utf8Path === null) return {
2619
+ items: [],
2620
+ truncated: false
2914
2621
  };
2915
- if (schema.dataSource !== void 0) {
2916
- const dataSourceFile = resolveDataDir(schema.dataSource.path, opts.workspaceRoot);
2917
- if (dataSourceFile === null) return {
2918
- ok: false,
2919
- reason: `dataSource.path '${schema.dataSource.path}' escapes the workspace`
2920
- };
2921
- const dataDir = resolveDataDir(conventionalDataPath(opts.slug), opts.workspaceRoot);
2922
- if (dataDir === null) return {
2923
- ok: false,
2924
- reason: `slug '${opts.slug}' yields no workspace-contained data dir`
2925
- };
2622
+ let rows;
2623
+ try {
2624
+ rows = await queryCsv(`SELECT * FROM read_csv(${readCsvArgs(primaryKey)}) LIMIT 5001`, [utf8Path]);
2625
+ } catch (err) {
2626
+ if (!isMissingKeyColumnError(err)) throw err;
2627
+ log.warn("collections", "dataSource CSV has no primaryKey column — every row is skipped", {
2628
+ path: absPath,
2629
+ primaryKey
2630
+ });
2926
2631
  return {
2927
- ok: true,
2928
- dataDir,
2929
- dataSourceFile
2632
+ items: [],
2633
+ truncated: false
2930
2634
  };
2931
2635
  }
2932
- if (schema.storage !== void 0) return acceptStorageSchema(schema.storage, opts);
2933
- const dataDir = resolveDataDir(schema.dataPath ?? "", opts.workspaceRoot);
2934
- if (dataDir === null) return {
2935
- ok: false,
2936
- reason: `dataPath '${schema.dataPath}' escapes the workspace`
2937
- };
2636
+ const truncated = rows.length > MAX_CSV_ROWS;
2637
+ if (truncated) {
2638
+ log.warn("collections", "dataSource CSV truncated to row cap", {
2639
+ path: absPath,
2640
+ cap: MAX_CSV_ROWS
2641
+ });
2642
+ rows.length = MAX_CSV_ROWS;
2643
+ }
2644
+ const items = rows.map((row) => csvRowToItem(row, primaryKey)).filter((item) => item !== null);
2645
+ const skipped = rows.length - items.length;
2646
+ if (skipped > 0) log.warn("collections", "dataSource CSV rows skipped (empty key cell)", {
2647
+ path: absPath,
2648
+ skipped
2649
+ });
2650
+ const deduped = dedupeByRecordId(items, primaryKey);
2651
+ if (deduped.duplicates > 0) log.warn("collections", "dataSource CSV has duplicate key values (last row wins)", {
2652
+ path: absPath,
2653
+ duplicates: deduped.duplicates
2654
+ });
2938
2655
  return {
2939
- ok: true,
2940
- dataDir
2656
+ items: deduped.items,
2657
+ truncated
2941
2658
  };
2942
2659
  }
2943
- /** The `storage` arm of the acceptance gate. Every storage backend gets the
2944
- * conventional phantom dataDir; what differs is what else has to resolve
2945
- * before the collection can exist at all.
2660
+ /** The scan-order ordinal column the last-match read adds. Underscore
2661
+ * prefix keeps it out of any plausible CSV header namespace; it is
2662
+ * stripped from the returned record either way. */
2663
+ var ROW_ORDINAL = "__mc_row";
2664
+ /** One record by id. The comparison value rides as a prepared-statement
2665
+ * parameter, and the LAST matching row is selected IN DuckDB (scan-order
2666
+ * ordinal + LIMIT 1) — a CSV with thousands of duplicate keys must not
2667
+ * materialize them all for one detail read. Consistent with csvList's
2668
+ * last-wins dedupe. */
2669
+ async function csvRead(absPath, primaryKey, itemId, workspaceRoot) {
2670
+ const utf8Path = await ensureUtf8CsvPath(absPath, workspaceRoot ?? getWorkspaceRoot());
2671
+ if (utf8Path === null) return null;
2672
+ const rawKey = decodeCsvRecordId(itemId);
2673
+ const last = (await queryCsv(`SELECT * FROM (SELECT *, row_number() OVER () AS ${quoteIdent(ROW_ORDINAL)} FROM read_csv(${readCsvArgs(primaryKey)})) WHERE CAST(${quoteIdent(primaryKey)} AS VARCHAR) = ? ORDER BY ${quoteIdent(ROW_ORDINAL)} DESC LIMIT 1`, [utf8Path, rawKey])).at(0);
2674
+ if (last === void 0) return null;
2675
+ const { [ROW_ORDINAL]: __ordinal, ...record } = last;
2676
+ return csvRowToItem(record, primaryKey);
2677
+ }
2678
+ /** Run a validated aggregation query (the structured DSL — see
2679
+ * `core/queryZ.ts`) over the WHOLE file: no row cap on the scan (a
2680
+ * capped aggregate would be a wrong number), only the result-row LIMIT
2681
+ * the compiler emits. Values are normalized like list/read rows so a
2682
+ * chart consumer gets plain JSON scalars. */
2683
+ async function csvRunQuery(absPath, primaryKey, query, workspaceRoot) {
2684
+ const utf8Path = await ensureUtf8CsvPath(absPath, workspaceRoot ?? getWorkspaceRoot());
2685
+ if (utf8Path === null) return [];
2686
+ const { sql, params } = compileCsvQuery(query, primaryKey);
2687
+ return (await queryCsv(sql, [utf8Path, ...params])).map((row) => Object.fromEntries(Object.entries(row).map(([key, value]) => [key, normalizeCsvValue(value)])));
2688
+ }
2689
+ //#endregion
2690
+ //#region src/collection/server/watchFs.ts
2691
+ /** An atomic file replace (editor save, `mv` over the target) surfaces as
2692
+ * 2-3 events. Collapse them so one user action reports one change. */
2693
+ var REPLACE_DEBOUNCE_MS = 300;
2694
+ /** The path to hand `watch()`, with Windows 8.3 short names resolved away.
2946
2695
  *
2947
- * A FILE-backed backend (sqlite) resolves and containment-checks a
2948
- * `storageFile`. A SHARED one (firestore) has no path on this machine — it
2949
- * resolves an IDENTITY instead: the `aid` from the repository's `app.json`,
2950
- * which together with the slug as `cid` names `apps/{aid}/collections/{cid}`.
2696
+ * ReadDirectoryChangesW reports filenames against the LONG path, but a watch
2697
+ * opened on a short path (`C:\Users\RUNNER~1\…` — what `os.tmpdir()` returns
2698
+ * on GitHub's Windows runners) keeps the short form. libuv's
2699
+ * `assert(!_wcsnicmp(filename, dir, dirlen))` in `src/win/fs-event.c` then
2700
+ * aborts the PROCESS on the first event — a native assert, so neither
2701
+ * `watcher.on("error")` nor a try/catch can contain it.
2951
2702
  *
2952
- * Resolving it HERE, once, is the point. The store then receives a settled
2953
- * `(aid, cid)` and never reads `app.json` itself — otherwise the questions of
2954
- * caching, staleness and what to do when the file is missing would be decided
2955
- * inside a read path, where the only cheap answer is to return nothing, and
2956
- * "this collection is misconfigured" would reach the user as "this collection
2957
- * is empty". A missing or malformed `app.json` is a CONFIGURATION error, so it
2958
- * is reported the same way an escaping `storage.path` is: the schema is
2959
- * refused, with a reason naming the file to create. */
2960
- function acceptStorageSchema(storage, opts) {
2961
- const dataDir = resolveDataDir(conventionalDataPath(opts.slug), opts.workspaceRoot);
2962
- if (dataDir === null) return {
2963
- ok: false,
2964
- reason: `slug '${opts.slug}' yields no workspace-contained data dir`
2965
- };
2966
- if (storage.type === "sqlite") {
2967
- const storageFile = resolveDataDir(storage.path, opts.workspaceRoot);
2968
- if (storageFile === null) return {
2969
- ok: false,
2970
- reason: `storage.path '${storage.path}' escapes the workspace`
2971
- };
2972
- return {
2973
- ok: true,
2974
- dataDir,
2975
- storageFile
2976
- };
2977
- }
2978
- const manifest = loadAppManifest(opts.workspaceRoot);
2979
- if (!manifest.ok) return {
2980
- ok: false,
2981
- reason: appManifestReason(manifest, opts.workspaceRoot)
2982
- };
2983
- return {
2984
- ok: true,
2985
- dataDir,
2986
- appId: manifest.manifest.aid
2987
- };
2703
+ * POSIX is deliberately left alone: `realpath` there also collapses symlinks
2704
+ * (`/var` → `/private/var` on macOS), which we neither need nor want to
2705
+ * change. A failure falls back to the original path — worst case we are no
2706
+ * worse off than before. */
2707
+ function watchablePath(dir) {
2708
+ if (process.platform !== "win32") return dir;
2709
+ try {
2710
+ return node_fs.realpathSync.native(dir);
2711
+ } catch {
2712
+ return dir;
2713
+ }
2988
2714
  }
2989
- async function loadOneCollection(skillsRoot, slug, source, workspaceRoot) {
2990
- const safeName = safeSlugName(slug);
2991
- if (safeName === null) return null;
2992
- const schemaPath = node_path.default.join(skillsRoot, safeName, SCHEMA_FILE);
2993
- let raw;
2715
+ /** Watch `dir`, reporting each accepted filename. `accept` decides what is
2716
+ * noise; a null filename always passes (the platform didn't tell us which
2717
+ * file, so the caller must assume the worst). */
2718
+ async function watchDirectory(dir, accept, onHit) {
2994
2719
  try {
2995
- if (!(await (0, node_fs_promises.stat)(schemaPath)).isFile()) return null;
2996
- raw = await (0, node_fs_promises.readFile)(schemaPath, "utf-8");
2720
+ await (0, node_fs_promises.mkdir)(dir, { recursive: true });
2721
+ const watcher = (0, node_fs.watch)(watchablePath(dir), { persistent: false }, (_eventType, rawFilename) => {
2722
+ const filename = rawFilename === null ? null : String(rawFilename);
2723
+ if (filename !== null && !accept(filename)) return;
2724
+ onHit(filename);
2725
+ });
2726
+ watcher.on("error", (err) => {
2727
+ log.warn("collections", "fs watch error", {
2728
+ dir,
2729
+ error: String(err)
2730
+ });
2731
+ });
2732
+ return { close: () => watcher.close() };
2997
2733
  } catch (err) {
2998
- if (!require_dist.isErrorWithCode(err) || err.code !== "ENOENT") log.warn("collections", "failed to read schema.json, skipping", {
2999
- slug: safeName,
3000
- path: schemaPath,
2734
+ log.warn("collections", "fs watch start failed", {
2735
+ dir,
3001
2736
  error: String(err)
3002
2737
  });
3003
2738
  return null;
3004
2739
  }
3005
- let parsedJson;
2740
+ }
2741
+ /** Watch the single file `absPath` by watching its PARENT directory, so an
2742
+ * atomic replace can't strand the watch on a dead inode. `alsoAccept`
2743
+ * widens the filter beyond the exact basename (sqlite's `-wal`/`-journal`
2744
+ * sidecars). Reports are debounced: one replace, one call. */
2745
+ async function watchSingleFile(absPath, alsoAccept, onChange) {
2746
+ const dir = node_path.default.dirname(absPath);
2747
+ const base = node_path.default.basename(absPath);
2748
+ let timer = null;
2749
+ const fire = () => {
2750
+ if (timer) clearTimeout(timer);
2751
+ timer = setTimeout(() => {
2752
+ timer = null;
2753
+ onChange();
2754
+ }, REPLACE_DEBOUNCE_MS);
2755
+ timer.unref?.();
2756
+ };
2757
+ const handle = await watchDirectory(dir, (filename) => filename === base || alsoAccept(base, filename), fire);
2758
+ if (!handle) return null;
2759
+ return { close: () => {
2760
+ if (timer) clearTimeout(timer);
2761
+ timer = null;
2762
+ handle.close();
2763
+ } };
2764
+ }
2765
+ /** An `FsWatchHandle` as a bare unsubscribe — `null` straight through, so an
2766
+ * unarmed watch stays distinguishable from an armed one. Lives here rather
2767
+ * than beside the store contract so both `store.ts` and the backends it
2768
+ * registers can reach it without importing each other. */
2769
+ function closerFor(handle) {
2770
+ return handle === null ? null : () => handle.close();
2771
+ }
2772
+ //#endregion
2773
+ //#region src/collection/server/sqliteStore.ts
2774
+ /** A constructor's parameter and return types are not observable at runtime,
2775
+ * so the check stops at "DatabaseSync is constructible" — the only member of
2776
+ * the module this store ever touches. */
2777
+ function isSqliteModule(mod) {
2778
+ return require_dist.isRecord(mod) && typeof mod.DatabaseSync === "function";
2779
+ }
2780
+ var sqliteModule = null;
2781
+ /** Drops the memo first so a later call can retry (e.g. tests stubbing the
2782
+ * runtime), then reports why the backend is unusable. */
2783
+ function sqliteUnavailable(reason) {
2784
+ sqliteModule = null;
2785
+ throw new BackendUnavailableError(`sqlite storage needs the node:sqlite module (Node.js >= 22.5) — this runtime cannot load it: ${reason}`);
2786
+ }
2787
+ /** Lazy-load node:sqlite once. A runtime without it (Node < 22.5) throws a
2788
+ * clearly-worded error the caller surfaces — never a bare MODULE_NOT_FOUND. */
2789
+ function loadSqlite() {
2790
+ sqliteModule ??= import("node:sqlite").then((mod) => isSqliteModule(mod) ? mod : sqliteUnavailable("the module exposes no DatabaseSync constructor"), (err) => sqliteUnavailable(String(err)));
2791
+ return sqliteModule;
2792
+ }
2793
+ /** The db file's on-disk state. A symlink or non-regular file is refused
2794
+ * (file-disclosure defense, same rule as io.ts record files); ENOENT is
2795
+ * just "no records yet". Any OTHER lstat failure (EACCES, EIO, …) is
2796
+ * rethrown so reads surface a real filesystem problem instead of
2797
+ * silently reporting an empty collection. */
2798
+ async function dbFileState(absPath) {
3006
2799
  try {
3007
- parsedJson = JSON.parse(raw);
2800
+ return (await (0, node_fs_promises.lstat)(absPath)).isFile() ? "file" : "refused";
3008
2801
  } catch (err) {
3009
- log.warn("collections", "schema.json is not valid JSON, skipping", {
3010
- slug: safeName,
3011
- error: String(err)
3012
- });
3013
- return null;
2802
+ if (require_dist.isErrorWithCode(err) && err.code === "ENOENT") return "missing";
2803
+ throw err;
3014
2804
  }
3015
- const candidate = source === "feed" ? applyFeedSchemaDefaults(parsedJson, safeName) : parsedJson;
3016
- const parsed = CollectionSchemaZ.safeParse(candidate);
3017
- if (!parsed.success) {
3018
- log.warn("collections", "schema.json failed validation, skipping", {
3019
- slug: safeName,
3020
- issues: parsed.error.issues
3021
- });
3022
- return null;
2805
+ }
2806
+ var CREATE_TABLE = "CREATE TABLE IF NOT EXISTS records (id TEXT PRIMARY KEY, record TEXT NOT NULL)";
2807
+ /** Open the database for one operation, classifying the two unavailable
2808
+ * states so callers can map them honestly (`refused` ⇒ path-escape,
2809
+ * `missing` ⇒ empty / not-found — conflating them would misreport a
2810
+ * containment escape as "item not found"). The containment pre-check runs
2811
+ * BEFORE mkdir even when the file is missing — `isContainedInRoot`
2812
+ * resolves through the closest existing ancestor, so a symlinked-away
2813
+ * parent can never make the recursive mkdir create directories outside
2814
+ * the workspace (same pre/post belt-and-suspenders as io.ts writes). */
2815
+ async function openDb(absPath, workspaceRoot, mode) {
2816
+ const state = await dbFileState(absPath);
2817
+ if (state === "refused") {
2818
+ log.warn("collections", "sqlite database refused: not a regular file", { path: absPath });
2819
+ return { kind: "refused" };
3023
2820
  }
3024
- const schema = parsed.data;
3025
- const acceptance = acceptParsedSchema(schema, {
3026
- source,
3027
- workspaceRoot,
3028
- slug: safeName
3029
- });
3030
- if (!acceptance.ok) {
3031
- log.warn("collections", "schema.json rejected after validation, skipping", {
3032
- slug: safeName,
3033
- reason: acceptance.reason
3034
- });
3035
- return null;
2821
+ if (!isContainedInRoot(node_path.default.dirname(absPath), workspaceRoot)) {
2822
+ log.warn("collections", "sqlite refused: database dir escapes workspace via symlink", { path: absPath });
2823
+ return { kind: "refused" };
2824
+ }
2825
+ if (mode === "read" && state === "missing") return { kind: "missing" };
2826
+ if (mode === "write") {
2827
+ await (0, node_fs_promises.mkdir)(node_path.default.dirname(absPath), { recursive: true });
2828
+ if (!isContainedInRoot(node_path.default.dirname(absPath), workspaceRoot)) {
2829
+ log.warn("collections", "sqlite write refused: database dir escapes workspace via symlink (post-mkdir)", { path: absPath });
2830
+ return { kind: "refused" };
2831
+ }
3036
2832
  }
2833
+ const { DatabaseSync } = await loadSqlite();
2834
+ const database = new DatabaseSync(absPath);
2835
+ database.exec("PRAGMA busy_timeout = 5000");
2836
+ database.exec(CREATE_TABLE);
3037
2837
  return {
3038
- slug: safeName,
3039
- source,
3040
- schema,
3041
- dataDir: acceptance.dataDir,
3042
- ...acceptance.dataSourceFile !== void 0 ? { dataSourceFile: acceptance.dataSourceFile } : {},
3043
- ...acceptance.storageFile !== void 0 ? { storageFile: acceptance.storageFile } : {},
3044
- ...acceptance.appId !== void 0 ? { appId: acceptance.appId } : {},
3045
- skillDir: node_path.default.join(skillsRoot, safeName)
2838
+ kind: "ok",
2839
+ database
2840
+ };
2841
+ }
2842
+ /** Run `operation` against the database and always close it; unavailable
2843
+ * states resolve through `onUnavailable` so each caller maps `missing`
2844
+ * vs `refused` to its own result kind. */
2845
+ async function withDb(absPath, workspaceRoot, mode, onUnavailable, operation) {
2846
+ const handle = await openDb(absPath, workspaceRoot, mode);
2847
+ if (handle.kind !== "ok") return onUnavailable(handle.kind);
2848
+ try {
2849
+ return await operation(handle.database);
2850
+ } finally {
2851
+ handle.database.close();
2852
+ }
2853
+ }
2854
+ var SQLITE_CONSTRAINT_PRIMARYKEY = 1555;
2855
+ var SQLITE_CONSTRAINT_UNIQUE = 2067;
2856
+ /** node:sqlite throws ERR_SQLITE_ERROR with the SQLite extended result
2857
+ * code on `errcode`. Checked structurally (message text kept only as a
2858
+ * fallback for runtimes that don't expose `errcode`). */
2859
+ function isUniqueConstraintError(err) {
2860
+ if (require_dist.hasNumberProp(err, "errcode")) return err.errcode === SQLITE_CONSTRAINT_PRIMARYKEY || err.errcode === SQLITE_CONSTRAINT_UNIQUE;
2861
+ return String(err).includes("UNIQUE constraint");
2862
+ }
2863
+ function parseRow(raw) {
2864
+ if (typeof raw !== "string") return null;
2865
+ try {
2866
+ const parsed = JSON.parse(raw);
2867
+ return require_dist.isRecord(parsed) ? parsed : null;
2868
+ } catch {
2869
+ return null;
2870
+ }
2871
+ }
2872
+ /** One column of a result row. node:sqlite types rows as `unknown`, so a
2873
+ * value that is not a row object yields no column at all. */
2874
+ function readColumn(row, column) {
2875
+ return require_dist.isRecord(row) ? row[column] : void 0;
2876
+ }
2877
+ function rowsToItems(rows) {
2878
+ return rows.map((row) => parseRow(readColumn(row, "record"))).filter((item) => item !== null);
2879
+ }
2880
+ /** node:sqlite hands back an integer column as `number`, or as `bigint` once
2881
+ * it leaves the safe-integer range — COUNT(*) can be either. */
2882
+ function countRecords(database) {
2883
+ const count = readColumn(database.prepare("SELECT COUNT(*) AS n FROM records").get(), "n");
2884
+ if (typeof count === "number") return count;
2885
+ if (typeof count === "bigint") return Number(count);
2886
+ throw new Error(`sqlite COUNT(*) returned no numeric row count (got ${typeof count})`);
2887
+ }
2888
+ async function sqliteList(absPath, workspaceRoot) {
2889
+ return withDb(absPath, workspaceRoot, "read", () => [], (database) => rowsToItems(database.prepare("SELECT record FROM records ORDER BY id").all()));
2890
+ }
2891
+ async function sqlitePage(absPath, primaryKey, opts, workspaceRoot) {
2892
+ const emptyPage = {
2893
+ items: [],
2894
+ total: 0,
2895
+ truncated: false
2896
+ };
2897
+ return withDb(absPath, workspaceRoot, "read", () => emptyPage, (database) => {
2898
+ const total = countRecords(database);
2899
+ const offset = Math.max(0, opts.offset ?? 0);
2900
+ const limit = opts.limit === void 0 ? -1 : Math.max(0, opts.limit);
2901
+ return {
2902
+ items: projectItemFields(rowsToItems(database.prepare("SELECT record FROM records ORDER BY id LIMIT ? OFFSET ?").all(limit, offset)), opts.fields, primaryKey),
2903
+ total,
2904
+ truncated: false
2905
+ };
2906
+ });
2907
+ }
2908
+ async function sqliteRead(absPath, itemId, workspaceRoot) {
2909
+ const safeId = safeRecordId(itemId);
2910
+ if (safeId === null) return null;
2911
+ return withDb(absPath, workspaceRoot, "read", () => null, (database) => {
2912
+ return parseRow(readColumn(database.prepare("SELECT record FROM records WHERE id = ?").get(safeId), "record"));
2913
+ });
2914
+ }
2915
+ async function sqliteWrite(absPath, itemId, item, opts) {
2916
+ const safeId = safeRecordId(itemId);
2917
+ if (safeId === null) return {
2918
+ kind: "invalid-id",
2919
+ itemId
2920
+ };
2921
+ const outcome = await withDb(absPath, opts.workspaceRoot, "write", () => ({
2922
+ kind: "path-escape",
2923
+ itemId: safeId
2924
+ }), (database) => {
2925
+ const payload = JSON.stringify(item);
2926
+ if (opts.refuseOverwrite) try {
2927
+ database.prepare("INSERT INTO records (id, record) VALUES (?, ?)").run(safeId, payload);
2928
+ } catch (err) {
2929
+ if (isUniqueConstraintError(err)) return {
2930
+ kind: "conflict",
2931
+ itemId: safeId
2932
+ };
2933
+ throw err;
2934
+ }
2935
+ else database.prepare("INSERT INTO records (id, record) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET record = excluded.record").run(safeId, payload);
2936
+ return {
2937
+ kind: "ok",
2938
+ itemId: safeId,
2939
+ item
2940
+ };
2941
+ });
2942
+ if (outcome.kind === "ok" && opts.slug) publishCollectionChange(collectionChangePayload({
2943
+ slug: opts.slug,
2944
+ ids: [safeId],
2945
+ op: "upsert"
2946
+ }, opts.publishRoot));
2947
+ return outcome;
2948
+ }
2949
+ async function sqliteDelete(absPath, itemId, opts) {
2950
+ const safeId = safeRecordId(itemId);
2951
+ if (safeId === null) return {
2952
+ kind: "invalid-id",
2953
+ itemId
3046
2954
  };
2955
+ const outcome = await withDb(absPath, opts.workspaceRoot, "read", (reason) => reason === "refused" ? {
2956
+ kind: "path-escape",
2957
+ itemId: safeId
2958
+ } : {
2959
+ kind: "not-found",
2960
+ itemId: safeId
2961
+ }, (database) => {
2962
+ const { changes } = database.prepare("DELETE FROM records WHERE id = ?").run(safeId);
2963
+ return Number(changes) === 0 ? {
2964
+ kind: "not-found",
2965
+ itemId: safeId
2966
+ } : {
2967
+ kind: "ok",
2968
+ itemId: safeId
2969
+ };
2970
+ });
2971
+ if (outcome.kind === "ok" && opts.slug) publishCollectionChange(collectionChangePayload({
2972
+ slug: opts.slug,
2973
+ ids: [safeId],
2974
+ op: "delete"
2975
+ }, opts.publishRoot));
2976
+ return outcome;
3047
2977
  }
3048
- async function collectFromDir(skillsRoot, source, workspaceRoot) {
3049
- let entries;
2978
+ /** Best-effort full WAL checkpoint so the MAIN db file alone is a
2979
+ * complete snapshot (committed pages in `<db>-wal` are folded in and the
2980
+ * WAL truncated). Used by `deleteCollection` before archiving. Returns
2981
+ * false on any failure (runtime without node:sqlite, locked db, missing
2982
+ * file) — the caller then archives the sidecar files alongside the db so
2983
+ * no committed data is lost either way. */
2984
+ async function checkpointSqliteDatabase(absPath) {
3050
2985
  try {
3051
- entries = await (0, node_fs_promises.readdir)(skillsRoot);
3052
- } catch (err) {
3053
- if (require_dist.isErrorWithCode(err) && err.code === "ENOENT") return [];
3054
- log.warn("collections", "failed to list skills dir, returning empty", {
3055
- root: skillsRoot,
3056
- error: String(err)
3057
- });
3058
- return [];
3059
- }
3060
- const results = [];
3061
- for (const name of entries) {
3062
- if (name.startsWith(".")) continue;
3063
- const safeName = safeSlugName(name);
3064
- if (safeName === null) continue;
3065
- const dirPath = node_path.default.join(skillsRoot, safeName);
3066
- let dirStat;
2986
+ const { DatabaseSync } = await loadSqlite();
2987
+ const database = new DatabaseSync(absPath);
3067
2988
  try {
3068
- dirStat = await (0, node_fs_promises.stat)(dirPath);
3069
- } catch {
3070
- continue;
2989
+ database.exec("PRAGMA wal_checkpoint(TRUNCATE)");
2990
+ } finally {
2991
+ database.close();
3071
2992
  }
3072
- if (!dirStat.isDirectory()) continue;
3073
- const collection = await loadOneCollection(skillsRoot, safeName, source, workspaceRoot);
3074
- if (collection) results.push(collection);
2993
+ return true;
2994
+ } catch {
2995
+ return false;
3075
2996
  }
3076
- return results;
3077
2997
  }
3078
- /** The user-scope dir this call should scan, or `null` for none. The single
3079
- * place the "explicit override beats the host binding, and either may say
3080
- * none" rule is spelled — `??` cannot express it, because `undefined` there
3081
- * means "ask the host" and would silently re-enable a scope the caller
3082
- * passed `null` to switch off. */
3083
- function resolveUserDir(opts, workspaceRoot) {
3084
- return opts.userSkillsDir !== void 0 ? opts.userSkillsDir : userSkillsDir(workspaceRoot);
2998
+ /** A `storage: sqlite` store over `collection.storageFile`. A schema whose
2999
+ * `storageFile` failed to resolve yields a read-only EMPTY store rather
3000
+ * than a writable one — same fail-closed rule as the CSV store. */
3001
+ function sqliteStoreFor(collection, opts) {
3002
+ const file = collection.storageFile;
3003
+ const key = collection.schema.primaryKey;
3004
+ const slug = opts.slug ?? collection.slug;
3005
+ const root = () => opts.workspaceRoot ?? getWorkspaceRoot();
3006
+ const publishRoot = opts.workspaceRoot;
3007
+ if (file === void 0) return {
3008
+ capabilities: {
3009
+ writable: false,
3010
+ nativeQuery: false,
3011
+ nativePaging: false
3012
+ },
3013
+ list: () => Promise.resolve([]),
3014
+ page: () => Promise.resolve({
3015
+ items: [],
3016
+ total: 0,
3017
+ truncated: false
3018
+ }),
3019
+ read: () => Promise.resolve(null)
3020
+ };
3021
+ return {
3022
+ capabilities: {
3023
+ writable: true,
3024
+ nativeQuery: false,
3025
+ nativePaging: true
3026
+ },
3027
+ list: () => sqliteList(file, root()),
3028
+ page: (pageOpts = {}) => sqlitePage(file, key, pageOpts, root()),
3029
+ read: (itemId) => sqliteRead(file, itemId, root()),
3030
+ write: (itemId, item, writeOpts = {}) => sqliteWrite(file, itemId, item, {
3031
+ workspaceRoot: root(),
3032
+ publishRoot,
3033
+ slug,
3034
+ refuseOverwrite: writeOpts.refuseOverwrite
3035
+ }),
3036
+ delete: (itemId) => sqliteDelete(file, itemId, {
3037
+ workspaceRoot: root(),
3038
+ publishRoot,
3039
+ slug
3040
+ }),
3041
+ watch: async (onChange) => closerFor(await watchSingleFile(file, (base, name) => name.startsWith(base), () => onChange({ kind: "collection" })))
3042
+ };
3085
3043
  }
3086
- /** Discover every schema-driven collection available to this
3087
- * workspace. Project-scope collections override user-scope on slug
3088
- * collision. The `workspaceRoot` override also flows into each
3089
- * collection's dataDir resolution so a tmpdir-scoped test gets
3090
- * dataDirs under the same tmpdir (Codex P1 review on PR #1489 —
3091
- * previously dataDir was always rooted at the live workspacePath
3092
- * regardless of override). */
3093
- async function discoverCollections(opts = {}) {
3094
- const workspaceRoot = opts.workspaceRoot ?? getWorkspaceRoot();
3095
- const userDir = resolveUserDir(opts, workspaceRoot);
3096
- const projectDir = projectSkillsDir(workspaceRoot);
3097
- const feedCollections = await collectFromDir(feedsRoot(workspaceRoot), "feed", workspaceRoot);
3098
- const userCollections = userDir === null ? [] : await collectFromDir(userDir, "user", workspaceRoot);
3099
- const projectCollections = await collectFromDir(projectDir, "project", workspaceRoot);
3100
- const merged = /* @__PURE__ */ new Map();
3101
- for (const entry of feedCollections) merged.set(entry.slug, entry);
3102
- for (const entry of userCollections) merged.set(entry.slug, entry);
3103
- for (const entry of projectCollections) merged.set(entry.slug, entry);
3104
- return [...merged.values()].sort((left, right) => left.slug.localeCompare(right.slug));
3044
+ //#endregion
3045
+ //#region src/collection/server/store.ts
3046
+ /** The file store's stable order: lexicographic by record id (codepoint
3047
+ * compare — locale-independent). `listItems` returns readdir order, which
3048
+ * is filesystem-dependent; paging needs determinism. */
3049
+ function sortByRecordId(items, primaryKey) {
3050
+ return [...items].sort((left, right) => {
3051
+ const leftId = require_calendarGrid.fieldText(left[primaryKey]);
3052
+ const rightId = require_calendarGrid.fieldText(right[primaryKey]);
3053
+ if (leftId < rightId) return -1;
3054
+ return leftId > rightId ? 1 : 0;
3055
+ });
3105
3056
  }
3106
- /** Load one collection by slug. Returns null if the slug is invalid,
3107
- * no matching skill exists, or the schema is malformed. */
3108
- async function loadCollection(slug, opts = {}) {
3109
- const safeName = safeSlugName(slug);
3110
- if (safeName === null) return null;
3111
- const workspaceRoot = opts.workspaceRoot ?? getWorkspaceRoot();
3112
- const userDir = resolveUserDir(opts, workspaceRoot);
3113
- const projectCollection = await loadOneCollection(projectSkillsDir(workspaceRoot), safeName, "project", workspaceRoot);
3114
- if (projectCollection) return projectCollection;
3115
- const userCollection = userDir === null ? null : await loadOneCollection(userDir, safeName, "user", workspaceRoot);
3116
- if (userCollection) return userCollection;
3117
- return loadOneCollection(feedsRoot(workspaceRoot), safeName, "feed", workspaceRoot);
3057
+ /** True when the collection accepts UI/tool writes. A `dataSource`
3058
+ * collection is read-only: updates happen by editing/replacing the
3059
+ * data file itself. Every write entry point checks this BEFORE calling
3060
+ * `writeItem`/`deleteItem` — server-enforced, not just UI-hidden. */
3061
+ function collectionWritable(collection) {
3062
+ return !require_calendarGrid.isReadOnlySchema(collection.schema);
3118
3063
  }
3119
- function toSummary(collection) {
3064
+ /** The one-line refusal write paths surface (HTTP 405 / MCP error text). */
3065
+ function readOnlyRefusal(slug) {
3066
+ return `collection '${slug}' is read-only (backed by an external dataSource) — update the data file itself instead`;
3067
+ }
3068
+ /** A `dataSource` store over `file` (CSV row order; DuckDB-native query).
3069
+ * A schema whose `dataSourceFile` failed to resolve yields a read-only
3070
+ * EMPTY store rather than falling back to the (writable) file store — a
3071
+ * half-loaded read-only collection must never become writable. */
3072
+ function csvStoreFor(collection, opts) {
3073
+ const file = collection.dataSourceFile;
3074
+ const key = collection.schema.primaryKey;
3075
+ const listAll = () => file === void 0 ? Promise.resolve({
3076
+ items: [],
3077
+ truncated: false
3078
+ }) : csvList(file, key, opts.workspaceRoot);
3120
3079
  return {
3121
- slug: collection.slug,
3122
- title: collection.schema.title,
3123
- icon: collection.schema.icon,
3124
- source: collection.source,
3125
- ...collection.schema.dataSource !== void 0 ? { readonly: true } : {},
3126
- ...collection.appId !== void 0 ? { appId: collection.appId } : {}
3080
+ capabilities: {
3081
+ writable: false,
3082
+ nativeQuery: true,
3083
+ nativePaging: false
3084
+ },
3085
+ list: () => listAll().then((result) => result.items),
3086
+ page: (pageOpts = {}) => listAll().then((result) => pageFromFullRead(result.items, pageOpts, key, result.truncated)),
3087
+ read: (itemId) => file === void 0 ? Promise.resolve(null) : csvRead(file, key, itemId, opts.workspaceRoot),
3088
+ query: (query) => file === void 0 ? Promise.resolve([]) : csvRunQuery(file, key, query, opts.workspaceRoot),
3089
+ ...file === void 0 ? {} : { watch: async (onChange) => closerFor(await watchSingleFile(file, () => false, () => onChange({ kind: "collection" }))) }
3127
3090
  };
3128
3091
  }
3129
- function toDetail(collection) {
3092
+ /** The classic file store over `<dataDir>/<itemId>.json` records. */
3093
+ function fileStoreFor(collection, opts) {
3094
+ const key = collection.schema.primaryKey;
3095
+ const ioOpts = {
3096
+ ...opts,
3097
+ slug: opts.slug ?? collection.slug
3098
+ };
3130
3099
  return {
3131
- ...toSummary(collection),
3132
- schema: collection.schema
3100
+ capabilities: {
3101
+ writable: true,
3102
+ nativeQuery: false,
3103
+ nativePaging: false
3104
+ },
3105
+ list: () => listItems(collection.dataDir, opts),
3106
+ page: async (pageOpts = {}) => pageFromFullRead(sortByRecordId(await listItems(collection.dataDir, opts), key), pageOpts, key, false),
3107
+ read: (itemId) => readItem(collection.dataDir, itemId, opts),
3108
+ write: (itemId, item, writeOpts = {}) => writeItem(collection.dataDir, itemId, item, {
3109
+ ...ioOpts,
3110
+ refuseOverwrite: writeOpts.refuseOverwrite
3111
+ }),
3112
+ delete: (itemId) => deleteItem(collection.dataDir, itemId, ioOpts),
3113
+ watch: async (onChange) => closerFor(await watchDirectory(collection.dataDir, (name) => name.endsWith(".json") && !name.startsWith("."), (filename) => onChange(filename === null ? { kind: "collection" } : {
3114
+ kind: "item",
3115
+ itemId: filename.slice(0, -5)
3116
+ })))
3133
3117
  };
3134
3118
  }
3119
+ var storeFactories = /* @__PURE__ */ new Map([
3120
+ ["file", fileStoreFor],
3121
+ ["csv", csvStoreFor],
3122
+ ["sqlite", sqliteStoreFor],
3123
+ ["firestore", firestoreStoreFor]
3124
+ ]);
3125
+ /** Pick the store implementation for a discovered collection via the
3126
+ * factory registry. An unknown kind cannot normally reach here (the
3127
+ * schema's `StorageZ` union gates it), so the throw is a loud invariant
3128
+ * breach, not a user-facing path. */
3129
+ function storeFor(collection, opts = {}) {
3130
+ const kind = require_calendarGrid.storageKindFor(collection.schema);
3131
+ const factory = storeFactories.get(kind);
3132
+ if (!factory) throw new Error(`no store factory registered for storage kind '${kind}'`);
3133
+ return factory(collection, opts);
3134
+ }
3135
3135
  //#endregion
3136
3136
  Object.defineProperty(exports, "APP_MANIFEST_FILE", {
3137
3137
  enumerable: true,
@@ -3548,4 +3548,4 @@ Object.defineProperty(exports, "writeItem", {
3548
3548
  }
3549
3549
  });
3550
3550
 
3551
- //# sourceMappingURL=discovery-Cw5xWzZD.cjs.map
3551
+ //# sourceMappingURL=store-5_P_NsGa.cjs.map