@mulmoclaude/core 0.25.0 → 0.25.1

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 (73) hide show
  1. package/assets/helps/collection-skills.md +51 -0
  2. package/assets/helps/custom-view.md +37 -3
  3. package/assets/helps/error-recovery.md +30 -0
  4. package/dist/collection/core/project.d.ts +3 -0
  5. package/dist/collection/core/schema.d.ts +13 -1
  6. package/dist/collection/core/schemaZ.d.ts +16 -0
  7. package/dist/collection/index.cjs +4 -1
  8. package/dist/collection/index.cjs.map +1 -1
  9. package/dist/collection/index.d.ts +1 -0
  10. package/dist/collection/index.js +3 -2
  11. package/dist/collection/index.js.map +1 -1
  12. package/dist/collection/registry/server/index.cjs +2 -2
  13. package/dist/collection/registry/server/index.js +2 -2
  14. package/dist/collection/server/csvStore.d.ts +8 -1
  15. package/dist/collection/server/discoveredCollection.d.ts +4 -0
  16. package/dist/collection/server/discovery.d.ts +1 -0
  17. package/dist/collection/server/index.cjs +20 -17
  18. package/dist/collection/server/index.d.ts +1 -0
  19. package/dist/collection/server/index.js +4 -4
  20. package/dist/collection/server/queryRunner.d.ts +4 -0
  21. package/dist/collection/server/spawn.d.ts +2 -1
  22. package/dist/collection/server/sqliteStore.d.ts +14 -0
  23. package/dist/collection/server/store.d.ts +43 -8
  24. package/dist/collection/server/storePage.d.ts +32 -0
  25. package/dist/collection-watchers/index.cjs +124 -38
  26. package/dist/collection-watchers/index.cjs.map +1 -1
  27. package/dist/collection-watchers/index.d.ts +1 -1
  28. package/dist/collection-watchers/index.js +124 -39
  29. package/dist/collection-watchers/index.js.map +1 -1
  30. package/dist/collection-watchers/reconciler.d.ts +13 -11
  31. package/dist/collection-watchers/watcher.d.ts +4 -3
  32. package/dist/{discovery-3K_Wtkvn.js → discovery-QUi_7aNG.js} +882 -7
  33. package/dist/discovery-QUi_7aNG.js.map +1 -0
  34. package/dist/{discovery-BZ2ms1tE.cjs → discovery-cwtfyDxl.cjs} +994 -4
  35. package/dist/discovery-cwtfyDxl.cjs.map +1 -0
  36. package/dist/feeds/index.cjs +2 -2
  37. package/dist/feeds/index.js +2 -2
  38. package/dist/feeds/server/index.cjs +26 -14
  39. package/dist/feeds/server/index.cjs.map +1 -1
  40. package/dist/feeds/server/index.js +26 -14
  41. package/dist/feeds/server/index.js.map +1 -1
  42. package/dist/google/index.cjs +9 -11
  43. package/dist/google/index.cjs.map +1 -1
  44. package/dist/google/index.js +9 -11
  45. package/dist/google/index.js.map +1 -1
  46. package/dist/{ids-BR4U1dJD.cjs → ids-Dl9Ij63K.cjs} +14 -1
  47. package/dist/ids-Dl9Ij63K.cjs.map +1 -0
  48. package/dist/{ids-D4e10971.js → ids-P2T89Gou.js} +9 -2
  49. package/dist/ids-P2T89Gou.js.map +1 -0
  50. package/dist/{ingestTypes-BsGOjR2l.js → ingestTypes-C-IQB4nw.js} +2 -2
  51. package/dist/{ingestTypes-BsGOjR2l.js.map → ingestTypes-C-IQB4nw.js.map} +1 -1
  52. package/dist/{ingestTypes-CygUXsjf.cjs → ingestTypes-DZpAC3N_.cjs} +2 -2
  53. package/dist/{ingestTypes-CygUXsjf.cjs.map → ingestTypes-DZpAC3N_.cjs.map} +1 -1
  54. package/dist/project-BWI5w_BT.cjs +17 -0
  55. package/dist/project-BWI5w_BT.cjs.map +1 -0
  56. package/dist/project-bU98ycsy.js +12 -0
  57. package/dist/project-bU98ycsy.js.map +1 -0
  58. package/dist/remote-view/index.cjs +5 -3
  59. package/dist/remote-view/index.cjs.map +1 -1
  60. package/dist/remote-view/index.d.ts +3 -1
  61. package/dist/remote-view/index.js +5 -3
  62. package/dist/remote-view/index.js.map +1 -1
  63. package/dist/{server-DZH7OjOD.js → server-BI3wnTwl.js} +227 -686
  64. package/dist/server-BI3wnTwl.js.map +1 -0
  65. package/dist/{server-DLRz-U7F.cjs → server-CKdWENZ-.cjs} +237 -775
  66. package/dist/server-CKdWENZ-.cjs.map +1 -0
  67. package/package.json +1 -1
  68. package/dist/discovery-3K_Wtkvn.js.map +0 -1
  69. package/dist/discovery-BZ2ms1tE.cjs.map +0 -1
  70. package/dist/ids-BR4U1dJD.cjs.map +0 -1
  71. package/dist/ids-D4e10971.js.map +0 -1
  72. package/dist/server-DLRz-U7F.cjs.map +0 -1
  73. package/dist/server-DZH7OjOD.js.map +0 -1
@@ -1,525 +1,11 @@
1
- import { d as isReadOnlySchema, l as embedTargetId, o as COMPUTED_TYPES, u as isFieldDrivenEvery } from "./ids-D4e10971.js";
1
+ import { f as storageKindFor, l as embedTargetId, o as COMPUTED_TYPES, u as isFieldDrivenEvery } from "./ids-P2T89Gou.js";
2
2
  import { D as rollupValue, E as projectBacklinkRow, O as actionVisible, S as itemIsDone, T as coerceNumeric, b as resolveIcon, d as parseIsoDateTime, g as deriveAll, t as defangForPrompt, u as parseIsoDate, w as backlinkRows, x as selectDynamicRecord, y as firstDateField } from "./promptSafety-Boz5qOs-.js";
3
- import { A as archiveDir, C as isContainedInRoot, D as resolveTemplatePath, E as resolveDataDir, F as log, N as getWorkspaceRoot, O as safeRecordId, P as isPresetSlug$1, R as skillsStagingDir, S as SCHEMA_FILE$1, _ as readItem, b as writeItem, f as isRegularFile, k as safeSlugName, n as discoverCollections, o as CollectionSchemaZ, p as listItems, r as loadCollection, s as resolveMutateSet, x as writeFileAtomic, y as resolveCreateItemId } from "./discovery-3K_Wtkvn.js";
3
+ import { $ as isPresetSlug$1, B as writeFileAtomic, C as compileJsonlQuery, G as resolveDataDir, H as isContainedInRoot, J as safeSlugName, K as resolveTemplatePath, Q as getWorkspaceRoot, R as resolveCreateItemId, V as SCHEMA_FILE$1, Y as archiveDir, b as normalizeCsvValue, d as checkpointSqliteDatabase, et as log, h as cacheDir, j as isRegularFile, l as readOnlyRefusal, n as discoverCollections, o as CollectionSchemaZ, r as loadCollection, rt as skillsStagingDir, s as resolveMutateSet, u as storeFor, w as CollectionQueryZ, x as queryCsv } from "./discovery-QUi_7aNG.js";
4
4
  import { dataSkillDir, mirrorSkillWrite } from "./skill-bridge/index.js";
5
5
  import path from "node:path";
6
- import { cp, lstat, mkdir, open, readFile, readdir, rename, rm, rmdir, stat, unlink, writeFile } from "node:fs/promises";
7
- import { createHash, randomBytes, randomUUID } from "node:crypto";
8
- import { tmpdir } from "node:os";
9
- import iconv from "iconv-lite";
6
+ import { cp, lstat, mkdir, open, readFile, readdir, rm, rmdir, stat, unlink, writeFile } from "node:fs/promises";
7
+ import { randomBytes, randomUUID } from "node:crypto";
10
8
  import { z } from "zod";
11
- //#region src/collection/core/queryZ.ts
12
- /** Result-column aliases double as SQL identifiers and JSON keys — keep
13
- * them to a conservative identifier charset so neither side needs
14
- * escaping gymnastics. */
15
- var SAFE_ALIAS_PATTERN = /^[A-Za-z_]\w{0,63}$/;
16
- /** Hard ceiling on returned rows; `limit` clamps below it. A group-by on
17
- * a near-unique column would otherwise return one row per source row —
18
- * the exact materialization the aggregate path exists to avoid. */
19
- var MAX_QUERY_ROWS = 1e4;
20
- /** Default row cap when the query declares no `limit`. */
21
- var DEFAULT_QUERY_ROWS = 1e3;
22
- /** One aggregate column: `count` (rows; `column` optional to count
23
- * non-null cells) or `sum`/`avg`/`min`/`max` over a named CSV column. */
24
- var QueryAggregateZ = z.object({
25
- op: z.enum([
26
- "count",
27
- "sum",
28
- "avg",
29
- "min",
30
- "max"
31
- ]),
32
- column: z.string().min(1).optional()
33
- }).refine((aggregate) => aggregate.op === "count" || aggregate.column !== void 0, {
34
- message: "`column` is required for every aggregate op except `count`",
35
- path: ["column"]
36
- });
37
- /** One filter condition. Same op vocabulary as the schema-level `where`
38
- * (`core/where.ts`) so authors learn one set; values may be typed
39
- * (number / boolean) since CSV columns are. `in` requires an array
40
- * value, every other op a scalar. */
41
- var QueryWhereZ = z.object({
42
- field: z.string().min(1),
43
- op: z.enum([
44
- "eq",
45
- "ne",
46
- "in",
47
- "gt",
48
- "gte",
49
- "lt",
50
- "lte",
51
- "contains"
52
- ]),
53
- value: z.union([
54
- z.string(),
55
- z.number(),
56
- z.boolean(),
57
- z.array(z.union([
58
- z.string(),
59
- z.number(),
60
- z.boolean()
61
- ])).min(1).max(100)
62
- ])
63
- }).refine((cond) => cond.op === "in" === Array.isArray(cond.value), {
64
- message: "`in` requires an array value (the allowed set); every other op requires a scalar value",
65
- path: ["value"]
66
- });
67
- var QueryOrderZ = z.object({
68
- /** A `groupBy` column or an aggregate alias — membership enforced by
69
- * the whole-query refine below. */
70
- field: z.string().min(1),
71
- dir: z.enum(["asc", "desc"]).optional()
72
- });
73
- /** The whole query. At least one of `groupBy` / `aggregates` must be
74
- * present: bare `groupBy` is a DISTINCT listing, bare `aggregates` a
75
- * whole-file scalar row, together a grouped aggregation. */
76
- var CollectionQueryZ = z.object({
77
- 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(),
78
- aggregates: z.record(z.string().regex(SAFE_ALIAS_PATTERN, "aggregate aliases must be simple identifiers (letters/digits/underscore)"), QueryAggregateZ).optional(),
79
- where: z.array(QueryWhereZ).max(16).optional(),
80
- orderBy: z.array(QueryOrderZ).max(4).optional(),
81
- limit: z.number().int().min(1).max(MAX_QUERY_ROWS).optional()
82
- }).refine((query) => (query.groupBy?.length ?? 0) > 0 || Object.keys(query.aggregates ?? {}).length > 0, {
83
- message: "declare at least one of `groupBy` (columns to bucket by) or `aggregates` (values to compute)",
84
- path: ["groupBy"]
85
- }).refine((query) => Object.keys(query.aggregates ?? {}).length <= 32, {
86
- message: `\`aggregates\` supports at most 32 entries`,
87
- path: ["aggregates"]
88
- }).refine((query) => {
89
- const groupLower = new Set((query.groupBy ?? []).map((column) => column.toLowerCase()));
90
- const seen = /* @__PURE__ */ new Set();
91
- return Object.keys(query.aggregates ?? {}).every((alias) => {
92
- const lower = alias.toLowerCase();
93
- if (groupLower.has(lower) || seen.has(lower)) return false;
94
- seen.add(lower);
95
- return true;
96
- });
97
- }, {
98
- message: "aggregate aliases must be unique and must not collide with `groupBy` column names (case-insensitively — SQL identifiers ignore case)",
99
- path: ["aggregates"]
100
- }).refine((query) => {
101
- const sortable = /* @__PURE__ */ new Set([...query.groupBy ?? [], ...Object.keys(query.aggregates ?? {})]);
102
- return (query.orderBy ?? []).every((order) => sortable.has(order.field));
103
- }, {
104
- message: "every `orderBy.field` must be a `groupBy` column or an aggregate alias",
105
- path: ["orderBy"]
106
- });
107
- //#endregion
108
- //#region src/collection/server/csvQuery.ts
109
- /** Double-quote a SQL identifier (CSV column name / result alias). */
110
- function quoteIdent(name) {
111
- return `"${name.replaceAll("\"", "\"\"")}"`;
112
- }
113
- /** Single-quote a SQL string literal (a `types={...}` struct key). */
114
- function quoteLiteral(value) {
115
- return `'${value.replaceAll("'", "''")}'`;
116
- }
117
- /** The `read_csv` argument list shared by every CSV query: the (prepared)
118
- * path plus a `types` pin forcing the key column to VARCHAR — without it
119
- * DuckDB's sniffer turns `001` into BIGINT 1, so leading zeros vanish
120
- * and distinct keys collapse. */
121
- function readCsvArgs(primaryKey) {
122
- return `?, types={${quoteLiteral(primaryKey)}: 'VARCHAR'}`;
123
- }
124
- /** One aggregate's SQL expression. `sum`/`avg` TRY_CAST to DOUBLE so a
125
- * column the sniffer kept as VARCHAR (mixed values) aggregates over its
126
- * numeric cells instead of erroring; non-numeric cells become NULL and
127
- * are skipped — standard BI tolerance. `min`/`max` stay native (they are
128
- * meaningful on strings and dates too). */
129
- function aggregateExpr(aggregate) {
130
- const { op, column } = aggregate;
131
- if (op === "count") return column === void 0 ? "count(*)" : `count(${quoteIdent(column)})`;
132
- if (op === "sum" || op === "avg") return `${op}(TRY_CAST(${quoteIdent(column ?? "")} AS DOUBLE))`;
133
- return `${op}(${quoteIdent(column ?? "")})`;
134
- }
135
- /** One where condition → SQL fragment + its bound parameters. String
136
- * equality compares against `CAST(col AS VARCHAR)` so a sniffer-typed
137
- * column still matches its textual value; numeric/boolean values compare
138
- * natively (DuckDB coerces the column side). */
139
- function whereFragment(cond) {
140
- const column = quoteIdent(cond.field);
141
- const asText = `CAST(${column} AS VARCHAR)`;
142
- if (cond.op === "in") {
143
- const values = cond.value;
144
- return {
145
- sql: `${values.every((value) => typeof value === "string") ? asText : column} IN (${values.map(() => "?").join(", ")})`,
146
- params: values
147
- };
148
- }
149
- if (cond.op === "contains") return {
150
- sql: `contains(${asText}, ?)`,
151
- params: [String(cond.value)]
152
- };
153
- const operator = {
154
- eq: "=",
155
- ne: "<>",
156
- gt: ">",
157
- gte: ">=",
158
- lt: "<",
159
- lte: "<="
160
- }[cond.op];
161
- return {
162
- sql: `${typeof cond.value === "string" && (cond.op === "eq" || cond.op === "ne") ? asText : column} ${operator} ?`,
163
- params: [cond.value]
164
- };
165
- }
166
- /** Compile a validated query against `fromSql` (a table-function call
167
- * whose FIRST placeholder is the source path — the executor binds it).
168
- * Returns the SQL and the where-value parameters that follow the path.
169
- * Callers MUST have run `CollectionQueryZ` first; this function trusts
170
- * the shape (aliases already charset-checked, orderBy membership already
171
- * enforced). */
172
- function compileQuery(query, fromSql) {
173
- const groupBy = query.groupBy ?? [];
174
- const aggregates = Object.entries(query.aggregates ?? {});
175
- const selectList = [...groupBy.map(quoteIdent), ...aggregates.map(([alias, aggregate]) => `${aggregateExpr(aggregate)} AS ${quoteIdent(alias)}`)];
176
- const where = (query.where ?? []).map(whereFragment);
177
- const clauses = [`SELECT ${selectList.join(", ")}`, `FROM ${fromSql}`];
178
- if (where.length > 0) clauses.push(`WHERE ${where.map((fragment) => fragment.sql).join(" AND ")}`);
179
- if (groupBy.length > 0) clauses.push(`GROUP BY ${groupBy.map(quoteIdent).join(", ")}`);
180
- const orderBy = (query.orderBy ?? []).map((order) => quoteIdent(order.field) + (order.dir === "desc" ? " DESC" : " ASC"));
181
- if (orderBy.length > 0) clauses.push(`ORDER BY ${orderBy.join(", ")}`);
182
- clauses.push(`LIMIT ${query.limit ?? 1e3}`);
183
- return {
184
- sql: clauses.join(" "),
185
- params: where.flatMap((fragment) => fragment.params)
186
- };
187
- }
188
- /** Compile against a CSV file (the dataSource store's engine). */
189
- function compileCsvQuery(query, primaryKey) {
190
- return compileQuery(query, `read_csv(${readCsvArgs(primaryKey)})`);
191
- }
192
- /** Compile against a JSONL file of ENRICHED records — the file-backed
193
- * collections' engine (see `jsonlQuery.ts`). No VARCHAR key pin needed:
194
- * enriched record ids are already strings. `sample_size=-1` makes the
195
- * schema inference scan EVERY line — with the default sample, a sparse
196
- * optional/derived field first appearing past the sample would not be
197
- * inferred as a column and the query would binder-error on it (Codex P2
198
- * on #2165). The full scan costs nothing extra here: aggregation reads
199
- * the whole file anyway. */
200
- function compileJsonlQuery(query) {
201
- return compileQuery(query, `read_json(?, format='newline_delimited', sample_size=-1)`);
202
- }
203
- //#endregion
204
- //#region src/collection/server/csvStore.ts
205
- /** `list()` row cap. Over-cap files are truncated with a warn — the v1
206
- * contract is "browse + per-record views", not full-table analytics. */
207
- var MAX_CSV_ROWS = 5e3;
208
- /** Record ids minted from non-safe key values: `id0x` + utf-8 hex. Raw key
209
- * values that themselves match this pattern are ALSO encoded, so the
210
- * encoded namespace never collides with a raw value (injective mapping). */
211
- var ENCODED_ID_PATTERN = /^id0x([0-9a-f]+)$/;
212
- /** A CSV key value → the record id it's addressed by. Safe values pass
213
- * through untouched; everything else (and anything shaped like an encoded
214
- * id) becomes `id0x<hex>`. Pure + exported for unit tests. */
215
- function encodeCsvRecordId(rawKey) {
216
- if (safeRecordId(rawKey) === rawKey && !ENCODED_ID_PATTERN.test(rawKey)) return rawKey;
217
- return `id0x${Buffer.from(rawKey, "utf-8").toString("hex")}`;
218
- }
219
- /** A record id → the CSV key value to look up. Inverse of
220
- * `encodeCsvRecordId` for encoded ids; anything else is already the raw
221
- * value. Pure + exported for unit tests. */
222
- function decodeCsvRecordId(itemId) {
223
- const match = ENCODED_ID_PATTERN.exec(itemId);
224
- if (!match) return itemId;
225
- return Buffer.from(match[1], "hex").toString("utf-8");
226
- }
227
- /** Normalize one DuckDB JS value into a JSON-safe record value: BigInt →
228
- * number (string beyond the safe range), DATE/TIMESTAMP → ISO string
229
- * (date-only when the clock is exactly UTC midnight, matching the `date`
230
- * field contract), exotic DuckDB values → their string form. Pure +
231
- * exported for unit tests. */
232
- function normalizeCsvValue(value) {
233
- if (typeof value === "bigint") return value <= BigInt(Number.MAX_SAFE_INTEGER) && value >= BigInt(-Number.MAX_SAFE_INTEGER) ? Number(value) : value.toString();
234
- if (value instanceof Date) {
235
- const iso = value.toISOString();
236
- return iso.endsWith("T00:00:00.000Z") ? iso.slice(0, 10) : iso;
237
- }
238
- if (value !== null && typeof value === "object") return String(value);
239
- return value;
240
- }
241
- /** One raw DuckDB row → a CollectionItem, or null when the key cell is
242
- * missing/empty (the row can't be addressed). The primaryKey field is
243
- * OVERWRITTEN with the (possibly encoded) record id so `item[primaryKey]`
244
- * and the record's address never drift — same invariant the file store's
245
- * write path enforces. Pure + exported for unit tests. */
246
- function csvRowToItem(row, primaryKey) {
247
- const normalized = Object.fromEntries(Object.entries(row).map(([key, value]) => [key, normalizeCsvValue(value)]));
248
- const rawKey = normalized[primaryKey];
249
- if (rawKey === null || rawKey === void 0 || String(rawKey) === "") return null;
250
- return {
251
- ...normalized,
252
- [primaryKey]: encodeCsvRecordId(String(rawKey))
253
- };
254
- }
255
- /** Dedupe by record id, LAST row wins (matches `csvRead`'s last-match
256
- * pick). Returns the surviving items in first-seen order. Pure +
257
- * exported for unit tests. */
258
- function dedupeByRecordId(items, primaryKey) {
259
- const byId = /* @__PURE__ */ new Map();
260
- for (const item of items) byId.set(String(item[primaryKey]), item);
261
- return {
262
- items: [...byId.values()],
263
- duplicates: items.length - byId.size
264
- };
265
- }
266
- /** True when a thrown DuckDB error is the `types` pin naming a column the
267
- * CSV doesn't have — the schema/file-mismatch case the caller downgrades
268
- * to "empty collection + warn" instead of a 500. */
269
- function isMissingKeyColumnError(err) {
270
- return String(err).includes("do not exist in the CSV");
271
- }
272
- /** Bytes sniffed for UTF-8 validity. The trailing 3 bytes of the sample
273
- * are dropped so a multibyte char split at the boundary can't produce a
274
- * false negative on a valid file. */
275
- var SNIFF_BYTES = 1024 * 1024;
276
- function isValidUtf8(buf) {
277
- try {
278
- new TextDecoder("utf-8", { fatal: true }).decode(buf);
279
- return true;
280
- } catch {
281
- return false;
282
- }
283
- }
284
- /** Detect the (best-effort) encoding of a non-UTF-8 buffer. BOMs decide
285
- * UTF-16; otherwise cp932 (the Shift_JIS superset — Excel-exported
286
- * Japanese CSVs are the primary non-UTF-8 case this feature serves). */
287
- function fallbackEncoding(buf) {
288
- if (buf.length >= 2 && buf[0] === 255 && buf[1] === 254) return "utf-16le";
289
- if (buf.length >= 2 && buf[0] === 254 && buf[1] === 255) return "utf-16be";
290
- return "cp932";
291
- }
292
- function cacheDir() {
293
- return path.join(tmpdir(), "mulmoclaude-csv-utf8");
294
- }
295
- /** Read only the first `bytes` of a file — the encoding sniff must not
296
- * pull a multi-hundred-MB CSV into memory on the (common) UTF-8 path. */
297
- async function readHead(absPath, bytes) {
298
- const handle = await open(absPath, "r");
299
- try {
300
- const { size } = await handle.stat();
301
- const buf = Buffer.alloc(Math.min(bytes, size));
302
- await handle.read(buf, 0, buf.length, 0);
303
- return buf;
304
- } finally {
305
- await handle.close();
306
- }
307
- }
308
- /** Decode the whole file into a UTF-8 cache copy and return its path.
309
- * Cache key = (path, mtime, size), so a replaced CSV re-decodes and an
310
- * unchanged one never does. */
311
- async function pathExists$1(target) {
312
- try {
313
- await stat(target);
314
- return true;
315
- } catch {
316
- return false;
317
- }
318
- }
319
- /** Best-effort removal of older decode-cache entries for the same source
320
- * path — a frequently-replaced large CSV would otherwise accumulate one
321
- * full copy per (mtime, size) forever. Runs AFTER the current copy is
322
- * published; a concurrent reader holding an old fd is unaffected
323
- * (unlink-while-open is safe on POSIX). */
324
- async function evictSupersededCache(key, keepBasename) {
325
- try {
326
- const entries = await readdir(cacheDir());
327
- await Promise.all(entries.filter((name) => name.startsWith(`${key}-`) && name !== keepBasename).map((name) => unlink(path.join(cacheDir(), name)).catch(() => void 0)));
328
- } catch {}
329
- }
330
- /** Decode the whole file into a UTF-8 cache copy and return its path.
331
- * Cache key = (path, mtime, size), so a replaced CSV re-decodes and an
332
- * unchanged one never does; superseded copies are evicted. The cache
333
- * lives in the SHARED OS tmpdir, so the dir is 0700 and files 0600 —
334
- * decoded rows must not be readable by other local users. */
335
- async function decodeToCache(absPath, info) {
336
- const key = createHash("sha256").update(absPath).digest("hex").slice(0, 16);
337
- const cached = path.join(cacheDir(), `${key}-${Math.trunc(info.mtimeMs)}-${info.size}.csv`);
338
- if (!await pathExists$1(cached)) {
339
- const whole = await readFile(absPath);
340
- const encoding = fallbackEncoding(whole);
341
- const text = iconv.decode(whole, encoding);
342
- await mkdir(cacheDir(), {
343
- recursive: true,
344
- mode: 448
345
- });
346
- const tmp = `${cached}.${randomBytes(4).toString("hex")}.tmp`;
347
- await writeFile(tmp, text, {
348
- encoding: "utf-8",
349
- mode: 384
350
- });
351
- await rename(tmp, cached);
352
- log.info("collections", "decoded non-UTF-8 dataSource file to cache", {
353
- path: absPath,
354
- encoding
355
- });
356
- await evictSupersededCache(key, path.basename(cached));
357
- }
358
- return cached;
359
- }
360
- /** Re-validate the dataSource file at READ time, mirroring the JSON
361
- * store's per-read defenses: realpath containment (a symlink swapped in
362
- * after discovery must not walk out of the workspace) and an lstat
363
- * regular-file check (a symlink leaf is refused outright, even one
364
- * pointing inside the workspace — same rule as `isRegularFile` on
365
- * record files). Returns the stat info, or null for "no readable file"
366
- * (ENOENT / refused), which callers render as an empty collection. */
367
- async function safeCsvStat(absPath, workspaceRoot) {
368
- if (!isContainedInRoot(absPath, workspaceRoot)) {
369
- log.warn("collections", "dataSource read refused: path escapes workspace", { path: absPath });
370
- return null;
371
- }
372
- let info;
373
- try {
374
- info = await lstat(absPath);
375
- } catch (err) {
376
- if (err.code === "ENOENT") return null;
377
- throw err;
378
- }
379
- if (!info.isFile()) {
380
- log.warn("collections", "dataSource read refused: not a regular file (symlink?)", { path: absPath });
381
- return null;
382
- }
383
- return info;
384
- }
385
- /** Return a path DuckDB can read as UTF-8: the original file when it
386
- * already is UTF-8 (the cheap, common case — only the head is sniffed),
387
- * else a decoded cache copy (see `decodeToCache`). Returns null when
388
- * there is no readable file (missing, symlink, or containment-refused —
389
- * see `safeCsvStat`), which callers render as an empty collection. */
390
- async function ensureUtf8CsvPath(absPath, workspaceRoot) {
391
- const info = await safeCsvStat(absPath, workspaceRoot);
392
- if (info === null) return null;
393
- const head = await readHead(absPath, SNIFF_BYTES);
394
- const sample = head.length === SNIFF_BYTES ? head.subarray(0, SNIFF_BYTES - 3) : head;
395
- if (!(head.length >= 2 && (head[0] === 255 && head[1] === 254 || head[0] === 254 && head[1] === 255)) && isValidUtf8(sample)) return absPath;
396
- return decodeToCache(absPath, info);
397
- }
398
- var instancePromise = null;
399
- /** Lazily create one shared in-memory DuckDB instance. The dynamic import
400
- * keeps the native module OUT of core's load path — a platform where the
401
- * prebuilt binding is missing degrades to a per-query error on dataSource
402
- * collections only, never a broken core. A failed init is retried on the
403
- * next call (the promise is reset). */
404
- async function duckDbInstance() {
405
- if (instancePromise === null) instancePromise = import("@duckdb/node-api").then((mod) => mod.DuckDBInstance.create(":memory:"));
406
- try {
407
- return await instancePromise;
408
- } catch (err) {
409
- instancePromise = null;
410
- throw new Error(`DuckDB is unavailable on this host (@duckdb/node-api failed to load: ${String(err)}) — dataSource collections cannot be read`);
411
- }
412
- }
413
- async function queryCsv(sql, params) {
414
- const connection = await (await duckDbInstance()).connect();
415
- try {
416
- return (await connection.runAndReadAll(sql, params)).getRowObjectsJS();
417
- } finally {
418
- connection.disconnectSync();
419
- }
420
- }
421
- /** Every row of the CSV as records — capped, deduped, id-encoded. The
422
- * key column is pinned to VARCHAR (see `readCsvArgs`). `workspaceRoot`
423
- * drives the per-read containment check; omitted, the configured host
424
- * root is used. */
425
- async function csvList(absPath, primaryKey, workspaceRoot) {
426
- const utf8Path = await ensureUtf8CsvPath(absPath, workspaceRoot ?? getWorkspaceRoot());
427
- if (utf8Path === null) return [];
428
- let rows;
429
- try {
430
- rows = await queryCsv(`SELECT * FROM read_csv(${readCsvArgs(primaryKey)}) LIMIT 5001`, [utf8Path]);
431
- } catch (err) {
432
- if (!isMissingKeyColumnError(err)) throw err;
433
- log.warn("collections", "dataSource CSV has no primaryKey column — every row is skipped", {
434
- path: absPath,
435
- primaryKey
436
- });
437
- return [];
438
- }
439
- if (rows.length > 5e3) {
440
- log.warn("collections", "dataSource CSV truncated to row cap", {
441
- path: absPath,
442
- cap: MAX_CSV_ROWS
443
- });
444
- rows.length = MAX_CSV_ROWS;
445
- }
446
- const items = rows.map((row) => csvRowToItem(row, primaryKey)).filter((item) => item !== null);
447
- const skipped = rows.length - items.length;
448
- if (skipped > 0) log.warn("collections", "dataSource CSV rows skipped (empty key cell)", {
449
- path: absPath,
450
- skipped
451
- });
452
- const deduped = dedupeByRecordId(items, primaryKey);
453
- if (deduped.duplicates > 0) log.warn("collections", "dataSource CSV has duplicate key values (last row wins)", {
454
- path: absPath,
455
- duplicates: deduped.duplicates
456
- });
457
- return deduped.items;
458
- }
459
- /** The scan-order ordinal column the last-match read adds. Underscore
460
- * prefix keeps it out of any plausible CSV header namespace; it is
461
- * stripped from the returned record either way. */
462
- var ROW_ORDINAL = "__mc_row";
463
- /** One record by id. The comparison value rides as a prepared-statement
464
- * parameter, and the LAST matching row is selected IN DuckDB (scan-order
465
- * ordinal + LIMIT 1) — a CSV with thousands of duplicate keys must not
466
- * materialize them all for one detail read. Consistent with csvList's
467
- * last-wins dedupe. */
468
- async function csvRead(absPath, primaryKey, itemId, workspaceRoot) {
469
- const utf8Path = await ensureUtf8CsvPath(absPath, workspaceRoot ?? getWorkspaceRoot());
470
- if (utf8Path === null) return null;
471
- const rawKey = decodeCsvRecordId(itemId);
472
- 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);
473
- if (last === void 0) return null;
474
- const { [ROW_ORDINAL]: __ordinal, ...record } = last;
475
- return csvRowToItem(record, primaryKey);
476
- }
477
- /** Run a validated aggregation query (the structured DSL — see
478
- * `core/queryZ.ts`) over the WHOLE file: no row cap on the scan (a
479
- * capped aggregate would be a wrong number), only the result-row LIMIT
480
- * the compiler emits. Values are normalized like list/read rows so a
481
- * chart consumer gets plain JSON scalars. */
482
- async function csvRunQuery(absPath, primaryKey, query, workspaceRoot) {
483
- const utf8Path = await ensureUtf8CsvPath(absPath, workspaceRoot ?? getWorkspaceRoot());
484
- if (utf8Path === null) return [];
485
- const { sql, params } = compileCsvQuery(query, primaryKey);
486
- return (await queryCsv(sql, [utf8Path, ...params])).map((row) => Object.fromEntries(Object.entries(row).map(([key, value]) => [key, normalizeCsvValue(value)])));
487
- }
488
- //#endregion
489
- //#region src/collection/server/store.ts
490
- /** True when the collection accepts UI/tool writes. A `dataSource`
491
- * collection is read-only: updates happen by editing/replacing the
492
- * data file itself. Every write entry point checks this BEFORE calling
493
- * `writeItem`/`deleteItem` — server-enforced, not just UI-hidden. */
494
- function collectionWritable(collection) {
495
- return !isReadOnlySchema(collection.schema);
496
- }
497
- /** The one-line refusal write paths surface (HTTP 405 / MCP error text). */
498
- function readOnlyRefusal(slug) {
499
- return `collection '${slug}' is read-only (backed by an external dataSource) — update the data file itself instead`;
500
- }
501
- /** Pick the store implementation for a discovered collection. A
502
- * `dataSource` schema whose `dataSourceFile` failed to resolve yields a
503
- * read-only EMPTY store rather than falling back to the (writable) file
504
- * store — a half-loaded read-only collection must never become writable. */
505
- function storeFor(collection, opts = {}) {
506
- if (isReadOnlySchema(collection.schema)) {
507
- const file = collection.dataSourceFile;
508
- const key = collection.schema.primaryKey;
509
- return {
510
- capabilities: { writable: false },
511
- list: () => file === void 0 ? Promise.resolve([]) : csvList(file, key, opts.workspaceRoot),
512
- read: (itemId) => file === void 0 ? Promise.resolve(null) : csvRead(file, key, itemId, opts.workspaceRoot),
513
- query: (query) => file === void 0 ? Promise.resolve([]) : csvRunQuery(file, key, query, opts.workspaceRoot)
514
- };
515
- }
516
- return {
517
- capabilities: { writable: true },
518
- list: () => listItems(collection.dataDir, opts),
519
- read: (itemId) => readItem(collection.dataDir, itemId, opts)
520
- };
521
- }
522
- //#endregion
523
9
  //#region src/collection/server/jsonlQuery.ts
524
10
  /** SQL semantics for an aggregate-only query over ZERO rows: one scalar
525
11
  * row (`count` = 0, everything else NULL) — the same shape the CSV path
@@ -588,6 +74,132 @@ async function runQueryOverRows(rows, query) {
588
74
  }
589
75
  }
590
76
  //#endregion
77
+ //#region src/collection/server/derive.ts
78
+ /** Slugs of every collection referenced by a `ref` field — top-level
79
+ * and one level into `table` sub-fields (nested tables are
80
+ * schema-rejected). Mirrors the client's `uniqueRefTargets`. */
81
+ function uniqueRefTargets(schema) {
82
+ const targets = /* @__PURE__ */ new Set();
83
+ const walk = (fields) => {
84
+ for (const field of Object.values(fields)) {
85
+ if (field.type === "ref" && typeof field.to === "string" && field.to.length > 0) targets.add(field.to);
86
+ if (field.type === "table" && field.of) walk(field.of);
87
+ }
88
+ };
89
+ walk(schema.fields);
90
+ return [...targets];
91
+ }
92
+ /** Slugs of every collection referenced by an `embed` field (top-level
93
+ * only, like the client). */
94
+ function uniqueEmbedTargets(schema) {
95
+ const targets = /* @__PURE__ */ new Set();
96
+ for (const field of Object.values(schema.fields)) if (field.type === "embed" && typeof field.to === "string" && field.to.length > 0) targets.add(field.to);
97
+ return [...targets];
98
+ }
99
+ /** Slugs of every SOURCE collection a `backlinks` or `rollup` field
100
+ * reverses over — loaded exactly like ref/embed targets (whole
101
+ * collection, once; the two field kinds share one load). */
102
+ function uniqueBacklinkSources(schema) {
103
+ const sources = /* @__PURE__ */ new Set();
104
+ for (const field of Object.values(schema.fields)) if ((field.type === "backlinks" || field.type === "rollup") && field.from.length > 0) sources.add(field.from);
105
+ return [...sources];
106
+ }
107
+ async function loadTarget(slug, opts) {
108
+ const target = await loadCollection(slug, opts);
109
+ if (!target) return null;
110
+ const items = await storeFor(target, { workspaceRoot: opts.workspaceRoot }).list();
111
+ const byId = {};
112
+ for (const item of items) {
113
+ const itemId = item[target.schema.primaryKey];
114
+ if (typeof itemId === "string" && itemId.length > 0) byId[itemId] = deriveAll(target.schema, item, {});
115
+ }
116
+ return {
117
+ schema: target.schema,
118
+ byId
119
+ };
120
+ }
121
+ /** Load every ref/embed target and backlink source collection once.
122
+ * Unknown / unloadable targets are simply absent — downstream derefs
123
+ * resolve to null (em-dash) and backlinks to an empty row set, the
124
+ * same fail-soft the UI renders. */
125
+ async function loadLinkedTargets(schema, opts) {
126
+ const slugs = [.../* @__PURE__ */ new Set([
127
+ ...uniqueRefTargets(schema),
128
+ ...uniqueEmbedTargets(schema),
129
+ ...uniqueBacklinkSources(schema)
130
+ ])];
131
+ const loaded = {};
132
+ for (const slug of slugs) {
133
+ const target = await loadTarget(slug, opts);
134
+ if (target) loaded[slug] = target;
135
+ }
136
+ return loaded;
137
+ }
138
+ function toRefRecords(linked) {
139
+ return Object.fromEntries(Object.entries(linked).map(([slug, target]) => [slug, target.byId]));
140
+ }
141
+ /** The matching source rows for one `backlinks` field, projected to the
142
+ * source primaryKey + `display` columns — so getItems on a
143
+ * heavily-referenced record stays a summary, not a dump of the source
144
+ * collection. Missing source ⇒ [] (fail-soft). The rows come from the
145
+ * DERIVED source records (`byId`), so `display`/`filter` on a derived
146
+ * source column (an invoice `total`) works. */
147
+ function projectBacklinks(field, schema, enriched, linked) {
148
+ const source = linked[field.from];
149
+ if (!source) return [];
150
+ return backlinkRows(field, String(enriched[schema.primaryKey] ?? ""), Object.values(source.byId)).map((row) => projectBacklinkRow(row, field.display, source.schema.primaryKey));
151
+ }
152
+ /** Project the computed (never-stored) field kinds onto one derived
153
+ * record: `toggle` → boolean off its enum, `embed` → the target record
154
+ * (fixed `id` or per-record `idField`), or null when missing,
155
+ * `backlinks` → the matching source rows (see `projectBacklinks`). */
156
+ function projectComputed(schema, enriched, linked) {
157
+ for (const [key, field] of Object.entries(schema.fields)) {
158
+ if (field.type === "toggle" && field.field) enriched[key] = String(enriched[field.field] ?? "") === field.onValue;
159
+ if (field.type === "embed" && field.to) {
160
+ const targetId = embedTargetId(field, enriched);
161
+ enriched[key] = targetId && linked[field.to]?.byId[targetId] || null;
162
+ }
163
+ if (field.type === "backlinks") enriched[key] = projectBacklinks(field, schema, enriched, linked);
164
+ }
165
+ return enriched;
166
+ }
167
+ /** Resolve every rollup field onto a COPY of the record, BEFORE the
168
+ * formula pass — a `derived` formula may reference rollup values as
169
+ * plain identifiers (`played = homePlayed + awayPlayed`). Same reverse
170
+ * machinery as backlinks, collapsed to a number: an unresolvable source
171
+ * is null (a formula reading it fails soft to em-dash); an empty match
172
+ * set is a real 0. Returns `record` unchanged when the schema declares
173
+ * no rollups. */
174
+ function projectRollups(schema, record, linked) {
175
+ let out = record;
176
+ for (const [key, field] of Object.entries(schema.fields)) {
177
+ if (field.type !== "rollup") continue;
178
+ if (out === record) out = { ...record };
179
+ const source = linked[field.from];
180
+ const selfId = String(record[schema.primaryKey] ?? "");
181
+ out[key] = source ? rollupValue(field, selfId, Object.values(source.byId)) : null;
182
+ }
183
+ return out;
184
+ }
185
+ /** Enrich records with every host-computed field: derived formulas
186
+ * evaluated (cross-collection derefs included), toggles projected,
187
+ * embeds resolved. Loads each linked collection ONCE per call. Input
188
+ * records are not mutated. */
189
+ async function enrichItems(collection, items, opts = {}) {
190
+ const { schema } = collection;
191
+ const linked = await loadLinkedTargets(schema, opts);
192
+ const refRecords = toRefRecords(linked);
193
+ return items.map((item) => projectComputed(schema, deriveAll(schema, projectRollups(schema, item, linked), refRecords), linked));
194
+ }
195
+ //#endregion
196
+ //#region src/collection/server/queryRunner.ts
197
+ async function runCollectionQuery(collection, query, opts = {}) {
198
+ const store = storeFor(collection, { workspaceRoot: opts.workspaceRoot });
199
+ if (store.query) return store.query(query);
200
+ return runQueryOverRows(await enrichItems(collection, await store.list(), opts), query);
201
+ }
202
+ //#endregion
591
203
  //#region src/collection/core/recordZ.ts
592
204
  /** The emptiness rule shared by `required` and the "only check present
593
205
  * values" gate. NOT a truthiness check — `0` and `false` are filled. */
@@ -705,6 +317,7 @@ async function listRecordFilenames(dataDir, workspaceRoot) {
705
317
  }
706
318
  async function validateCollectionRecords(collection, opts = {}) {
707
319
  if (collection.schema.dataSource !== void 0) return [];
320
+ if (collection.schema.storage !== void 0) return validateStoreRecords(collection, opts);
708
321
  const workspaceRoot = opts.workspaceRoot ?? getWorkspaceRoot();
709
322
  const entries = await listRecordFilenames(collection.dataDir, workspaceRoot);
710
323
  const issues = [];
@@ -716,6 +329,34 @@ async function validateCollectionRecords(collection, opts = {}) {
716
329
  }
717
330
  return issues;
718
331
  }
332
+ /** Store-backed twin of the file scan: list every record through the
333
+ * collection's store and lint it with the same "strict" report-only tier.
334
+ * A row the store can't even parse is invisible here (the store skips
335
+ * it), so the read/parse classifications of the file scan don't apply —
336
+ * schema violations are what this catches. `file` carries the record id
337
+ * (there is no per-record filename). */
338
+ async function validateStoreRecords(collection, opts) {
339
+ let items;
340
+ try {
341
+ items = await storeFor(collection, { workspaceRoot: opts.workspaceRoot }).list();
342
+ } catch (err) {
343
+ return [{
344
+ file: "(store)",
345
+ problem: `records could not be read from the storage backend: ${err instanceof Error ? err.message : String(err)}`
346
+ }];
347
+ }
348
+ const issues = [];
349
+ for (const item of items) {
350
+ if (issues.length >= MAX_ISSUES) break;
351
+ const itemId = String(item[collection.schema.primaryKey] ?? "");
352
+ const problem = validateRecordObject(item, itemId, collection.schema, "strict");
353
+ if (problem) issues.push({
354
+ file: itemId,
355
+ problem
356
+ });
357
+ }
358
+ return issues;
359
+ }
719
360
  async function readRecordText(fullPath, name) {
720
361
  try {
721
362
  if (!(await lstat(fullPath)).isFile()) return {
@@ -793,7 +434,13 @@ async function applyMutateAction(collection, action, itemId, params, opts = {})
793
434
  status: "invalid-params",
794
435
  problem: paramProblem
795
436
  };
796
- const existing = await readItem(collection.dataDir, itemId, opts);
437
+ const store = storeFor(collection, opts);
438
+ if (!store.write) return {
439
+ ok: false,
440
+ status: "write-refused",
441
+ problem: "write refused (read-only collection)"
442
+ };
443
+ const existing = await store.read(itemId);
797
444
  if (!existing) return {
798
445
  ok: false,
799
446
  status: "not-found",
@@ -818,10 +465,7 @@ async function applyMutateAction(collection, action, itemId, params, opts = {})
818
465
  status: "invalid-record",
819
466
  problem: invalid
820
467
  };
821
- const result = await writeItem(collection.dataDir, itemId, merged, {
822
- workspaceRoot: opts.workspaceRoot,
823
- slug: collection.slug
824
- });
468
+ const result = await store.write(itemId, merged);
825
469
  if (result.kind !== "ok") return {
826
470
  ok: false,
827
471
  status: "write-refused",
@@ -879,14 +523,14 @@ async function countRecordFiles(dataDir, workspaceRoot) {
879
523
  }
880
524
  }
881
525
  /** A collection's record count for the ontology. File-backed: the cheap
882
- * readdir count. `dataSource`-backed: the row count via the CSV store
883
- * (subject to its row cap) — the dataDir is a phantom there, so the
884
- * readdir count would always misreport 0. Fail-soft to 0 like
885
- * `countRecordFiles` (an unreadable file / missing DuckDB must not
526
+ * readdir count. Every other backend (`dataSource` CSV, `storage`
527
+ * sqlite): the store's page total — the dataDir is a phantom there, so
528
+ * the readdir count would always misreport 0. Fail-soft to 0 like
529
+ * `countRecordFiles` (an unreadable file / missing engine must not
886
530
  * break the whole ontology). */
887
531
  async function countRecords(collection, workspaceRoot) {
888
- if (!collectionWritable(collection)) try {
889
- return (await storeFor(collection, { workspaceRoot }).list()).length;
532
+ if (storageKindFor(collection.schema) !== "file") try {
533
+ return (await storeFor(collection, { workspaceRoot }).page({ limit: 0 })).total;
890
534
  } catch {
891
535
  return 0;
892
536
  }
@@ -913,125 +557,6 @@ async function buildWorkspaceOntology(opts = {}) {
913
557
  return Promise.all(collections.map((collection) => toOntologyEntry(collection, workspaceRoot)));
914
558
  }
915
559
  //#endregion
916
- //#region src/collection/server/derive.ts
917
- /** Slugs of every collection referenced by a `ref` field — top-level
918
- * and one level into `table` sub-fields (nested tables are
919
- * schema-rejected). Mirrors the client's `uniqueRefTargets`. */
920
- function uniqueRefTargets(schema) {
921
- const targets = /* @__PURE__ */ new Set();
922
- const walk = (fields) => {
923
- for (const field of Object.values(fields)) {
924
- if (field.type === "ref" && typeof field.to === "string" && field.to.length > 0) targets.add(field.to);
925
- if (field.type === "table" && field.of) walk(field.of);
926
- }
927
- };
928
- walk(schema.fields);
929
- return [...targets];
930
- }
931
- /** Slugs of every collection referenced by an `embed` field (top-level
932
- * only, like the client). */
933
- function uniqueEmbedTargets(schema) {
934
- const targets = /* @__PURE__ */ new Set();
935
- for (const field of Object.values(schema.fields)) if (field.type === "embed" && typeof field.to === "string" && field.to.length > 0) targets.add(field.to);
936
- return [...targets];
937
- }
938
- /** Slugs of every SOURCE collection a `backlinks` or `rollup` field
939
- * reverses over — loaded exactly like ref/embed targets (whole
940
- * collection, once; the two field kinds share one load). */
941
- function uniqueBacklinkSources(schema) {
942
- const sources = /* @__PURE__ */ new Set();
943
- for (const field of Object.values(schema.fields)) if ((field.type === "backlinks" || field.type === "rollup") && field.from.length > 0) sources.add(field.from);
944
- return [...sources];
945
- }
946
- async function loadTarget(slug, opts) {
947
- const target = await loadCollection(slug, opts);
948
- if (!target) return null;
949
- const items = await storeFor(target, { workspaceRoot: opts.workspaceRoot }).list();
950
- const byId = {};
951
- for (const item of items) {
952
- const itemId = item[target.schema.primaryKey];
953
- if (typeof itemId === "string" && itemId.length > 0) byId[itemId] = deriveAll(target.schema, item, {});
954
- }
955
- return {
956
- schema: target.schema,
957
- byId
958
- };
959
- }
960
- /** Load every ref/embed target and backlink source collection once.
961
- * Unknown / unloadable targets are simply absent — downstream derefs
962
- * resolve to null (em-dash) and backlinks to an empty row set, the
963
- * same fail-soft the UI renders. */
964
- async function loadLinkedTargets(schema, opts) {
965
- const slugs = [.../* @__PURE__ */ new Set([
966
- ...uniqueRefTargets(schema),
967
- ...uniqueEmbedTargets(schema),
968
- ...uniqueBacklinkSources(schema)
969
- ])];
970
- const loaded = {};
971
- for (const slug of slugs) {
972
- const target = await loadTarget(slug, opts);
973
- if (target) loaded[slug] = target;
974
- }
975
- return loaded;
976
- }
977
- function toRefRecords(linked) {
978
- return Object.fromEntries(Object.entries(linked).map(([slug, target]) => [slug, target.byId]));
979
- }
980
- /** The matching source rows for one `backlinks` field, projected to the
981
- * source primaryKey + `display` columns — so getItems on a
982
- * heavily-referenced record stays a summary, not a dump of the source
983
- * collection. Missing source ⇒ [] (fail-soft). The rows come from the
984
- * DERIVED source records (`byId`), so `display`/`filter` on a derived
985
- * source column (an invoice `total`) works. */
986
- function projectBacklinks(field, schema, enriched, linked) {
987
- const source = linked[field.from];
988
- if (!source) return [];
989
- return backlinkRows(field, String(enriched[schema.primaryKey] ?? ""), Object.values(source.byId)).map((row) => projectBacklinkRow(row, field.display, source.schema.primaryKey));
990
- }
991
- /** Project the computed (never-stored) field kinds onto one derived
992
- * record: `toggle` → boolean off its enum, `embed` → the target record
993
- * (fixed `id` or per-record `idField`), or null when missing,
994
- * `backlinks` → the matching source rows (see `projectBacklinks`). */
995
- function projectComputed(schema, enriched, linked) {
996
- for (const [key, field] of Object.entries(schema.fields)) {
997
- if (field.type === "toggle" && field.field) enriched[key] = String(enriched[field.field] ?? "") === field.onValue;
998
- if (field.type === "embed" && field.to) {
999
- const targetId = embedTargetId(field, enriched);
1000
- enriched[key] = targetId && linked[field.to]?.byId[targetId] || null;
1001
- }
1002
- if (field.type === "backlinks") enriched[key] = projectBacklinks(field, schema, enriched, linked);
1003
- }
1004
- return enriched;
1005
- }
1006
- /** Resolve every rollup field onto a COPY of the record, BEFORE the
1007
- * formula pass — a `derived` formula may reference rollup values as
1008
- * plain identifiers (`played = homePlayed + awayPlayed`). Same reverse
1009
- * machinery as backlinks, collapsed to a number: an unresolvable source
1010
- * is null (a formula reading it fails soft to em-dash); an empty match
1011
- * set is a real 0. Returns `record` unchanged when the schema declares
1012
- * no rollups. */
1013
- function projectRollups(schema, record, linked) {
1014
- let out = record;
1015
- for (const [key, field] of Object.entries(schema.fields)) {
1016
- if (field.type !== "rollup") continue;
1017
- if (out === record) out = { ...record };
1018
- const source = linked[field.from];
1019
- const selfId = String(record[schema.primaryKey] ?? "");
1020
- out[key] = source ? rollupValue(field, selfId, Object.values(source.byId)) : null;
1021
- }
1022
- return out;
1023
- }
1024
- /** Enrich records with every host-computed field: derived formulas
1025
- * evaluated (cross-collection derefs included), toggles projected,
1026
- * embeds resolved. Loads each linked collection ONCE per call. Input
1027
- * records are not mutated. */
1028
- async function enrichItems(collection, items, opts = {}) {
1029
- const { schema } = collection;
1030
- const linked = await loadLinkedTargets(schema, opts);
1031
- const refRecords = toRefRecords(linked);
1032
- return items.map((item) => projectComputed(schema, deriveAll(schema, projectRollups(schema, item, linked), refRecords), linked));
1033
- }
1034
- //#endregion
1035
560
  //#region src/collection/server/dynamicIcon.ts
1036
561
  /** Index `items` by their `primaryKey` value — the `recordsById` map a
1037
562
  * `valueFrom` reference (e.g. `_config.defaultCity`) resolves against.
@@ -1252,7 +777,8 @@ function logSpawnSkip(slug, triggerField, every, sourceItem, sourceId) {
1252
777
  * predicate doesn't match, the trigger date is unparseable, or the
1253
778
  * successor already exists (create-if-absent). Never overwrites an
1254
779
  * existing successor — protects any edits the user made to it. */
1255
- async function maybeSpawnSuccessor(slug, schema, dataDir, sourceItem, sourceId, ioOpts = {}) {
780
+ async function maybeSpawnSuccessor(collection, sourceItem, sourceId, ioOpts = {}) {
781
+ const { slug, schema } = collection;
1256
782
  const { spawn } = schema;
1257
783
  if (!spawn || !schema.triggerField) return;
1258
784
  if (!matchesWhen(spawn.when, schema, sourceItem)) return;
@@ -1270,11 +796,15 @@ async function maybeSpawnSuccessor(slug, schema, dataDir, sourceItem, sourceId,
1270
796
  return;
1271
797
  }
1272
798
  try {
1273
- const result = await writeItem(dataDir, computed.id, computed.record, {
1274
- ...ioOpts,
1275
- refuseOverwrite: true,
1276
- slug
1277
- });
799
+ const { write } = storeFor(collection, ioOpts);
800
+ if (!write) {
801
+ log.warn("collections", "spawn skipped: collection store is read-only", {
802
+ slug,
803
+ sourceId
804
+ });
805
+ return;
806
+ }
807
+ const result = await write(computed.id, computed.record, { refuseOverwrite: true });
1278
808
  if (result.kind === "ok") log.info("collections", "spawned successor", {
1279
809
  slug,
1280
810
  sourceId,
@@ -1332,7 +862,8 @@ function deleteTargets(collection, workspaceRoot) {
1332
862
  return [
1333
863
  stagingSkillDir(workspaceRoot, collection.slug),
1334
864
  collection.skillDir,
1335
- collection.dataDir
865
+ collection.dataDir,
866
+ ...collection.storageFile !== void 0 ? [collection.storageFile] : []
1336
867
  ];
1337
868
  }
1338
869
  /** The records directory the delete recursively archives + removes
@@ -1364,6 +895,11 @@ function isDataDirSafe(dataDir, slug, workspaceRoot) {
1364
895
  * `dataSource` collection has no record files to copy (its rows live in
1365
896
  * the external data file, which the delete never touches). */
1366
897
  function restoreRecordsStep(schema) {
898
+ if (schema.storage !== void 0) return `2. Records: copy the archived database file
899
+ \`${path.basename(schema.storage.path)}\` (next to this document) back to
900
+ \`${schema.storage.path}\` (workspace-relative, \`cp\`). It holds every
901
+ record. If \`-wal\`/\`-journal\` sidecar files were archived alongside
902
+ it, copy them back too.`;
1367
903
  if (schema.dataPath === void 0) return `2. Records: nothing to copy. This is a \`dataSource\` collection —
1368
904
  its records are the rows of \`${schema.dataSource?.path}\`, which the
1369
905
  delete never touched.`;
@@ -1400,7 +936,7 @@ ${restoreRecordsStep(schema)}
1400
936
 
1401
937
  - slug: \`${slug}\`
1402
938
  - title: ${schema.title}
1403
- - dataPath: \`${schema.dataPath ?? `(dataSource) ${schema.dataSource?.path}`}\`
939
+ - dataPath: \`${schema.dataPath ?? (schema.storage !== void 0 ? `(storage) ${schema.storage.path}` : `(dataSource) ${schema.dataSource?.path}`)}\`
1404
940
  `;
1405
941
  }
1406
942
  /** Copy one skill copy + the records + RESTORE.md into `archiveDir`. */
@@ -1408,6 +944,18 @@ async function writeArchive(collection, archiveDir, workspaceRoot) {
1408
944
  const staging = stagingSkillDir(workspaceRoot, collection.slug);
1409
945
  await cp(await pathExists(staging) ? staging : collection.skillDir, path.join(archiveDir, "skill"), { recursive: true });
1410
946
  if (await pathExists(collection.dataDir)) await cp(collection.dataDir, path.join(archiveDir, "records"), { recursive: true });
947
+ if (collection.storageFile !== void 0 && await pathExists(collection.storageFile)) {
948
+ const checkpointed = await checkpointSqliteDatabase(collection.storageFile);
949
+ await cp(collection.storageFile, path.join(archiveDir, path.basename(collection.storageFile)));
950
+ if (!checkpointed) for (const suffix of [
951
+ "-wal",
952
+ "-journal",
953
+ "-shm"
954
+ ]) {
955
+ const sidecar = `${collection.storageFile}${suffix}`;
956
+ if (await pathExists(sidecar)) await cp(sidecar, path.join(archiveDir, path.basename(sidecar)));
957
+ }
958
+ }
1411
959
  await writeFile(path.join(archiveDir, "RESTORE.md"), buildRestoreDoc(collection), "utf-8");
1412
960
  }
1413
961
  /** Remove all three locations. `rm -rf`-style (force) so a missing dir
@@ -1427,6 +975,12 @@ async function removeLocations(collection, workspaceRoot) {
1427
975
  force: true
1428
976
  });
1429
977
  await rmdir(path.dirname(collection.dataDir)).catch(() => void 0);
978
+ if (collection.storageFile !== void 0) {
979
+ await rm(collection.storageFile, { force: true });
980
+ await rm(`${collection.storageFile}-wal`, { force: true });
981
+ await rm(`${collection.storageFile}-journal`, { force: true });
982
+ await rm(`${collection.storageFile}-shm`, { force: true });
983
+ }
1430
984
  }
1431
985
  async function deleteCollection(collection, opts = {}) {
1432
986
  const { slug } = collection;
@@ -1685,13 +1239,13 @@ function computedKeyProblem(record, schema) {
1685
1239
  * `toggle` value, and re-writing it would perpetuate a forged
1686
1240
  * host-computed value. A merge heals the record instead.
1687
1241
  *
1688
- * readItem THROWS on a malformed stored file (only ENOENT is null) —
1242
+ * the store read THROWS on a malformed stored file (only ENOENT is null) —
1689
1243
  * downgraded to a per-row rejection here, like loadRequestedItems'
1690
1244
  * `missing`, so one broken file can't abort the whole putItems batch. */
1691
- async function mergeWithExisting(collection, record, itemId, deps) {
1245
+ async function mergeWithExisting(collection, store, record, itemId) {
1692
1246
  let existing;
1693
1247
  try {
1694
- existing = await readItem(collection.dataDir, itemId, { workspaceRoot: deps.workspaceRoot });
1248
+ existing = await store.read(itemId);
1695
1249
  } catch {
1696
1250
  return `'${itemId}' has a malformed stored file — mode "merge" needs to read it; fix the file (Read → correct → Write) or replace it whole with "upsert"`;
1697
1251
  }
@@ -1705,7 +1259,7 @@ async function mergeWithExisting(collection, record, itemId, deps) {
1705
1259
  ...record
1706
1260
  };
1707
1261
  }
1708
- async function putOneItem(collection, record, mode, deps) {
1262
+ async function putOneItem(collection, store, write, record, mode, deps) {
1709
1263
  const { schema } = collection;
1710
1264
  const itemId = resolveCreateItemId(schema, record);
1711
1265
  const reject = (about, problem) => ({ rejected: {
@@ -1717,7 +1271,7 @@ async function putOneItem(collection, record, mode, deps) {
1717
1271
  if (computed) return reject(itemId, computed);
1718
1272
  let toWrite = record;
1719
1273
  if (mode === "merge") {
1720
- const merged = await mergeWithExisting(collection, record, itemId, deps);
1274
+ const merged = await mergeWithExisting(collection, store, record, itemId);
1721
1275
  if (typeof merged === "string") return reject(itemId, merged);
1722
1276
  toWrite = merged;
1723
1277
  }
@@ -1725,11 +1279,7 @@ async function putOneItem(collection, record, mode, deps) {
1725
1279
  const invalid = validateRecordObject(toWrite, itemId, schema);
1726
1280
  if (invalid) return reject(itemId, invalid);
1727
1281
  }
1728
- const result = await writeItem(collection.dataDir, itemId, toWrite, {
1729
- refuseOverwrite: mode === "create",
1730
- workspaceRoot: deps.workspaceRoot,
1731
- slug: collection.slug
1732
- });
1282
+ const result = await write(itemId, toWrite, { refuseOverwrite: mode === "create" });
1733
1283
  if (result.kind === "ok") return { written: result.itemId };
1734
1284
  if (result.kind === "invalid-id") return reject(itemId, `'${itemId}' is not a valid record id (letters/digits at the ends; -, _, or . inside; no '..' or path characters)`);
1735
1285
  if (result.kind === "conflict") return reject(itemId, `'${itemId}' already exists — mode "create" refuses overwrite; use "upsert" to update it`);
@@ -1737,25 +1287,14 @@ async function putOneItem(collection, record, mode, deps) {
1737
1287
  }
1738
1288
  /** Aggregation over a collection via the structured query DSL
1739
1289
  * (`core/queryZ.ts`) — the paved road for counts / sums / group-bys
1740
- * that a row listing can't answer honestly. Two engines behind one
1741
- * shape: a dataSource collection queries its CSV natively through the
1742
- * store (`store.query`, uncapped whole-file scan); a file-backed
1743
- * collection aggregates its ENRICHED records (computed fields
1744
- * `derived` / `rollup` / `toggle` — are real columns) through the same
1745
- * compiled SQL over a temp JSONL (`runQueryOverRows`). */
1290
+ * that a row listing can't answer honestly. The engine choice (native
1291
+ * store query vs enrich-then-JSONL fallback) lives in
1292
+ * `runCollectionQuery`, shared with the desktop custom view's `/query`
1293
+ * route so the two surfaces can never drift. */
1746
1294
  async function handleQueryItems(collection, queryArg, deps) {
1747
1295
  const parsed = CollectionQueryZ.safeParse(queryArg);
1748
1296
  if (!parsed.success) return `manageCollection: \`query\` rejected — fix and retry:\n${parsed.error.issues.slice(0, 20).map((issue) => `- ${issue.path.map(String).join(".") || "(root)"}: ${defangForPrompt(issue.message)}`).join("\n")}`;
1749
- const store = storeFor(collection, { workspaceRoot: deps.workspaceRoot });
1750
- if (store.query) {
1751
- const rows = await store.query(parsed.data);
1752
- return JSON.stringify({
1753
- collection: collection.slug,
1754
- count: rows.length,
1755
- rows
1756
- });
1757
- }
1758
- const rows = await runQueryOverRows(await enrichItems(collection, await store.list(), deps), parsed.data);
1297
+ const rows = await runCollectionQuery(collection, parsed.data, { workspaceRoot: deps.workspaceRoot });
1759
1298
  return JSON.stringify({
1760
1299
  collection: collection.slug,
1761
1300
  count: rows.length,
@@ -1763,11 +1302,13 @@ async function handleQueryItems(collection, queryArg, deps) {
1763
1302
  });
1764
1303
  }
1765
1304
  async function handlePutItems(collection, args, deps) {
1766
- if (!collectionWritable(collection)) return `manageCollection: ${readOnlyRefusal(collection.slug)} (its records are the rows of '${collection.schema.dataSource?.path}'; edit that file to change the data).`;
1305
+ const store = storeFor(collection, { workspaceRoot: deps.workspaceRoot });
1306
+ const { write } = store;
1307
+ if (!write) return `manageCollection: ${readOnlyRefusal(collection.slug)} (its records are the rows of '${collection.schema.dataSource?.path}'; edit that file to change the data).`;
1767
1308
  const written = [];
1768
1309
  const rejected = [];
1769
1310
  for (const record of args.items) {
1770
- const outcome = await putOneItem(collection, record, args.mode, deps);
1311
+ const outcome = await putOneItem(collection, store, write, record, args.mode, deps);
1771
1312
  if (outcome.written) written.push(outcome.written);
1772
1313
  if (outcome.rejected) rejected.push(outcome.rejected);
1773
1314
  }
@@ -1983,6 +1524,6 @@ function makeManageCollectionTool(deps = {}) {
1983
1524
  };
1984
1525
  }
1985
1526
  //#endregion
1986
- export { readOnlyRefusal as A, CollectionQueryZ as B, firstMutateParamProblem as C, recordFieldProblem as D, compileRecordZ as E, dedupeByRecordId as F, MAX_QUERY_ROWS as H, encodeCsvRecordId as I, normalizeCsvValue as L, MAX_CSV_ROWS as M, csvRowToItem as N, runQueryOverRows as O, decodeCsvRecordId as P, compileCsvQuery as R, applyMutateAction as S, validateRecordObject as T, DEFAULT_QUERY_ROWS as V, errorMessage as _, deleteCollection as a, buildWorkspaceOntology as b, computeSuccessor as c, isTriggerDue as d, maybeSpawnSuccessor as f, ONE_SECOND_MS as g, successorId as h, deleteCustomView as i, storeFor as j, collectionWritable as k, daysInMonth as l, resolveEvery as m, MAX_UNSELECTIVE_ITEMS as n, deleteCollectionRefusalMessage as o, parseCivil as p, makeManageCollectionTool as r, advanceTriggerDate as s, MAX_SCHEMA_ISSUES as t, formatCivil as u, computeCollectionIcon as v, validateCollectionRecords as w, schemaRelations as x, enrichItems as y, compileJsonlQuery as z };
1527
+ export { validateCollectionRecords as C, runCollectionQuery as D, recordFieldProblem as E, enrichItems as O, firstMutateParamProblem as S, compileRecordZ as T, errorMessage as _, deleteCollection as a, schemaRelations as b, computeSuccessor as c, isTriggerDue as d, maybeSpawnSuccessor as f, ONE_SECOND_MS as g, successorId as h, deleteCustomView as i, runQueryOverRows as k, daysInMonth as l, resolveEvery as m, MAX_UNSELECTIVE_ITEMS as n, deleteCollectionRefusalMessage as o, parseCivil as p, makeManageCollectionTool as r, advanceTriggerDate as s, MAX_SCHEMA_ISSUES as t, formatCivil as u, computeCollectionIcon as v, validateRecordObject as w, applyMutateAction as x, buildWorkspaceOntology as y };
1987
1528
 
1988
- //# sourceMappingURL=server-DZH7OjOD.js.map
1529
+ //# sourceMappingURL=server-BI3wnTwl.js.map