@mulmoclaude/core 0.25.0 → 0.26.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 (82) hide show
  1. package/assets/helps/billing-clients-worklog.md +3 -2
  2. package/assets/helps/collection-skills.md +54 -1
  3. package/assets/helps/custom-view.md +37 -3
  4. package/assets/helps/error-recovery.md +30 -0
  5. package/assets/helps/todo-collection.md +2 -2
  6. package/dist/collection/core/fieldText.d.ts +11 -0
  7. package/dist/collection/core/project.d.ts +3 -0
  8. package/dist/collection/core/schema.d.ts +13 -1
  9. package/dist/collection/core/schemaZ.d.ts +16 -0
  10. package/dist/collection/index.cjs +18 -10
  11. package/dist/collection/index.cjs.map +1 -1
  12. package/dist/collection/index.d.ts +2 -0
  13. package/dist/collection/index.js +15 -11
  14. package/dist/collection/index.js.map +1 -1
  15. package/dist/collection/registry/server/index.cjs +2 -2
  16. package/dist/collection/registry/server/index.js +2 -2
  17. package/dist/collection/server/csvStore.d.ts +8 -1
  18. package/dist/collection/server/discoveredCollection.d.ts +4 -0
  19. package/dist/collection/server/discovery.d.ts +1 -0
  20. package/dist/collection/server/index.cjs +20 -17
  21. package/dist/collection/server/index.d.ts +1 -0
  22. package/dist/collection/server/index.js +4 -4
  23. package/dist/collection/server/queryRunner.d.ts +4 -0
  24. package/dist/collection/server/spawn.d.ts +2 -1
  25. package/dist/collection/server/sqliteStore.d.ts +14 -0
  26. package/dist/collection/server/store.d.ts +43 -8
  27. package/dist/collection/server/storePage.d.ts +32 -0
  28. package/dist/collection-watchers/index.cjs +167 -43
  29. package/dist/collection-watchers/index.cjs.map +1 -1
  30. package/dist/collection-watchers/index.d.ts +1 -1
  31. package/dist/collection-watchers/index.js +168 -45
  32. package/dist/collection-watchers/index.js.map +1 -1
  33. package/dist/collection-watchers/reconciler.d.ts +13 -11
  34. package/dist/collection-watchers/watcher.d.ts +4 -3
  35. package/dist/{discovery-3K_Wtkvn.js → discovery-QUi_7aNG.js} +882 -7
  36. package/dist/discovery-QUi_7aNG.js.map +1 -0
  37. package/dist/{discovery-BZ2ms1tE.cjs → discovery-cwtfyDxl.cjs} +994 -4
  38. package/dist/discovery-cwtfyDxl.cjs.map +1 -0
  39. package/dist/feeds/index.cjs +2 -2
  40. package/dist/feeds/index.js +2 -2
  41. package/dist/feeds/server/index.cjs +26 -14
  42. package/dist/feeds/server/index.cjs.map +1 -1
  43. package/dist/feeds/server/index.js +26 -14
  44. package/dist/feeds/server/index.js.map +1 -1
  45. package/dist/google/index.cjs +9 -11
  46. package/dist/google/index.cjs.map +1 -1
  47. package/dist/google/index.js +9 -11
  48. package/dist/google/index.js.map +1 -1
  49. package/dist/{ids-BR4U1dJD.cjs → ids-Dl9Ij63K.cjs} +14 -1
  50. package/dist/ids-Dl9Ij63K.cjs.map +1 -0
  51. package/dist/{ids-D4e10971.js → ids-P2T89Gou.js} +9 -2
  52. package/dist/ids-P2T89Gou.js.map +1 -0
  53. package/dist/{ingestTypes-BsGOjR2l.js → ingestTypes-C-IQB4nw.js} +2 -2
  54. package/dist/{ingestTypes-BsGOjR2l.js.map → ingestTypes-C-IQB4nw.js.map} +1 -1
  55. package/dist/{ingestTypes-CygUXsjf.cjs → ingestTypes-DZpAC3N_.cjs} +2 -2
  56. package/dist/{ingestTypes-CygUXsjf.cjs.map → ingestTypes-DZpAC3N_.cjs.map} +1 -1
  57. package/dist/project-BWI5w_BT.cjs +17 -0
  58. package/dist/project-BWI5w_BT.cjs.map +1 -0
  59. package/dist/project-bU98ycsy.js +12 -0
  60. package/dist/project-bU98ycsy.js.map +1 -0
  61. package/dist/{promptSafety-Boz5qOs-.js → promptSafety-B5KPkaNY.js} +32 -9
  62. package/dist/promptSafety-B5KPkaNY.js.map +1 -0
  63. package/dist/{promptSafety-Co4cYQ_Z.cjs → promptSafety-BOc1B3im.cjs} +43 -8
  64. package/dist/promptSafety-BOc1B3im.cjs.map +1 -0
  65. package/dist/remote-view/index.cjs +5 -3
  66. package/dist/remote-view/index.cjs.map +1 -1
  67. package/dist/remote-view/index.d.ts +3 -1
  68. package/dist/remote-view/index.js +5 -3
  69. package/dist/remote-view/index.js.map +1 -1
  70. package/dist/{server-DLRz-U7F.cjs → server-B7mtx4Cd.cjs} +302 -785
  71. package/dist/server-B7mtx4Cd.cjs.map +1 -0
  72. package/dist/{server-DZH7OjOD.js → server-CNLZZONa.js} +292 -696
  73. package/dist/server-CNLZZONa.js.map +1 -0
  74. package/package.json +1 -1
  75. package/dist/discovery-3K_Wtkvn.js.map +0 -1
  76. package/dist/discovery-BZ2ms1tE.cjs.map +0 -1
  77. package/dist/ids-BR4U1dJD.cjs.map +0 -1
  78. package/dist/ids-D4e10971.js.map +0 -1
  79. package/dist/promptSafety-Boz5qOs-.js.map +0 -1
  80. package/dist/promptSafety-Co4cYQ_Z.cjs.map +0 -1
  81. package/dist/server-DLRz-U7F.cjs.map +0 -1
  82. package/dist/server-DZH7OjOD.js.map +0 -1
@@ -1,9 +1,12 @@
1
- import { a as AGENT_INGEST_KIND, c as INGEST_KINDS, i as isSafeSlug, n as SAFE_SLUG_PATTERN, o as COMPUTED_TYPES, r as isSafeRecordId, s as FEED_SCHEDULES, t as SAFE_RECORD_ID_PATTERN } from "./ids-D4e10971.js";
1
+ import { a as AGENT_INGEST_KIND, c as INGEST_KINDS, d as isReadOnlySchema, f as storageKindFor, i as isSafeSlug, n as SAFE_SLUG_PATTERN, o as COMPUTED_TYPES, r as isSafeRecordId, s as FEED_SCHEDULES, t as SAFE_RECORD_ID_PATTERN } from "./ids-P2T89Gou.js";
2
+ import { t as projectRecordFields } from "./project-bU98ycsy.js";
2
3
  import { n as isSafeCustomViewI18nPath, r as isSafeCustomViewPath, t as isSafeActionTemplatePath } from "./templatePath-k_WNbL_Q.js";
3
4
  import path from "node:path";
4
5
  import { promises, realpathSync } from "node:fs";
5
- import { lstat, mkdir, open, readFile, readdir, stat, unlink } from "node:fs/promises";
6
- import { randomBytes } from "node:crypto";
6
+ import { lstat, mkdir, open, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
7
+ import { createHash, randomBytes } from "node:crypto";
8
+ import { tmpdir } from "node:os";
9
+ import iconv from "iconv-lite";
7
10
  import { z } from "zod";
8
11
  //#region src/collection/server/host.ts
9
12
  var current = null;
@@ -633,6 +636,843 @@ ${dataJson}
633
636
 
634
637
  ${templateText}`;
635
638
  }
639
+ //#endregion
640
+ //#region src/collection/core/queryZ.ts
641
+ /** Result-column aliases double as SQL identifiers and JSON keys — keep
642
+ * them to a conservative identifier charset so neither side needs
643
+ * escaping gymnastics. */
644
+ var SAFE_ALIAS_PATTERN = /^[A-Za-z_]\w{0,63}$/;
645
+ /** Hard ceiling on returned rows; `limit` clamps below it. A group-by on
646
+ * a near-unique column would otherwise return one row per source row —
647
+ * the exact materialization the aggregate path exists to avoid. */
648
+ var MAX_QUERY_ROWS = 1e4;
649
+ /** Default row cap when the query declares no `limit`. */
650
+ var DEFAULT_QUERY_ROWS = 1e3;
651
+ /** One aggregate column: `count` (rows; `column` optional to count
652
+ * non-null cells) or `sum`/`avg`/`min`/`max` over a named CSV column. */
653
+ var QueryAggregateZ = z.object({
654
+ op: z.enum([
655
+ "count",
656
+ "sum",
657
+ "avg",
658
+ "min",
659
+ "max"
660
+ ]),
661
+ column: z.string().min(1).optional()
662
+ }).refine((aggregate) => aggregate.op === "count" || aggregate.column !== void 0, {
663
+ message: "`column` is required for every aggregate op except `count`",
664
+ path: ["column"]
665
+ });
666
+ /** One filter condition. Same op vocabulary as the schema-level `where`
667
+ * (`core/where.ts`) so authors learn one set; values may be typed
668
+ * (number / boolean) since CSV columns are. `in` requires an array
669
+ * value, every other op a scalar. */
670
+ var QueryWhereZ = z.object({
671
+ field: z.string().min(1),
672
+ op: z.enum([
673
+ "eq",
674
+ "ne",
675
+ "in",
676
+ "gt",
677
+ "gte",
678
+ "lt",
679
+ "lte",
680
+ "contains"
681
+ ]),
682
+ value: z.union([
683
+ z.string(),
684
+ z.number(),
685
+ z.boolean(),
686
+ z.array(z.union([
687
+ z.string(),
688
+ z.number(),
689
+ z.boolean()
690
+ ])).min(1).max(100)
691
+ ])
692
+ }).refine((cond) => cond.op === "in" === Array.isArray(cond.value), {
693
+ message: "`in` requires an array value (the allowed set); every other op requires a scalar value",
694
+ path: ["value"]
695
+ });
696
+ var QueryOrderZ = z.object({
697
+ /** A `groupBy` column or an aggregate alias — membership enforced by
698
+ * the whole-query refine below. */
699
+ field: z.string().min(1),
700
+ dir: z.enum(["asc", "desc"]).optional()
701
+ });
702
+ /** The whole query. At least one of `groupBy` / `aggregates` must be
703
+ * present: bare `groupBy` is a DISTINCT listing, bare `aggregates` a
704
+ * whole-file scalar row, together a grouped aggregation. */
705
+ var CollectionQueryZ = z.object({
706
+ groupBy: z.array(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(),
707
+ aggregates: z.record(z.string().regex(SAFE_ALIAS_PATTERN, "aggregate aliases must be simple identifiers (letters/digits/underscore)"), QueryAggregateZ).optional(),
708
+ where: z.array(QueryWhereZ).max(16).optional(),
709
+ orderBy: z.array(QueryOrderZ).max(4).optional(),
710
+ limit: z.number().int().min(1).max(MAX_QUERY_ROWS).optional()
711
+ }).refine((query) => (query.groupBy?.length ?? 0) > 0 || Object.keys(query.aggregates ?? {}).length > 0, {
712
+ message: "declare at least one of `groupBy` (columns to bucket by) or `aggregates` (values to compute)",
713
+ path: ["groupBy"]
714
+ }).refine((query) => Object.keys(query.aggregates ?? {}).length <= 32, {
715
+ message: `\`aggregates\` supports at most 32 entries`,
716
+ path: ["aggregates"]
717
+ }).refine((query) => {
718
+ const groupLower = new Set((query.groupBy ?? []).map((column) => column.toLowerCase()));
719
+ const seen = /* @__PURE__ */ new Set();
720
+ return Object.keys(query.aggregates ?? {}).every((alias) => {
721
+ const lower = alias.toLowerCase();
722
+ if (groupLower.has(lower) || seen.has(lower)) return false;
723
+ seen.add(lower);
724
+ return true;
725
+ });
726
+ }, {
727
+ message: "aggregate aliases must be unique and must not collide with `groupBy` column names (case-insensitively — SQL identifiers ignore case)",
728
+ path: ["aggregates"]
729
+ }).refine((query) => {
730
+ const sortable = /* @__PURE__ */ new Set([...query.groupBy ?? [], ...Object.keys(query.aggregates ?? {})]);
731
+ return (query.orderBy ?? []).every((order) => sortable.has(order.field));
732
+ }, {
733
+ message: "every `orderBy.field` must be a `groupBy` column or an aggregate alias",
734
+ path: ["orderBy"]
735
+ });
736
+ //#endregion
737
+ //#region src/collection/server/csvQuery.ts
738
+ /** Double-quote a SQL identifier (CSV column name / result alias). */
739
+ function quoteIdent(name) {
740
+ return `"${name.replaceAll("\"", "\"\"")}"`;
741
+ }
742
+ /** Single-quote a SQL string literal (a `types={...}` struct key). */
743
+ function quoteLiteral(value) {
744
+ return `'${value.replaceAll("'", "''")}'`;
745
+ }
746
+ /** The `read_csv` argument list shared by every CSV query: the (prepared)
747
+ * path plus a `types` pin forcing the key column to VARCHAR — without it
748
+ * DuckDB's sniffer turns `001` into BIGINT 1, so leading zeros vanish
749
+ * and distinct keys collapse. */
750
+ function readCsvArgs(primaryKey) {
751
+ return `?, types={${quoteLiteral(primaryKey)}: 'VARCHAR'}`;
752
+ }
753
+ /** One aggregate's SQL expression. `sum`/`avg` TRY_CAST to DOUBLE so a
754
+ * column the sniffer kept as VARCHAR (mixed values) aggregates over its
755
+ * numeric cells instead of erroring; non-numeric cells become NULL and
756
+ * are skipped — standard BI tolerance. `min`/`max` stay native (they are
757
+ * meaningful on strings and dates too). */
758
+ function aggregateExpr(aggregate) {
759
+ const { op, column } = aggregate;
760
+ if (op === "count") return column === void 0 ? "count(*)" : `count(${quoteIdent(column)})`;
761
+ if (op === "sum" || op === "avg") return `${op}(TRY_CAST(${quoteIdent(column ?? "")} AS DOUBLE))`;
762
+ return `${op}(${quoteIdent(column ?? "")})`;
763
+ }
764
+ /** One where condition → SQL fragment + its bound parameters. String
765
+ * equality compares against `CAST(col AS VARCHAR)` so a sniffer-typed
766
+ * column still matches its textual value; numeric/boolean values compare
767
+ * natively (DuckDB coerces the column side). */
768
+ function whereFragment(cond) {
769
+ const column = quoteIdent(cond.field);
770
+ const asText = `CAST(${column} AS VARCHAR)`;
771
+ if (cond.op === "in") {
772
+ const values = cond.value;
773
+ return {
774
+ sql: `${values.every((value) => typeof value === "string") ? asText : column} IN (${values.map(() => "?").join(", ")})`,
775
+ params: values
776
+ };
777
+ }
778
+ if (cond.op === "contains") return {
779
+ sql: `contains(${asText}, ?)`,
780
+ params: [String(cond.value)]
781
+ };
782
+ const operator = {
783
+ eq: "=",
784
+ ne: "<>",
785
+ gt: ">",
786
+ gte: ">=",
787
+ lt: "<",
788
+ lte: "<="
789
+ }[cond.op];
790
+ return {
791
+ sql: `${typeof cond.value === "string" && (cond.op === "eq" || cond.op === "ne") ? asText : column} ${operator} ?`,
792
+ params: [cond.value]
793
+ };
794
+ }
795
+ /** Compile a validated query against `fromSql` (a table-function call
796
+ * whose FIRST placeholder is the source path — the executor binds it).
797
+ * Returns the SQL and the where-value parameters that follow the path.
798
+ * Callers MUST have run `CollectionQueryZ` first; this function trusts
799
+ * the shape (aliases already charset-checked, orderBy membership already
800
+ * enforced). */
801
+ function compileQuery(query, fromSql) {
802
+ const groupBy = query.groupBy ?? [];
803
+ const aggregates = Object.entries(query.aggregates ?? {});
804
+ const selectList = [...groupBy.map(quoteIdent), ...aggregates.map(([alias, aggregate]) => `${aggregateExpr(aggregate)} AS ${quoteIdent(alias)}`)];
805
+ const where = (query.where ?? []).map(whereFragment);
806
+ const clauses = [`SELECT ${selectList.join(", ")}`, `FROM ${fromSql}`];
807
+ if (where.length > 0) clauses.push(`WHERE ${where.map((fragment) => fragment.sql).join(" AND ")}`);
808
+ if (groupBy.length > 0) clauses.push(`GROUP BY ${groupBy.map(quoteIdent).join(", ")}`);
809
+ const orderBy = (query.orderBy ?? []).map((order) => quoteIdent(order.field) + (order.dir === "desc" ? " DESC" : " ASC"));
810
+ if (orderBy.length > 0) clauses.push(`ORDER BY ${orderBy.join(", ")}`);
811
+ clauses.push(`LIMIT ${query.limit ?? 1e3}`);
812
+ return {
813
+ sql: clauses.join(" "),
814
+ params: where.flatMap((fragment) => fragment.params)
815
+ };
816
+ }
817
+ /** Compile against a CSV file (the dataSource store's engine). */
818
+ function compileCsvQuery(query, primaryKey) {
819
+ return compileQuery(query, `read_csv(${readCsvArgs(primaryKey)})`);
820
+ }
821
+ /** Compile against a JSONL file of ENRICHED records — the file-backed
822
+ * collections' engine (see `jsonlQuery.ts`). No VARCHAR key pin needed:
823
+ * enriched record ids are already strings. `sample_size=-1` makes the
824
+ * schema inference scan EVERY line — with the default sample, a sparse
825
+ * optional/derived field first appearing past the sample would not be
826
+ * inferred as a column and the query would binder-error on it (Codex P2
827
+ * on #2165). The full scan costs nothing extra here: aggregation reads
828
+ * the whole file anyway. */
829
+ function compileJsonlQuery(query) {
830
+ return compileQuery(query, `read_json(?, format='newline_delimited', sample_size=-1)`);
831
+ }
832
+ //#endregion
833
+ //#region src/collection/server/csvStore.ts
834
+ /** `list()` row cap. Over-cap files are truncated with a warn — the v1
835
+ * contract is "browse + per-record views", not full-table analytics. */
836
+ var MAX_CSV_ROWS = 5e3;
837
+ /** Record ids minted from non-safe key values: `id0x` + utf-8 hex. Raw key
838
+ * values that themselves match this pattern are ALSO encoded, so the
839
+ * encoded namespace never collides with a raw value (injective mapping). */
840
+ var ENCODED_ID_PATTERN = /^id0x([0-9a-f]+)$/;
841
+ /** A CSV key value → the record id it's addressed by. Safe values pass
842
+ * through untouched; everything else (and anything shaped like an encoded
843
+ * id) becomes `id0x<hex>`. Pure + exported for unit tests. */
844
+ function encodeCsvRecordId(rawKey) {
845
+ if (safeRecordId(rawKey) === rawKey && !ENCODED_ID_PATTERN.test(rawKey)) return rawKey;
846
+ return `id0x${Buffer.from(rawKey, "utf-8").toString("hex")}`;
847
+ }
848
+ /** A record id → the CSV key value to look up. Inverse of
849
+ * `encodeCsvRecordId` for encoded ids; anything else is already the raw
850
+ * value. Pure + exported for unit tests. */
851
+ function decodeCsvRecordId(itemId) {
852
+ const match = ENCODED_ID_PATTERN.exec(itemId);
853
+ if (!match) return itemId;
854
+ return Buffer.from(match[1], "hex").toString("utf-8");
855
+ }
856
+ /** Normalize one DuckDB JS value into a JSON-safe record value: BigInt →
857
+ * number (string beyond the safe range), DATE/TIMESTAMP → ISO string
858
+ * (date-only when the clock is exactly UTC midnight, matching the `date`
859
+ * field contract), exotic DuckDB values → their string form. Pure +
860
+ * exported for unit tests. */
861
+ function normalizeCsvValue(value) {
862
+ if (typeof value === "bigint") return value <= BigInt(Number.MAX_SAFE_INTEGER) && value >= BigInt(-Number.MAX_SAFE_INTEGER) ? Number(value) : value.toString();
863
+ if (value instanceof Date) {
864
+ const iso = value.toISOString();
865
+ return iso.endsWith("T00:00:00.000Z") ? iso.slice(0, 10) : iso;
866
+ }
867
+ if (value !== null && typeof value === "object") return String(value);
868
+ return value;
869
+ }
870
+ /** One raw DuckDB row → a CollectionItem, or null when the key cell is
871
+ * missing/empty (the row can't be addressed). The primaryKey field is
872
+ * OVERWRITTEN with the (possibly encoded) record id so `item[primaryKey]`
873
+ * and the record's address never drift — same invariant the file store's
874
+ * write path enforces. Pure + exported for unit tests. */
875
+ function csvRowToItem(row, primaryKey) {
876
+ const normalized = Object.fromEntries(Object.entries(row).map(([key, value]) => [key, normalizeCsvValue(value)]));
877
+ const rawKey = normalized[primaryKey];
878
+ if (rawKey === null || rawKey === void 0 || String(rawKey) === "") return null;
879
+ return {
880
+ ...normalized,
881
+ [primaryKey]: encodeCsvRecordId(String(rawKey))
882
+ };
883
+ }
884
+ /** Dedupe by record id, LAST row wins (matches `csvRead`'s last-match
885
+ * pick). Returns the surviving items in first-seen order. Pure +
886
+ * exported for unit tests. */
887
+ function dedupeByRecordId(items, primaryKey) {
888
+ const byId = /* @__PURE__ */ new Map();
889
+ for (const item of items) byId.set(String(item[primaryKey]), item);
890
+ return {
891
+ items: [...byId.values()],
892
+ duplicates: items.length - byId.size
893
+ };
894
+ }
895
+ /** True when a thrown DuckDB error is the `types` pin naming a column the
896
+ * CSV doesn't have — the schema/file-mismatch case the caller downgrades
897
+ * to "empty collection + warn" instead of a 500. */
898
+ function isMissingKeyColumnError(err) {
899
+ return String(err).includes("do not exist in the CSV");
900
+ }
901
+ /** Bytes sniffed for UTF-8 validity. The trailing 3 bytes of the sample
902
+ * are dropped so a multibyte char split at the boundary can't produce a
903
+ * false negative on a valid file. */
904
+ var SNIFF_BYTES = 1024 * 1024;
905
+ function isValidUtf8(buf) {
906
+ try {
907
+ new TextDecoder("utf-8", { fatal: true }).decode(buf);
908
+ return true;
909
+ } catch {
910
+ return false;
911
+ }
912
+ }
913
+ /** Detect the (best-effort) encoding of a non-UTF-8 buffer. BOMs decide
914
+ * UTF-16; otherwise cp932 (the Shift_JIS superset — Excel-exported
915
+ * Japanese CSVs are the primary non-UTF-8 case this feature serves). */
916
+ function fallbackEncoding(buf) {
917
+ if (buf.length >= 2 && buf[0] === 255 && buf[1] === 254) return "utf-16le";
918
+ if (buf.length >= 2 && buf[0] === 254 && buf[1] === 255) return "utf-16be";
919
+ return "cp932";
920
+ }
921
+ function cacheDir() {
922
+ return path.join(tmpdir(), "mulmoclaude-csv-utf8");
923
+ }
924
+ /** Read only the first `bytes` of a file — the encoding sniff must not
925
+ * pull a multi-hundred-MB CSV into memory on the (common) UTF-8 path. */
926
+ async function readHead(absPath, bytes) {
927
+ const handle = await open(absPath, "r");
928
+ try {
929
+ const { size } = await handle.stat();
930
+ const buf = Buffer.alloc(Math.min(bytes, size));
931
+ await handle.read(buf, 0, buf.length, 0);
932
+ return buf;
933
+ } finally {
934
+ await handle.close();
935
+ }
936
+ }
937
+ /** Decode the whole file into a UTF-8 cache copy and return its path.
938
+ * Cache key = (path, mtime, size), so a replaced CSV re-decodes and an
939
+ * unchanged one never does. */
940
+ async function pathExists(target) {
941
+ try {
942
+ await stat(target);
943
+ return true;
944
+ } catch {
945
+ return false;
946
+ }
947
+ }
948
+ /** Best-effort removal of older decode-cache entries for the same source
949
+ * path — a frequently-replaced large CSV would otherwise accumulate one
950
+ * full copy per (mtime, size) forever. Runs AFTER the current copy is
951
+ * published; a concurrent reader holding an old fd is unaffected
952
+ * (unlink-while-open is safe on POSIX). */
953
+ async function evictSupersededCache(key, keepBasename) {
954
+ try {
955
+ const entries = await readdir(cacheDir());
956
+ await Promise.all(entries.filter((name) => name.startsWith(`${key}-`) && name !== keepBasename).map((name) => unlink(path.join(cacheDir(), name)).catch(() => void 0)));
957
+ } catch {}
958
+ }
959
+ /** Decode the whole file into a UTF-8 cache copy and return its path.
960
+ * Cache key = (path, mtime, size), so a replaced CSV re-decodes and an
961
+ * unchanged one never does; superseded copies are evicted. The cache
962
+ * lives in the SHARED OS tmpdir, so the dir is 0700 and files 0600 —
963
+ * decoded rows must not be readable by other local users. */
964
+ async function decodeToCache(absPath, info) {
965
+ const key = createHash("sha256").update(absPath).digest("hex").slice(0, 16);
966
+ const cached = path.join(cacheDir(), `${key}-${Math.trunc(info.mtimeMs)}-${info.size}.csv`);
967
+ if (!await pathExists(cached)) {
968
+ const whole = await readFile(absPath);
969
+ const encoding = fallbackEncoding(whole);
970
+ const text = iconv.decode(whole, encoding);
971
+ await mkdir(cacheDir(), {
972
+ recursive: true,
973
+ mode: 448
974
+ });
975
+ const tmp = `${cached}.${randomBytes(4).toString("hex")}.tmp`;
976
+ await writeFile(tmp, text, {
977
+ encoding: "utf-8",
978
+ mode: 384
979
+ });
980
+ await rename(tmp, cached);
981
+ log.info("collections", "decoded non-UTF-8 dataSource file to cache", {
982
+ path: absPath,
983
+ encoding
984
+ });
985
+ await evictSupersededCache(key, path.basename(cached));
986
+ }
987
+ return cached;
988
+ }
989
+ /** Re-validate the dataSource file at READ time, mirroring the JSON
990
+ * store's per-read defenses: realpath containment (a symlink swapped in
991
+ * after discovery must not walk out of the workspace) and an lstat
992
+ * regular-file check (a symlink leaf is refused outright, even one
993
+ * pointing inside the workspace — same rule as `isRegularFile` on
994
+ * record files). Returns the stat info, or null for "no readable file"
995
+ * (ENOENT / refused), which callers render as an empty collection. */
996
+ async function safeCsvStat(absPath, workspaceRoot) {
997
+ if (!isContainedInRoot(absPath, workspaceRoot)) {
998
+ log.warn("collections", "dataSource read refused: path escapes workspace", { path: absPath });
999
+ return null;
1000
+ }
1001
+ let info;
1002
+ try {
1003
+ info = await lstat(absPath);
1004
+ } catch (err) {
1005
+ if (err.code === "ENOENT") return null;
1006
+ throw err;
1007
+ }
1008
+ if (!info.isFile()) {
1009
+ log.warn("collections", "dataSource read refused: not a regular file (symlink?)", { path: absPath });
1010
+ return null;
1011
+ }
1012
+ return info;
1013
+ }
1014
+ /** Return a path DuckDB can read as UTF-8: the original file when it
1015
+ * already is UTF-8 (the cheap, common case — only the head is sniffed),
1016
+ * else a decoded cache copy (see `decodeToCache`). Returns null when
1017
+ * there is no readable file (missing, symlink, or containment-refused —
1018
+ * see `safeCsvStat`), which callers render as an empty collection. */
1019
+ async function ensureUtf8CsvPath(absPath, workspaceRoot) {
1020
+ const info = await safeCsvStat(absPath, workspaceRoot);
1021
+ if (info === null) return null;
1022
+ const head = await readHead(absPath, SNIFF_BYTES);
1023
+ const sample = head.length === SNIFF_BYTES ? head.subarray(0, SNIFF_BYTES - 3) : head;
1024
+ if (!(head.length >= 2 && (head[0] === 255 && head[1] === 254 || head[0] === 254 && head[1] === 255)) && isValidUtf8(sample)) return absPath;
1025
+ return decodeToCache(absPath, info);
1026
+ }
1027
+ var instancePromise = null;
1028
+ /** Lazily create one shared in-memory DuckDB instance. The dynamic import
1029
+ * keeps the native module OUT of core's load path — a platform where the
1030
+ * prebuilt binding is missing degrades to a per-query error on dataSource
1031
+ * collections only, never a broken core. A failed init is retried on the
1032
+ * next call (the promise is reset). */
1033
+ async function duckDbInstance() {
1034
+ if (instancePromise === null) instancePromise = import("@duckdb/node-api").then((mod) => mod.DuckDBInstance.create(":memory:"));
1035
+ try {
1036
+ return await instancePromise;
1037
+ } catch (err) {
1038
+ instancePromise = null;
1039
+ throw new Error(`DuckDB is unavailable on this host (@duckdb/node-api failed to load: ${String(err)}) — dataSource collections cannot be read`);
1040
+ }
1041
+ }
1042
+ async function queryCsv(sql, params) {
1043
+ const connection = await (await duckDbInstance()).connect();
1044
+ try {
1045
+ return (await connection.runAndReadAll(sql, params)).getRowObjectsJS();
1046
+ } finally {
1047
+ connection.disconnectSync();
1048
+ }
1049
+ }
1050
+ async function csvList(absPath, primaryKey, workspaceRoot) {
1051
+ const utf8Path = await ensureUtf8CsvPath(absPath, workspaceRoot ?? getWorkspaceRoot());
1052
+ if (utf8Path === null) return {
1053
+ items: [],
1054
+ truncated: false
1055
+ };
1056
+ let rows;
1057
+ try {
1058
+ rows = await queryCsv(`SELECT * FROM read_csv(${readCsvArgs(primaryKey)}) LIMIT 5001`, [utf8Path]);
1059
+ } catch (err) {
1060
+ if (!isMissingKeyColumnError(err)) throw err;
1061
+ log.warn("collections", "dataSource CSV has no primaryKey column — every row is skipped", {
1062
+ path: absPath,
1063
+ primaryKey
1064
+ });
1065
+ return {
1066
+ items: [],
1067
+ truncated: false
1068
+ };
1069
+ }
1070
+ const truncated = rows.length > MAX_CSV_ROWS;
1071
+ if (truncated) {
1072
+ log.warn("collections", "dataSource CSV truncated to row cap", {
1073
+ path: absPath,
1074
+ cap: MAX_CSV_ROWS
1075
+ });
1076
+ rows.length = MAX_CSV_ROWS;
1077
+ }
1078
+ const items = rows.map((row) => csvRowToItem(row, primaryKey)).filter((item) => item !== null);
1079
+ const skipped = rows.length - items.length;
1080
+ if (skipped > 0) log.warn("collections", "dataSource CSV rows skipped (empty key cell)", {
1081
+ path: absPath,
1082
+ skipped
1083
+ });
1084
+ const deduped = dedupeByRecordId(items, primaryKey);
1085
+ if (deduped.duplicates > 0) log.warn("collections", "dataSource CSV has duplicate key values (last row wins)", {
1086
+ path: absPath,
1087
+ duplicates: deduped.duplicates
1088
+ });
1089
+ return {
1090
+ items: deduped.items,
1091
+ truncated
1092
+ };
1093
+ }
1094
+ /** The scan-order ordinal column the last-match read adds. Underscore
1095
+ * prefix keeps it out of any plausible CSV header namespace; it is
1096
+ * stripped from the returned record either way. */
1097
+ var ROW_ORDINAL = "__mc_row";
1098
+ /** One record by id. The comparison value rides as a prepared-statement
1099
+ * parameter, and the LAST matching row is selected IN DuckDB (scan-order
1100
+ * ordinal + LIMIT 1) — a CSV with thousands of duplicate keys must not
1101
+ * materialize them all for one detail read. Consistent with csvList's
1102
+ * last-wins dedupe. */
1103
+ async function csvRead(absPath, primaryKey, itemId, workspaceRoot) {
1104
+ const utf8Path = await ensureUtf8CsvPath(absPath, workspaceRoot ?? getWorkspaceRoot());
1105
+ if (utf8Path === null) return null;
1106
+ const rawKey = decodeCsvRecordId(itemId);
1107
+ 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);
1108
+ if (last === void 0) return null;
1109
+ const { [ROW_ORDINAL]: __ordinal, ...record } = last;
1110
+ return csvRowToItem(record, primaryKey);
1111
+ }
1112
+ /** Run a validated aggregation query (the structured DSL — see
1113
+ * `core/queryZ.ts`) over the WHOLE file: no row cap on the scan (a
1114
+ * capped aggregate would be a wrong number), only the result-row LIMIT
1115
+ * the compiler emits. Values are normalized like list/read rows so a
1116
+ * chart consumer gets plain JSON scalars. */
1117
+ async function csvRunQuery(absPath, primaryKey, query, workspaceRoot) {
1118
+ const utf8Path = await ensureUtf8CsvPath(absPath, workspaceRoot ?? getWorkspaceRoot());
1119
+ if (utf8Path === null) return [];
1120
+ const { sql, params } = compileCsvQuery(query, primaryKey);
1121
+ return (await queryCsv(sql, [utf8Path, ...params])).map((row) => Object.fromEntries(Object.entries(row).map(([key, value]) => [key, normalizeCsvValue(value)])));
1122
+ }
1123
+ //#endregion
1124
+ //#region src/collection/server/storePage.ts
1125
+ /** Project `fields` (+ the primary key, always) out of each record. Thin
1126
+ * server-typed alias over the shared isomorphic `projectRecordFields`
1127
+ * (../core/project.ts) — kept as the store layer's exported name. */
1128
+ function projectItemFields(items, fields, primaryKey) {
1129
+ return projectRecordFields(items, fields, primaryKey);
1130
+ }
1131
+ /** Slice + project an already-ordered full read into a `ListPage` — the
1132
+ * shared emulation for stores without native paging. Pure, exported for
1133
+ * tests. `limit: 0` is a valid "count only" page. */
1134
+ function pageFromFullRead(items, opts, primaryKey, truncated) {
1135
+ const offset = Math.max(0, opts.offset ?? 0);
1136
+ const end = opts.limit === void 0 ? void 0 : offset + Math.max(0, opts.limit);
1137
+ return {
1138
+ items: projectItemFields(items.slice(offset, end), opts.fields, primaryKey),
1139
+ total: items.length,
1140
+ truncated
1141
+ };
1142
+ }
1143
+ //#endregion
1144
+ //#region src/collection/server/sqliteStore.ts
1145
+ var sqliteModule = null;
1146
+ /** Lazy-load node:sqlite once. A runtime without it (Node < 22.5) throws a
1147
+ * clearly-worded error the caller surfaces — never a bare MODULE_NOT_FOUND. */
1148
+ function loadSqlite() {
1149
+ sqliteModule ??= import("node:sqlite").then((mod) => mod, (err) => {
1150
+ sqliteModule = null;
1151
+ throw new Error(`sqlite storage needs the node:sqlite module (Node.js >= 22.5) — this runtime cannot load it: ${String(err)}`);
1152
+ });
1153
+ return sqliteModule;
1154
+ }
1155
+ /** The db file's on-disk state. A symlink or non-regular file is refused
1156
+ * (file-disclosure defense, same rule as io.ts record files); ENOENT is
1157
+ * just "no records yet". Any OTHER lstat failure (EACCES, EIO, …) is
1158
+ * rethrown so reads surface a real filesystem problem instead of
1159
+ * silently reporting an empty collection. */
1160
+ async function dbFileState(absPath) {
1161
+ try {
1162
+ return (await lstat(absPath)).isFile() ? "file" : "refused";
1163
+ } catch (err) {
1164
+ if (err.code === "ENOENT") return "missing";
1165
+ throw err;
1166
+ }
1167
+ }
1168
+ var CREATE_TABLE = "CREATE TABLE IF NOT EXISTS records (id TEXT PRIMARY KEY, record TEXT NOT NULL)";
1169
+ /** Open the database for one operation, classifying the two unavailable
1170
+ * states so callers can map them honestly (`refused` ⇒ path-escape,
1171
+ * `missing` ⇒ empty / not-found — conflating them would misreport a
1172
+ * containment escape as "item not found"). The containment pre-check runs
1173
+ * BEFORE mkdir even when the file is missing — `isContainedInRoot`
1174
+ * resolves through the closest existing ancestor, so a symlinked-away
1175
+ * parent can never make the recursive mkdir create directories outside
1176
+ * the workspace (same pre/post belt-and-suspenders as io.ts writes). */
1177
+ async function openDb(absPath, workspaceRoot, mode) {
1178
+ const state = await dbFileState(absPath);
1179
+ if (state === "refused") {
1180
+ log.warn("collections", "sqlite database refused: not a regular file", { path: absPath });
1181
+ return { kind: "refused" };
1182
+ }
1183
+ if (!isContainedInRoot(path.dirname(absPath), workspaceRoot)) {
1184
+ log.warn("collections", "sqlite refused: database dir escapes workspace via symlink", { path: absPath });
1185
+ return { kind: "refused" };
1186
+ }
1187
+ if (mode === "read" && state === "missing") return { kind: "missing" };
1188
+ if (mode === "write") {
1189
+ await mkdir(path.dirname(absPath), { recursive: true });
1190
+ if (!isContainedInRoot(path.dirname(absPath), workspaceRoot)) {
1191
+ log.warn("collections", "sqlite write refused: database dir escapes workspace via symlink (post-mkdir)", { path: absPath });
1192
+ return { kind: "refused" };
1193
+ }
1194
+ }
1195
+ const { DatabaseSync } = await loadSqlite();
1196
+ const database = new DatabaseSync(absPath);
1197
+ database.exec("PRAGMA busy_timeout = 5000");
1198
+ database.exec(CREATE_TABLE);
1199
+ return {
1200
+ kind: "ok",
1201
+ database
1202
+ };
1203
+ }
1204
+ /** Run `operation` against the database and always close it; unavailable
1205
+ * states resolve through `onUnavailable` so each caller maps `missing`
1206
+ * vs `refused` to its own result kind. */
1207
+ async function withDb(absPath, workspaceRoot, mode, onUnavailable, operation) {
1208
+ const handle = await openDb(absPath, workspaceRoot, mode);
1209
+ if (handle.kind !== "ok") return onUnavailable(handle.kind);
1210
+ try {
1211
+ return await operation(handle.database);
1212
+ } finally {
1213
+ handle.database.close();
1214
+ }
1215
+ }
1216
+ /** node:sqlite throws ERR_SQLITE_ERROR with the SQLite extended result
1217
+ * code on `errcode`. Our INSERT's duplicate-id failure is
1218
+ * SQLITE_CONSTRAINT_PRIMARYKEY (1555); SQLITE_CONSTRAINT_UNIQUE (2067)
1219
+ * covers a plain UNIQUE index. Checked structurally (message text kept
1220
+ * only as a fallback for runtimes that don't expose `errcode`). */
1221
+ function isUniqueConstraintError(err) {
1222
+ const { errcode } = err;
1223
+ if (errcode !== void 0) return errcode === 1555 || errcode === 2067;
1224
+ return String(err).includes("UNIQUE constraint");
1225
+ }
1226
+ function parseRow(raw) {
1227
+ if (typeof raw !== "string") return null;
1228
+ try {
1229
+ const parsed = JSON.parse(raw);
1230
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
1231
+ } catch {
1232
+ return null;
1233
+ }
1234
+ }
1235
+ function rowsToItems(rows) {
1236
+ return rows.map((row) => parseRow(row.record)).filter((item) => item !== null);
1237
+ }
1238
+ async function sqliteList(absPath, workspaceRoot) {
1239
+ return withDb(absPath, workspaceRoot, "read", () => [], (database) => rowsToItems(database.prepare("SELECT record FROM records ORDER BY id").all()));
1240
+ }
1241
+ async function sqlitePage(absPath, primaryKey, opts, workspaceRoot) {
1242
+ const emptyPage = {
1243
+ items: [],
1244
+ total: 0,
1245
+ truncated: false
1246
+ };
1247
+ return withDb(absPath, workspaceRoot, "read", () => emptyPage, (database) => {
1248
+ const total = Number(database.prepare("SELECT COUNT(*) AS n FROM records").get().n);
1249
+ const offset = Math.max(0, opts.offset ?? 0);
1250
+ const limit = opts.limit === void 0 ? -1 : Math.max(0, opts.limit);
1251
+ return {
1252
+ items: projectItemFields(rowsToItems(database.prepare("SELECT record FROM records ORDER BY id LIMIT ? OFFSET ?").all(limit, offset)), opts.fields, primaryKey),
1253
+ total,
1254
+ truncated: false
1255
+ };
1256
+ });
1257
+ }
1258
+ async function sqliteRead(absPath, itemId, workspaceRoot) {
1259
+ const safeId = safeRecordId(itemId);
1260
+ if (safeId === null) return null;
1261
+ return withDb(absPath, workspaceRoot, "read", () => null, (database) => {
1262
+ const row = database.prepare("SELECT record FROM records WHERE id = ?").get(safeId);
1263
+ return row === void 0 ? null : parseRow(row.record);
1264
+ });
1265
+ }
1266
+ async function sqliteWrite(absPath, itemId, item, opts) {
1267
+ const safeId = safeRecordId(itemId);
1268
+ if (safeId === null) return {
1269
+ kind: "invalid-id",
1270
+ itemId
1271
+ };
1272
+ const outcome = await withDb(absPath, opts.workspaceRoot, "write", () => ({
1273
+ kind: "path-escape",
1274
+ itemId: safeId
1275
+ }), (database) => {
1276
+ const payload = JSON.stringify(item);
1277
+ if (opts.refuseOverwrite) try {
1278
+ database.prepare("INSERT INTO records (id, record) VALUES (?, ?)").run(safeId, payload);
1279
+ } catch (err) {
1280
+ if (isUniqueConstraintError(err)) return {
1281
+ kind: "conflict",
1282
+ itemId: safeId
1283
+ };
1284
+ throw err;
1285
+ }
1286
+ else database.prepare("INSERT INTO records (id, record) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET record = excluded.record").run(safeId, payload);
1287
+ return {
1288
+ kind: "ok",
1289
+ itemId: safeId,
1290
+ item
1291
+ };
1292
+ });
1293
+ if (outcome.kind === "ok" && opts.slug) publishCollectionChange({
1294
+ slug: opts.slug,
1295
+ ids: [safeId],
1296
+ op: "upsert"
1297
+ });
1298
+ return outcome;
1299
+ }
1300
+ async function sqliteDelete(absPath, itemId, opts) {
1301
+ const safeId = safeRecordId(itemId);
1302
+ if (safeId === null) return {
1303
+ kind: "invalid-id",
1304
+ itemId
1305
+ };
1306
+ const outcome = await withDb(absPath, opts.workspaceRoot, "read", (reason) => reason === "refused" ? {
1307
+ kind: "path-escape",
1308
+ itemId: safeId
1309
+ } : {
1310
+ kind: "not-found",
1311
+ itemId: safeId
1312
+ }, (database) => {
1313
+ const { changes } = database.prepare("DELETE FROM records WHERE id = ?").run(safeId);
1314
+ return Number(changes) === 0 ? {
1315
+ kind: "not-found",
1316
+ itemId: safeId
1317
+ } : {
1318
+ kind: "ok",
1319
+ itemId: safeId
1320
+ };
1321
+ });
1322
+ if (outcome.kind === "ok" && opts.slug) publishCollectionChange({
1323
+ slug: opts.slug,
1324
+ ids: [safeId],
1325
+ op: "delete"
1326
+ });
1327
+ return outcome;
1328
+ }
1329
+ /** Best-effort full WAL checkpoint so the MAIN db file alone is a
1330
+ * complete snapshot (committed pages in `<db>-wal` are folded in and the
1331
+ * WAL truncated). Used by `deleteCollection` before archiving. Returns
1332
+ * false on any failure (runtime without node:sqlite, locked db, missing
1333
+ * file) — the caller then archives the sidecar files alongside the db so
1334
+ * no committed data is lost either way. */
1335
+ async function checkpointSqliteDatabase(absPath) {
1336
+ try {
1337
+ const { DatabaseSync } = await loadSqlite();
1338
+ const database = new DatabaseSync(absPath);
1339
+ try {
1340
+ database.exec("PRAGMA wal_checkpoint(TRUNCATE)");
1341
+ } finally {
1342
+ database.close();
1343
+ }
1344
+ return true;
1345
+ } catch {
1346
+ return false;
1347
+ }
1348
+ }
1349
+ /** A `storage: sqlite` store over `collection.storageFile`. A schema whose
1350
+ * `storageFile` failed to resolve yields a read-only EMPTY store rather
1351
+ * than a writable one — same fail-closed rule as the CSV store. */
1352
+ function sqliteStoreFor(collection, opts) {
1353
+ const file = collection.storageFile;
1354
+ const key = collection.schema.primaryKey;
1355
+ const slug = opts.slug ?? collection.slug;
1356
+ const root = () => opts.workspaceRoot ?? getWorkspaceRoot();
1357
+ if (file === void 0) return {
1358
+ capabilities: {
1359
+ writable: false,
1360
+ nativeQuery: false,
1361
+ nativePaging: false
1362
+ },
1363
+ list: () => Promise.resolve([]),
1364
+ page: () => Promise.resolve({
1365
+ items: [],
1366
+ total: 0,
1367
+ truncated: false
1368
+ }),
1369
+ read: () => Promise.resolve(null)
1370
+ };
1371
+ return {
1372
+ capabilities: {
1373
+ writable: true,
1374
+ nativeQuery: false,
1375
+ nativePaging: true
1376
+ },
1377
+ list: () => sqliteList(file, root()),
1378
+ page: (pageOpts = {}) => sqlitePage(file, key, pageOpts, root()),
1379
+ read: (itemId) => sqliteRead(file, itemId, root()),
1380
+ write: (itemId, item, writeOpts = {}) => sqliteWrite(file, itemId, item, {
1381
+ workspaceRoot: root(),
1382
+ slug,
1383
+ refuseOverwrite: writeOpts.refuseOverwrite
1384
+ }),
1385
+ delete: (itemId) => sqliteDelete(file, itemId, {
1386
+ workspaceRoot: root(),
1387
+ slug
1388
+ })
1389
+ };
1390
+ }
1391
+ //#endregion
1392
+ //#region src/collection/server/store.ts
1393
+ /** The file store's stable order: lexicographic by record id (codepoint
1394
+ * compare — locale-independent). `listItems` returns readdir order, which
1395
+ * is filesystem-dependent; paging needs determinism. */
1396
+ function sortByRecordId(items, primaryKey) {
1397
+ return [...items].sort((left, right) => {
1398
+ const leftId = String(left[primaryKey] ?? "");
1399
+ const rightId = String(right[primaryKey] ?? "");
1400
+ if (leftId < rightId) return -1;
1401
+ return leftId > rightId ? 1 : 0;
1402
+ });
1403
+ }
1404
+ /** True when the collection accepts UI/tool writes. A `dataSource`
1405
+ * collection is read-only: updates happen by editing/replacing the
1406
+ * data file itself. Every write entry point checks this BEFORE calling
1407
+ * `writeItem`/`deleteItem` — server-enforced, not just UI-hidden. */
1408
+ function collectionWritable(collection) {
1409
+ return !isReadOnlySchema(collection.schema);
1410
+ }
1411
+ /** The one-line refusal write paths surface (HTTP 405 / MCP error text). */
1412
+ function readOnlyRefusal(slug) {
1413
+ return `collection '${slug}' is read-only (backed by an external dataSource) — update the data file itself instead`;
1414
+ }
1415
+ /** A `dataSource` store over `file` (CSV row order; DuckDB-native query).
1416
+ * A schema whose `dataSourceFile` failed to resolve yields a read-only
1417
+ * EMPTY store rather than falling back to the (writable) file store — a
1418
+ * half-loaded read-only collection must never become writable. */
1419
+ function csvStoreFor(collection, opts) {
1420
+ const file = collection.dataSourceFile;
1421
+ const key = collection.schema.primaryKey;
1422
+ const listAll = () => file === void 0 ? Promise.resolve({
1423
+ items: [],
1424
+ truncated: false
1425
+ }) : csvList(file, key, opts.workspaceRoot);
1426
+ return {
1427
+ capabilities: {
1428
+ writable: false,
1429
+ nativeQuery: true,
1430
+ nativePaging: false
1431
+ },
1432
+ list: () => listAll().then((result) => result.items),
1433
+ page: (pageOpts = {}) => listAll().then((result) => pageFromFullRead(result.items, pageOpts, key, result.truncated)),
1434
+ read: (itemId) => file === void 0 ? Promise.resolve(null) : csvRead(file, key, itemId, opts.workspaceRoot),
1435
+ query: (query) => file === void 0 ? Promise.resolve([]) : csvRunQuery(file, key, query, opts.workspaceRoot)
1436
+ };
1437
+ }
1438
+ /** The classic file store over `<dataDir>/<itemId>.json` records. */
1439
+ function fileStoreFor(collection, opts) {
1440
+ const key = collection.schema.primaryKey;
1441
+ const ioOpts = {
1442
+ ...opts,
1443
+ slug: opts.slug ?? collection.slug
1444
+ };
1445
+ return {
1446
+ capabilities: {
1447
+ writable: true,
1448
+ nativeQuery: false,
1449
+ nativePaging: false
1450
+ },
1451
+ list: () => listItems(collection.dataDir, opts),
1452
+ page: async (pageOpts = {}) => pageFromFullRead(sortByRecordId(await listItems(collection.dataDir, opts), key), pageOpts, key, false),
1453
+ read: (itemId) => readItem(collection.dataDir, itemId, opts),
1454
+ write: (itemId, item, writeOpts = {}) => writeItem(collection.dataDir, itemId, item, {
1455
+ ...ioOpts,
1456
+ refuseOverwrite: writeOpts.refuseOverwrite
1457
+ }),
1458
+ delete: (itemId) => deleteItem(collection.dataDir, itemId, ioOpts)
1459
+ };
1460
+ }
1461
+ var storeFactories = /* @__PURE__ */ new Map([
1462
+ ["file", fileStoreFor],
1463
+ ["csv", csvStoreFor],
1464
+ ["sqlite", sqliteStoreFor]
1465
+ ]);
1466
+ /** Pick the store implementation for a discovered collection via the
1467
+ * factory registry. An unknown kind cannot normally reach here (the
1468
+ * schema's `StorageZ` union gates it), so the throw is a loud invariant
1469
+ * breach, not a user-facing path. */
1470
+ function storeFor(collection, opts = {}) {
1471
+ const kind = storageKindFor(collection.schema);
1472
+ const factory = storeFactories.get(kind);
1473
+ if (!factory) throw new Error(`no store factory registered for storage kind '${kind}'`);
1474
+ return factory(collection, opts);
1475
+ }
636
1476
  /** The param name a `set` value references, or null when the value is a
637
1477
  * literal (non-strings can never be references). A bare/empty prefix
638
1478
  * (`"$params."`) returns the empty string — the schema refine rejects
@@ -1208,11 +2048,24 @@ var DataSourceZ = z.object({
1208
2048
  type: z.literal("csv"),
1209
2049
  path: z.string().min(1)
1210
2050
  });
2051
+ /** Alternative WRITABLE storage backend for a collection's records —
2052
+ * unlike `dataSource` (external read-only file), a `storage` collection
2053
+ * behaves like a normal writable collection; only where the rows live
2054
+ * changes. v1: `sqlite` — records in a single SQLite database file
2055
+ * (`node:sqlite`, one JSON record per row keyed by the primaryKey).
2056
+ * `path` is workspace-relative and containment-checked exactly like
2057
+ * `dataPath`. The store factory registry (`server/store.ts`) picks the
2058
+ * implementation by `type` (plans/refactor-storage-virtualization.md). */
2059
+ var StorageZ = z.object({
2060
+ type: z.literal("sqlite"),
2061
+ path: z.string().min(1)
2062
+ });
1211
2063
  var BareCollectionSchemaZ = z.object({
1212
2064
  title: z.string().min(1),
1213
2065
  icon: z.string().min(1),
1214
2066
  dataPath: z.string().min(1).optional(),
1215
2067
  dataSource: DataSourceZ.optional(),
2068
+ storage: StorageZ.optional(),
1216
2069
  primaryKey: z.string().min(1),
1217
2070
  singleton: z.string().trim().min(1).optional(),
1218
2071
  fields: z.record(z.string(), FieldSpecZ),
@@ -1233,8 +2086,12 @@ var BareCollectionSchemaZ = z.object({
1233
2086
  ingest: IngestZ.optional(),
1234
2087
  googleCalendar: GoogleCalendarSyncZ.optional(),
1235
2088
  dynamicIcon: DynamicIconSpecZ.optional()
1236
- }).refine((schema) => schema.dataPath !== void 0 !== (schema.dataSource !== void 0), {
1237
- message: "declare exactly one of `dataPath` (native JSON records) or `dataSource` (external read-only data file), never both or neither",
2089
+ }).refine((schema) => [
2090
+ schema.dataPath,
2091
+ schema.dataSource,
2092
+ schema.storage
2093
+ ].filter((declared) => declared !== void 0).length === 1, {
2094
+ message: "declare exactly one of `dataPath` (native JSON records), `dataSource` (external read-only data file), or `storage` (alternative writable backend)",
1238
2095
  path: ["dataPath"]
1239
2096
  }).refine((schema) => schema.dataSource === void 0 || schema.singleton === void 0 && schema.ingest === void 0 && schema.spawn === void 0 && schema.googleCalendar === void 0, {
1240
2097
  message: "a `dataSource` collection is read-only — it cannot declare `singleton`, `ingest`, `spawn`, or `googleCalendar` (all of them write records)",
@@ -1485,6 +2342,23 @@ function acceptParsedSchema(schema, opts) {
1485
2342
  dataSourceFile
1486
2343
  };
1487
2344
  }
2345
+ if (schema.storage !== void 0) {
2346
+ const storageFile = resolveDataDir(schema.storage.path, opts.workspaceRoot);
2347
+ if (storageFile === null) return {
2348
+ ok: false,
2349
+ reason: `storage.path '${schema.storage.path}' escapes the workspace`
2350
+ };
2351
+ const dataDir = resolveDataDir(conventionalDataPath(opts.slug), opts.workspaceRoot);
2352
+ if (dataDir === null) return {
2353
+ ok: false,
2354
+ reason: `slug '${opts.slug}' yields no workspace-contained data dir`
2355
+ };
2356
+ return {
2357
+ ok: true,
2358
+ dataDir,
2359
+ storageFile
2360
+ };
2361
+ }
1488
2362
  const dataDir = resolveDataDir(schema.dataPath ?? "", opts.workspaceRoot);
1489
2363
  if (dataDir === null) return {
1490
2364
  ok: false,
@@ -1549,6 +2423,7 @@ async function loadOneCollection(skillsRoot, slug, source, workspaceRoot) {
1549
2423
  schema,
1550
2424
  dataDir: acceptance.dataDir,
1551
2425
  ...acceptance.dataSourceFile !== void 0 ? { dataSourceFile: acceptance.dataSourceFile } : {},
2426
+ ...acceptance.storageFile !== void 0 ? { storageFile: acceptance.storageFile } : {},
1552
2427
  skillDir: path.join(skillsRoot, safeName)
1553
2428
  };
1554
2429
  }
@@ -1631,6 +2506,6 @@ function toDetail(collection) {
1631
2506
  };
1632
2507
  }
1633
2508
  //#endregion
1634
- export { archiveDir as A, isContainedInRoot as C, resolveTemplatePath as D, resolveDataDir as E, log as F, publishCollectionChange as I, setCollectionChangePublisher as L, configureCollectionHost as M, getWorkspaceRoot as N, safeRecordId as O, isPresetSlug as P, skillsStagingDir as R, SCHEMA_FILE as S, itemFilePath as T, readItem as _, toSummary as a, writeItem as b, buildActionSeedPrompt as c, generateItemId as d, isRegularFile as f, readCustomViewI18n as g, readCustomViewHtml as h, toDetail as i, collectionsRegistriesConfigPath as j, safeSlugName as k, buildCollectionActionSeedPrompt as l, promptPathsFor as m, discoverCollections as n, CollectionSchemaZ as o, listItems as p, loadCollection as r, resolveMutateSet as s, acceptParsedSchema as t, deleteItem as u, readSkillTemplate as v, isContainedInWorkspace as w, writeFileAtomic as x, resolveCreateItemId as y };
2509
+ export { isPresetSlug as $, generateItemId as A, writeFileAtomic as B, compileJsonlQuery as C, buildActionSeedPrompt as D, MAX_QUERY_ROWS as E, readCustomViewI18n as F, resolveDataDir as G, isContainedInRoot as H, readItem as I, safeSlugName as J, resolveTemplatePath as K, readSkillTemplate as L, listItems as M, promptPathsFor as N, buildCollectionActionSeedPrompt as O, readCustomViewHtml as P, getWorkspaceRoot as Q, resolveCreateItemId as R, compileCsvQuery as S, DEFAULT_QUERY_ROWS as T, isContainedInWorkspace as U, SCHEMA_FILE as V, itemFilePath as W, collectionsRegistriesConfigPath as X, archiveDir as Y, configureCollectionHost as Z, decodeCsvRecordId as _, toSummary as a, normalizeCsvValue as b, collectionWritable as c, checkpointSqliteDatabase as d, log as et, pageFromFullRead as f, csvRowToItem as g, cacheDir as h, toDetail as i, isRegularFile as j, deleteItem as k, readOnlyRefusal as l, MAX_CSV_ROWS as m, discoverCollections as n, setCollectionChangePublisher as nt, CollectionSchemaZ as o, projectItemFields as p, safeRecordId as q, loadCollection as r, skillsStagingDir as rt, resolveMutateSet as s, acceptParsedSchema as t, publishCollectionChange as tt, storeFor as u, dedupeByRecordId as v, CollectionQueryZ as w, queryCsv as x, encodeCsvRecordId as y, writeItem as z };
1635
2510
 
1636
- //# sourceMappingURL=discovery-3K_Wtkvn.js.map
2511
+ //# sourceMappingURL=discovery-QUi_7aNG.js.map