@mulmoclaude/core 0.25.0 → 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/assets/helps/billing-clients-worklog.md +3 -2
- package/assets/helps/collection-skills.md +54 -1
- package/assets/helps/custom-view.md +37 -3
- package/assets/helps/error-recovery.md +30 -0
- package/assets/helps/todo-collection.md +2 -2
- package/dist/collection/core/fieldText.d.ts +11 -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 +18 -10
- package/dist/collection/index.cjs.map +1 -1
- package/dist/collection/index.d.ts +2 -0
- package/dist/collection/index.js +15 -11
- 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 +167 -43
- package/dist/collection-watchers/index.cjs.map +1 -1
- package/dist/collection-watchers/index.d.ts +1 -1
- package/dist/collection-watchers/index.js +168 -45
- 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/{promptSafety-Boz5qOs-.js → promptSafety-B5KPkaNY.js} +32 -9
- package/dist/promptSafety-B5KPkaNY.js.map +1 -0
- package/dist/{promptSafety-Co4cYQ_Z.cjs → promptSafety-BOc1B3im.cjs} +43 -8
- package/dist/promptSafety-BOc1B3im.cjs.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-DLRz-U7F.cjs → server-B7mtx4Cd.cjs} +302 -785
- package/dist/server-B7mtx4Cd.cjs.map +1 -0
- package/dist/{server-DZH7OjOD.js → server-CNLZZONa.js} +292 -696
- package/dist/server-CNLZZONa.js.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/promptSafety-Boz5qOs-.js.map +0 -1
- package/dist/promptSafety-Co4cYQ_Z.cjs.map +0 -1
- package/dist/server-DLRz-U7F.cjs.map +0 -1
- package/dist/server-DZH7OjOD.js.map +0 -1
|
@@ -1,525 +1,11 @@
|
|
|
1
|
-
import {
|
|
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-
|
|
3
|
-
import {
|
|
1
|
+
import { f as storageKindFor, l as embedTargetId, o as COMPUTED_TYPES, u as isFieldDrivenEvery } from "./ids-P2T89Gou.js";
|
|
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-B5KPkaNY.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,
|
|
7
|
-
import {
|
|
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
|
|
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
|
|
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.
|
|
883
|
-
*
|
|
884
|
-
* readdir count would always misreport 0. Fail-soft to 0 like
|
|
885
|
-
* `countRecordFiles` (an unreadable file / missing
|
|
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 (
|
|
889
|
-
return (await storeFor(collection, { workspaceRoot }).
|
|
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(
|
|
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
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
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
|
-
*
|
|
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
|
|
1245
|
+
async function mergeWithExisting(collection, store, record, itemId) {
|
|
1692
1246
|
let existing;
|
|
1693
1247
|
try {
|
|
1694
|
-
existing = await
|
|
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
|
|
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
|
|
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.
|
|
1741
|
-
*
|
|
1742
|
-
*
|
|
1743
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
}
|
|
@@ -1777,6 +1318,44 @@ async function handlePutItems(collection, args, deps) {
|
|
|
1777
1318
|
rejected
|
|
1778
1319
|
});
|
|
1779
1320
|
}
|
|
1321
|
+
/** Delete records by id, through the store — so it works on any writable
|
|
1322
|
+
* backend, not just file records. Same read-only refusal as putItems (an
|
|
1323
|
+
* absent `delete` IS the refusal), and the same per-id result shape so a
|
|
1324
|
+
* partially-bad batch reports per id instead of failing whole. */
|
|
1325
|
+
async function handleDeleteItems(collection, ids, deps) {
|
|
1326
|
+
const { delete: removeItem } = storeFor(collection, { workspaceRoot: deps.workspaceRoot });
|
|
1327
|
+
if (!removeItem) return `manageCollection: ${readOnlyRefusal(collection.slug)} (its records are the rows of '${collection.schema.dataSource?.path}'; edit that file to change the data).`;
|
|
1328
|
+
const deleted = [];
|
|
1329
|
+
const rejected = [];
|
|
1330
|
+
for (const itemId of ids) {
|
|
1331
|
+
const outcome = await deleteOneItem(removeItem, itemId);
|
|
1332
|
+
if (outcome.deleted) deleted.push(outcome.deleted);
|
|
1333
|
+
if (outcome.rejected) rejected.push(outcome.rejected);
|
|
1334
|
+
}
|
|
1335
|
+
return JSON.stringify({
|
|
1336
|
+
collection: collection.slug,
|
|
1337
|
+
deleted,
|
|
1338
|
+
rejected
|
|
1339
|
+
});
|
|
1340
|
+
}
|
|
1341
|
+
/** A missing id is a rejection, not a silent success: deleting by a typo'd
|
|
1342
|
+
* id would otherwise report "done" while the real record survives. */
|
|
1343
|
+
async function deleteOneItem(removeItem, itemId) {
|
|
1344
|
+
const result = await removeItem(itemId);
|
|
1345
|
+
if (result.kind === "ok") return { deleted: result.itemId };
|
|
1346
|
+
const reject = (problem) => ({ rejected: {
|
|
1347
|
+
id: defangForPrompt(itemId),
|
|
1348
|
+
problem: defangForPrompt(problem)
|
|
1349
|
+
} });
|
|
1350
|
+
if (result.kind === "invalid-id") return reject(`'${itemId}' is not a valid record id (letters/digits at the ends; -, _, or . inside; no '..' or path characters)`);
|
|
1351
|
+
if (result.kind === "not-found") return reject(`'${itemId}' not found — nothing was deleted; confirm the id with getItems`);
|
|
1352
|
+
return reject("delete refused: the collection's data dir escapes the workspace");
|
|
1353
|
+
}
|
|
1354
|
+
function parseDeleteIds(args) {
|
|
1355
|
+
const { ids } = args;
|
|
1356
|
+
if (!(Array.isArray(ids) && ids.length > 0 && ids.every((entry) => typeof entry === "string" && entry.trim().length > 0))) return "manageCollection: `ids` is required for deleteItems — a non-empty array of record ids.";
|
|
1357
|
+
return ids;
|
|
1358
|
+
}
|
|
1780
1359
|
function parsePutItems(args, slug) {
|
|
1781
1360
|
const { items, mode } = args;
|
|
1782
1361
|
if (!(Array.isArray(items) && items.length > 0 && items.every((entry) => Boolean(entry) && typeof entry === "object" && !Array.isArray(entry)))) return "manageCollection: `items` is required for putItems — a non-empty array of record objects.";
|
|
@@ -1885,7 +1464,7 @@ async function handlePutSchema(slug, schemaArg, deps) {
|
|
|
1885
1464
|
written: true
|
|
1886
1465
|
});
|
|
1887
1466
|
}
|
|
1888
|
-
var MANAGE_COLLECTION_PROMPT = "Use `manageCollection` instead of raw Read/Write/Edit when working with a collection's records OR its schema (raw file I/O stays available as the escape hatch). Before authoring or changing a collection's `schema.json`, call `schemaDocs` to load the field/DSL reference, then read with `getSchema` and write with `putSchema` — `putSchema` validates the whole schema before writing and returns actionable errors instead of silently failing discovery's validation. `getItems` is the only way to see computed values — `derived` fields (e.g. a portfolio's value), `toggle` projections, and `embed` records are host-computed and never present in the stored JSON files. On large collections pass `ids` and/or `fields` to keep the result small. For a question that spans collections (\"which clients have unpaid invoices?\"), start with `getOntology`: it lists every collection with its primaryKey, record count, and outbound `ref`/`embed` relations, so you know which collections to join before reading any records. `putItems` validates every row against the schema before writing (required fields, enum values, primaryKey = record id) and returns `{ written, rejected }`; fix each rejected row using its `problem` text and retry just those rows. Never include computed fields in a row you write. To update a few fields of an existing record, use `mode: \"merge\"` with a partial row ({ id, <changed fields> }) — the default upsert replaces the WHOLE record, so a partial upsert would silently erase every optional field it omits. Answer aggregation questions (counts, sums, averages, group-bys) with `queryItems` on ANY collection — on a dataSource (CSV) collection it scans the whole file (getItems is row-capped, so aggregates computed from its output can be silently wrong on large files); on a file-backed collection it aggregates the enriched records, so computed fields (derived/rollup/toggle) are queryable columns.";
|
|
1467
|
+
var MANAGE_COLLECTION_PROMPT = "Use `manageCollection` instead of raw Read/Write/Edit when working with a collection's records OR its schema (raw file I/O stays available as the escape hatch). Before authoring or changing a collection's `schema.json`, call `schemaDocs` to load the field/DSL reference, then read with `getSchema` and write with `putSchema` — `putSchema` validates the whole schema before writing and returns actionable errors instead of silently failing discovery's validation. `getItems` is the only way to see computed values — `derived` fields (e.g. a portfolio's value), `toggle` projections, and `embed` records are host-computed and never present in the stored JSON files. On large collections pass `ids` and/or `fields` to keep the result small. For a question that spans collections (\"which clients have unpaid invoices?\"), start with `getOntology`: it lists every collection with its primaryKey, record count, and outbound `ref`/`embed` relations, so you know which collections to join before reading any records. `putItems` validates every row against the schema before writing (required fields, enum values, primaryKey = record id) and returns `{ written, rejected }`; fix each rejected row using its `problem` text and retry just those rows. Never include computed fields in a row you write. To update a few fields of an existing record, use `mode: \"merge\"` with a partial row ({ id, <changed fields> }) — the default upsert replaces the WHOLE record, so a partial upsert would silently erase every optional field it omits. `deleteItems` removes records by id and returns `{ deleted, rejected }`; an id that doesn't exist comes back rejected rather than counted as deleted, so check `rejected` before reporting a deletion as done. Answer aggregation questions (counts, sums, averages, group-bys) with `queryItems` on ANY collection — on a dataSource (CSV) collection it scans the whole file (getItems is row-capped, so aggregates computed from its output can be silently wrong on large files); on a file-backed collection it aggregates the enriched records, so computed fields (derived/rollup/toggle) are queryable columns.";
|
|
1889
1468
|
/** Validate getItems' optional `ids`/`fields` args, then delegate. */
|
|
1890
1469
|
async function dispatchGetItems(collection, args, deps) {
|
|
1891
1470
|
const ids = optionalStringArray(args.ids, "ids");
|
|
@@ -1898,6 +1477,26 @@ async function dispatchGetItems(collection, args, deps) {
|
|
|
1898
1477
|
fields: fields.value
|
|
1899
1478
|
}, deps);
|
|
1900
1479
|
}
|
|
1480
|
+
/** Actions that operate on a collection's RECORDS — i.e. the ones that
|
|
1481
|
+
* need the collection loaded first. Schema/workspace actions don't. */
|
|
1482
|
+
var RECORD_ACTIONS = /* @__PURE__ */ new Set([
|
|
1483
|
+
"getItems",
|
|
1484
|
+
"putItems",
|
|
1485
|
+
"deleteItems",
|
|
1486
|
+
"queryItems"
|
|
1487
|
+
]);
|
|
1488
|
+
/** Record-action dispatch, split from `manageCollectionHandler` to keep
|
|
1489
|
+
* both within the cognitive-complexity budget. */
|
|
1490
|
+
async function dispatchRecordAction(action, collection, args, deps) {
|
|
1491
|
+
if (action === "getItems") return dispatchGetItems(collection, args, deps);
|
|
1492
|
+
if (action === "queryItems") return handleQueryItems(collection, args.query, deps);
|
|
1493
|
+
if (action === "deleteItems") {
|
|
1494
|
+
const ids = parseDeleteIds(args);
|
|
1495
|
+
return typeof ids === "string" ? ids : handleDeleteItems(collection, ids, deps);
|
|
1496
|
+
}
|
|
1497
|
+
const parsed = parsePutItems(args, collection.slug);
|
|
1498
|
+
return typeof parsed === "string" ? parsed : handlePutItems(collection, parsed, deps);
|
|
1499
|
+
}
|
|
1901
1500
|
async function manageCollectionHandler(deps, args) {
|
|
1902
1501
|
const action = typeof args.action === "string" ? args.action : "";
|
|
1903
1502
|
if (action === "schemaDocs") return handleSchemaDocs(deps);
|
|
@@ -1906,18 +1505,14 @@ async function manageCollectionHandler(deps, args) {
|
|
|
1906
1505
|
if (!slug) return "manageCollection: `slug` is required (the collection's slug).";
|
|
1907
1506
|
if (action === "getSchema") return handleGetSchema(slug, deps);
|
|
1908
1507
|
if (action === "putSchema") return handlePutSchema(slug, args.schema, deps);
|
|
1909
|
-
if (action
|
|
1508
|
+
if (!RECORD_ACTIONS.has(action)) return "manageCollection: `action` must be \"getItems\", \"putItems\", \"deleteItems\", \"queryItems\", \"getOntology\", \"schemaDocs\", \"getSchema\", or \"putSchema\".";
|
|
1910
1509
|
const collection = await loadCollection(slug, deps);
|
|
1911
1510
|
if (!collection) return unknownCollection(slug);
|
|
1912
|
-
|
|
1913
|
-
if (action === "queryItems") return handleQueryItems(collection, args.query, deps);
|
|
1914
|
-
const parsed = parsePutItems(args, slug);
|
|
1915
|
-
if (typeof parsed === "string") return parsed;
|
|
1916
|
-
return handlePutItems(collection, parsed, deps);
|
|
1511
|
+
return dispatchRecordAction(action, collection, args, deps);
|
|
1917
1512
|
}
|
|
1918
1513
|
var MANAGE_COLLECTION_DEFINITION = {
|
|
1919
1514
|
name: "manageCollection",
|
|
1920
|
-
description: "Read and write a schema-driven collection through the host — both its records and its structure. getItems returns records WITH computed values (derived formulas, toggles, embeds) the stored JSON files don't contain; putItems validates each row against the schema before writing. getOntology maps the whole workspace: every collection with its record count and outbound ref/embed relations — call it first for cross-collection questions. schemaDocs returns the collection-authoring reference; getSchema/putSchema read and validate-then-write the collection's schema.json. Prefer it over raw file I/O on collections.",
|
|
1515
|
+
description: "Read and write a schema-driven collection through the host — both its records and its structure. getItems returns records WITH computed values (derived formulas, toggles, embeds) the stored JSON files don't contain; putItems validates each row against the schema before writing; deleteItems removes records by id. getOntology maps the whole workspace: every collection with its record count and outbound ref/embed relations — call it first for cross-collection questions. schemaDocs returns the collection-authoring reference; getSchema/putSchema read and validate-then-write the collection's schema.json. Prefer it over raw file I/O on collections.",
|
|
1921
1516
|
inputSchema: {
|
|
1922
1517
|
type: "object",
|
|
1923
1518
|
properties: {
|
|
@@ -1926,6 +1521,7 @@ var MANAGE_COLLECTION_DEFINITION = {
|
|
|
1926
1521
|
enum: [
|
|
1927
1522
|
"getItems",
|
|
1928
1523
|
"putItems",
|
|
1524
|
+
"deleteItems",
|
|
1929
1525
|
"queryItems",
|
|
1930
1526
|
"getOntology",
|
|
1931
1527
|
"schemaDocs",
|
|
@@ -1941,7 +1537,7 @@ var MANAGE_COLLECTION_DEFINITION = {
|
|
|
1941
1537
|
ids: {
|
|
1942
1538
|
type: "array",
|
|
1943
1539
|
items: { type: "string" },
|
|
1944
|
-
description: "getItems: only these record ids (primary-key values)
|
|
1540
|
+
description: "getItems: only these record ids (primary-key values); omit for all records. deleteItems: the record ids to delete — required, and never optional there."
|
|
1945
1541
|
},
|
|
1946
1542
|
fields: {
|
|
1947
1543
|
type: "array",
|
|
@@ -1983,6 +1579,6 @@ function makeManageCollectionTool(deps = {}) {
|
|
|
1983
1579
|
};
|
|
1984
1580
|
}
|
|
1985
1581
|
//#endregion
|
|
1986
|
-
export {
|
|
1582
|
+
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
1583
|
|
|
1988
|
-
//# sourceMappingURL=server-
|
|
1584
|
+
//# sourceMappingURL=server-CNLZZONa.js.map
|