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