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