@mulmoclaude/core 0.24.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/assets/helps/google-calendar-collection.md +79 -0
- package/assets/helps/index.md +1 -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 +45 -0
- package/dist/collection/index.cjs +18 -14
- package/dist/collection/index.cjs.map +1 -1
- package/dist/collection/index.d.ts +1 -0
- package/dist/collection/index.js +4 -2
- package/dist/collection/index.js.map +1 -1
- package/dist/collection/paths.cjs +5 -55
- package/dist/collection/paths.js +1 -51
- package/dist/collection/registry/server/index.cjs +20 -19
- package/dist/collection/registry/server/index.cjs.map +1 -1
- package/dist/collection/registry/server/index.js +3 -2
- package/dist/collection/registry/server/index.js.map +1 -1
- 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 +58 -54
- package/dist/collection/server/index.d.ts +1 -0
- package/dist/collection/server/index.js +5 -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 +131 -44
- package/dist/collection-watchers/index.cjs.map +1 -1
- package/dist/collection-watchers/index.d.ts +1 -1
- package/dist/collection-watchers/index.js +125 -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/{server-BpEo0FOd.js → discovery-QUi_7aNG.js} +414 -1495
- package/dist/discovery-QUi_7aNG.js.map +1 -0
- package/dist/{server-CkysfLh4.cjs → discovery-cwtfyDxl.cjs} +463 -1658
- package/dist/discovery-cwtfyDxl.cjs.map +1 -0
- package/dist/feeds/index.cjs +4 -4
- package/dist/feeds/index.js +2 -2
- package/dist/feeds/server/index.cjs +35 -22
- package/dist/feeds/server/index.cjs.map +1 -1
- package/dist/feeds/server/index.js +26 -13
- package/dist/feeds/server/index.js.map +1 -1
- package/dist/google/apiClient.d.ts +8 -1
- package/dist/google/calendar.d.ts +35 -0
- package/dist/google/calendarSyncStore.d.ts +7 -0
- package/dist/google/collectionSync.d.ts +70 -0
- package/dist/google/index.cjs +294 -4
- package/dist/google/index.cjs.map +1 -1
- package/dist/google/index.d.ts +4 -2
- package/dist/google/index.js +279 -5
- package/dist/google/index.js.map +1 -1
- package/dist/ids-Dl9Ij63K.cjs +163 -0
- package/dist/ids-Dl9Ij63K.cjs.map +1 -0
- package/dist/ids-P2T89Gou.js +92 -0
- package/dist/ids-P2T89Gou.js.map +1 -0
- package/dist/{ingestTypes-CFEgTItj.js → ingestTypes-C-IQB4nw.js} +2 -2
- package/dist/{ingestTypes-CFEgTItj.js.map → ingestTypes-C-IQB4nw.js.map} +1 -1
- package/dist/{ingestTypes-Stc7iQyA.cjs → ingestTypes-DZpAC3N_.cjs} +3 -3
- package/dist/{ingestTypes-Stc7iQyA.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-BLUcImzd.js → promptSafety-Boz5qOs-.js} +2 -84
- package/dist/promptSafety-Boz5qOs-.js.map +1 -0
- package/dist/{promptSafety-DI_1Rvhk.cjs → promptSafety-Co4cYQ_Z.cjs} +1 -149
- package/dist/promptSafety-Co4cYQ_Z.cjs.map +1 -0
- package/dist/remote-host/server/index.cjs +6 -4
- package/dist/remote-host/server/index.cjs.map +1 -1
- package/dist/remote-host/server/index.js +6 -4
- package/dist/remote-host/server/index.js.map +1 -1
- 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/scheduler/index.cjs +3 -1
- package/dist/scheduler/index.cjs.map +1 -1
- package/dist/scheduler/index.js +3 -1
- package/dist/scheduler/index.js.map +1 -1
- package/dist/server-BI3wnTwl.js +1529 -0
- package/dist/server-BI3wnTwl.js.map +1 -0
- package/dist/server-CKdWENZ-.cjs +1704 -0
- package/dist/server-CKdWENZ-.cjs.map +1 -0
- package/dist/skill-bridge/index.cjs +2 -2
- package/dist/skill-bridge/index.cjs.map +1 -1
- package/dist/skill-bridge/index.js +1 -1
- package/dist/templatePath-27vUZowm.cjs +75 -0
- package/dist/templatePath-27vUZowm.cjs.map +1 -0
- package/dist/templatePath-k_WNbL_Q.js +52 -0
- package/dist/templatePath-k_WNbL_Q.js.map +1 -0
- package/package.json +1 -1
- package/dist/collection/paths.cjs.map +0 -1
- package/dist/collection/paths.js.map +0 -1
- package/dist/promptSafety-BLUcImzd.js.map +0 -1
- package/dist/promptSafety-DI_1Rvhk.cjs.map +0 -1
- package/dist/server-BpEo0FOd.js.map +0 -1
- package/dist/server-CkysfLh4.cjs.map +0 -1
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
1
|
+
import { a as AGENT_INGEST_KIND, c as INGEST_KINDS, d as isReadOnlySchema, f as storageKindFor, i as isSafeSlug, n as SAFE_SLUG_PATTERN, o as COMPUTED_TYPES, r as isSafeRecordId, s as FEED_SCHEDULES, t as SAFE_RECORD_ID_PATTERN } from "./ids-P2T89Gou.js";
|
|
2
|
+
import { t as projectRecordFields } from "./project-bU98ycsy.js";
|
|
3
|
+
import { n as isSafeCustomViewI18nPath, r as isSafeCustomViewPath, t as isSafeActionTemplatePath } from "./templatePath-k_WNbL_Q.js";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { promises, realpathSync } from "node:fs";
|
|
6
|
-
import {
|
|
7
|
-
import { createHash, randomBytes
|
|
6
|
+
import { lstat, mkdir, open, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
|
|
7
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
8
8
|
import { tmpdir } from "node:os";
|
|
9
9
|
import iconv from "iconv-lite";
|
|
10
10
|
import { z } from "zod";
|
|
@@ -63,7 +63,7 @@ function collectionsRegistriesConfigPath() {
|
|
|
63
63
|
const host = requireHost();
|
|
64
64
|
return host.paths.collectionsRegistriesConfig(host.workspaceRoot);
|
|
65
65
|
}
|
|
66
|
-
function isPresetSlug
|
|
66
|
+
function isPresetSlug(slug) {
|
|
67
67
|
return requireHost().isPresetSlug(slug);
|
|
68
68
|
}
|
|
69
69
|
/** Logger proxy so engine modules can `import { log }` and use it exactly like
|
|
@@ -80,7 +80,7 @@ var log = {
|
|
|
80
80
|
};
|
|
81
81
|
//#endregion
|
|
82
82
|
//#region src/collection/server/paths.ts
|
|
83
|
-
var SCHEMA_FILE
|
|
83
|
+
var SCHEMA_FILE = "schema.json";
|
|
84
84
|
/** Sanitise a user-supplied slug into a safe directory-name leaf.
|
|
85
85
|
* Returns null for anything that fails the slug whitelist OR isn't a
|
|
86
86
|
* basename (i.e. survives `path.basename` round-trip unchanged).
|
|
@@ -937,7 +937,7 @@ async function readHead(absPath, bytes) {
|
|
|
937
937
|
/** Decode the whole file into a UTF-8 cache copy and return its path.
|
|
938
938
|
* Cache key = (path, mtime, size), so a replaced CSV re-decodes and an
|
|
939
939
|
* unchanged one never does. */
|
|
940
|
-
async function pathExists
|
|
940
|
+
async function pathExists(target) {
|
|
941
941
|
try {
|
|
942
942
|
await stat(target);
|
|
943
943
|
return true;
|
|
@@ -964,7 +964,7 @@ async function evictSupersededCache(key, keepBasename) {
|
|
|
964
964
|
async function decodeToCache(absPath, info) {
|
|
965
965
|
const key = createHash("sha256").update(absPath).digest("hex").slice(0, 16);
|
|
966
966
|
const cached = path.join(cacheDir(), `${key}-${Math.trunc(info.mtimeMs)}-${info.size}.csv`);
|
|
967
|
-
if (!await pathExists
|
|
967
|
+
if (!await pathExists(cached)) {
|
|
968
968
|
const whole = await readFile(absPath);
|
|
969
969
|
const encoding = fallbackEncoding(whole);
|
|
970
970
|
const text = iconv.decode(whole, encoding);
|
|
@@ -1047,13 +1047,12 @@ async function queryCsv(sql, params) {
|
|
|
1047
1047
|
connection.disconnectSync();
|
|
1048
1048
|
}
|
|
1049
1049
|
}
|
|
1050
|
-
/** Every row of the CSV as records — capped, deduped, id-encoded. The
|
|
1051
|
-
* key column is pinned to VARCHAR (see `readCsvArgs`). `workspaceRoot`
|
|
1052
|
-
* drives the per-read containment check; omitted, the configured host
|
|
1053
|
-
* root is used. */
|
|
1054
1050
|
async function csvList(absPath, primaryKey, workspaceRoot) {
|
|
1055
1051
|
const utf8Path = await ensureUtf8CsvPath(absPath, workspaceRoot ?? getWorkspaceRoot());
|
|
1056
|
-
if (utf8Path === null) return
|
|
1052
|
+
if (utf8Path === null) return {
|
|
1053
|
+
items: [],
|
|
1054
|
+
truncated: false
|
|
1055
|
+
};
|
|
1057
1056
|
let rows;
|
|
1058
1057
|
try {
|
|
1059
1058
|
rows = await queryCsv(`SELECT * FROM read_csv(${readCsvArgs(primaryKey)}) LIMIT 5001`, [utf8Path]);
|
|
@@ -1063,9 +1062,13 @@ async function csvList(absPath, primaryKey, workspaceRoot) {
|
|
|
1063
1062
|
path: absPath,
|
|
1064
1063
|
primaryKey
|
|
1065
1064
|
});
|
|
1066
|
-
return
|
|
1065
|
+
return {
|
|
1066
|
+
items: [],
|
|
1067
|
+
truncated: false
|
|
1068
|
+
};
|
|
1067
1069
|
}
|
|
1068
|
-
|
|
1070
|
+
const truncated = rows.length > MAX_CSV_ROWS;
|
|
1071
|
+
if (truncated) {
|
|
1069
1072
|
log.warn("collections", "dataSource CSV truncated to row cap", {
|
|
1070
1073
|
path: absPath,
|
|
1071
1074
|
cap: MAX_CSV_ROWS
|
|
@@ -1083,7 +1086,10 @@ async function csvList(absPath, primaryKey, workspaceRoot) {
|
|
|
1083
1086
|
path: absPath,
|
|
1084
1087
|
duplicates: deduped.duplicates
|
|
1085
1088
|
});
|
|
1086
|
-
return
|
|
1089
|
+
return {
|
|
1090
|
+
items: deduped.items,
|
|
1091
|
+
truncated
|
|
1092
|
+
};
|
|
1087
1093
|
}
|
|
1088
1094
|
/** The scan-order ordinal column the last-match read adds. Underscore
|
|
1089
1095
|
* prefix keeps it out of any plausible CSV header namespace; it is
|
|
@@ -1115,287 +1121,357 @@ async function csvRunQuery(absPath, primaryKey, query, workspaceRoot) {
|
|
|
1115
1121
|
return (await queryCsv(sql, [utf8Path, ...params])).map((row) => Object.fromEntries(Object.entries(row).map(([key, value]) => [key, normalizeCsvValue(value)])));
|
|
1116
1122
|
}
|
|
1117
1123
|
//#endregion
|
|
1118
|
-
//#region src/collection/server/
|
|
1119
|
-
/**
|
|
1120
|
-
*
|
|
1121
|
-
*
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
* `dataSource` schema whose `dataSourceFile` failed to resolve yields a
|
|
1132
|
-
* read-only EMPTY store rather than falling back to the (writable) file
|
|
1133
|
-
* store — a half-loaded read-only collection must never become writable. */
|
|
1134
|
-
function storeFor(collection, opts = {}) {
|
|
1135
|
-
if (isReadOnlySchema(collection.schema)) {
|
|
1136
|
-
const file = collection.dataSourceFile;
|
|
1137
|
-
const key = collection.schema.primaryKey;
|
|
1138
|
-
return {
|
|
1139
|
-
capabilities: { writable: false },
|
|
1140
|
-
list: () => file === void 0 ? Promise.resolve([]) : csvList(file, key, opts.workspaceRoot),
|
|
1141
|
-
read: (itemId) => file === void 0 ? Promise.resolve(null) : csvRead(file, key, itemId, opts.workspaceRoot),
|
|
1142
|
-
query: (query) => file === void 0 ? Promise.resolve([]) : csvRunQuery(file, key, query, opts.workspaceRoot)
|
|
1143
|
-
};
|
|
1144
|
-
}
|
|
1124
|
+
//#region src/collection/server/storePage.ts
|
|
1125
|
+
/** Project `fields` (+ the primary key, always) out of each record. Thin
|
|
1126
|
+
* server-typed alias over the shared isomorphic `projectRecordFields`
|
|
1127
|
+
* (../core/project.ts) — kept as the store layer's exported name. */
|
|
1128
|
+
function projectItemFields(items, fields, primaryKey) {
|
|
1129
|
+
return projectRecordFields(items, fields, primaryKey);
|
|
1130
|
+
}
|
|
1131
|
+
/** Slice + project an already-ordered full read into a `ListPage` — the
|
|
1132
|
+
* shared emulation for stores without native paging. Pure, exported for
|
|
1133
|
+
* tests. `limit: 0` is a valid "count only" page. */
|
|
1134
|
+
function pageFromFullRead(items, opts, primaryKey, truncated) {
|
|
1135
|
+
const offset = Math.max(0, opts.offset ?? 0);
|
|
1136
|
+
const end = opts.limit === void 0 ? void 0 : offset + Math.max(0, opts.limit);
|
|
1145
1137
|
return {
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1138
|
+
items: projectItemFields(items.slice(offset, end), opts.fields, primaryKey),
|
|
1139
|
+
total: items.length,
|
|
1140
|
+
truncated
|
|
1149
1141
|
};
|
|
1150
1142
|
}
|
|
1151
1143
|
//#endregion
|
|
1152
|
-
//#region src/collection/server/
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
*
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
if ((query.groupBy?.length ?? 0) > 0) return [];
|
|
1161
|
-
const aggregates = Object.entries(query.aggregates ?? {});
|
|
1162
|
-
return [Object.fromEntries(aggregates.map(([alias, aggregate]) => [alias, aggregate.op === "count" ? 0 : null]))];
|
|
1163
|
-
}
|
|
1164
|
-
/** The SOURCE columns a query reads: groupBy columns, aggregate input
|
|
1165
|
-
* columns, and where fields. (orderBy resolves to groupBy columns or
|
|
1166
|
-
* aggregate ALIASES — never to a new source column — so it adds none.) */
|
|
1167
|
-
function referencedSourceColumns(query) {
|
|
1168
|
-
const columns = new Set(query.groupBy ?? []);
|
|
1169
|
-
for (const aggregate of Object.values(query.aggregates ?? {})) if (aggregate.column !== void 0) columns.add(aggregate.column);
|
|
1170
|
-
for (const cond of query.where ?? []) columns.add(cond.field);
|
|
1171
|
-
return [...columns];
|
|
1172
|
-
}
|
|
1173
|
-
/** Referenced columns that appear in NO row — a freshly-added optional
|
|
1174
|
-
* field, or a derived field whose inputs no record has yet. DuckDB only
|
|
1175
|
-
* infers columns from keys that occur in the JSONL, so without help a
|
|
1176
|
-
* valid query on such a field binder-errors here while the CSV path
|
|
1177
|
-
* (whose header always declares the column) returns NULLs. Padding the
|
|
1178
|
-
* FIRST line with `col: null` makes the column exist (full-scan
|
|
1179
|
-
* inference unions keys across lines) with matching NULL semantics. */
|
|
1180
|
-
function absentReferencedColumns(rows, query) {
|
|
1181
|
-
const present = /* @__PURE__ */ new Set();
|
|
1182
|
-
for (const row of rows) for (const key of Object.keys(row)) present.add(key);
|
|
1183
|
-
return referencedSourceColumns(query).filter((column) => !present.has(column));
|
|
1184
|
-
}
|
|
1185
|
-
/** Run a validated query over enriched collection rows. */
|
|
1186
|
-
async function runQueryOverRows(rows, query) {
|
|
1187
|
-
if (rows.length === 0) return emptyCollectionResult(query);
|
|
1188
|
-
await mkdir(cacheDir(), {
|
|
1189
|
-
recursive: true,
|
|
1190
|
-
mode: 448
|
|
1144
|
+
//#region src/collection/server/sqliteStore.ts
|
|
1145
|
+
var sqliteModule = null;
|
|
1146
|
+
/** Lazy-load node:sqlite once. A runtime without it (Node < 22.5) throws a
|
|
1147
|
+
* clearly-worded error the caller surfaces — never a bare MODULE_NOT_FOUND. */
|
|
1148
|
+
function loadSqlite() {
|
|
1149
|
+
sqliteModule ??= import("node:sqlite").then((mod) => mod, (err) => {
|
|
1150
|
+
sqliteModule = null;
|
|
1151
|
+
throw new Error(`sqlite storage needs the node:sqlite module (Node.js >= 22.5) — this runtime cannot load it: ${String(err)}`);
|
|
1191
1152
|
});
|
|
1192
|
-
|
|
1153
|
+
return sqliteModule;
|
|
1154
|
+
}
|
|
1155
|
+
/** The db file's on-disk state. A symlink or non-regular file is refused
|
|
1156
|
+
* (file-disclosure defense, same rule as io.ts record files); ENOENT is
|
|
1157
|
+
* just "no records yet". Any OTHER lstat failure (EACCES, EIO, …) is
|
|
1158
|
+
* rethrown so reads surface a real filesystem problem instead of
|
|
1159
|
+
* silently reporting an empty collection. */
|
|
1160
|
+
async function dbFileState(absPath) {
|
|
1193
1161
|
try {
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
for (const row of rows) {
|
|
1199
|
-
await handle.write(`${JSON.stringify(first ? {
|
|
1200
|
-
...nullPads,
|
|
1201
|
-
...row
|
|
1202
|
-
} : row)}\n`);
|
|
1203
|
-
first = false;
|
|
1204
|
-
}
|
|
1205
|
-
} finally {
|
|
1206
|
-
await handle.close();
|
|
1207
|
-
}
|
|
1208
|
-
const { sql, params } = compileJsonlQuery(query);
|
|
1209
|
-
return (await queryCsv(sql, [jsonlPath, ...params])).map((row) => Object.fromEntries(Object.entries(row).map(([key, value]) => [key, normalizeCsvValue(value)])));
|
|
1210
|
-
} finally {
|
|
1211
|
-
await unlink(jsonlPath).catch((err) => {
|
|
1212
|
-
if (err.code !== "ENOENT") log.warn("collections", "temp JSONL cleanup failed", {
|
|
1213
|
-
path: jsonlPath,
|
|
1214
|
-
error: String(err)
|
|
1215
|
-
});
|
|
1216
|
-
});
|
|
1217
|
-
}
|
|
1218
|
-
}
|
|
1219
|
-
//#endregion
|
|
1220
|
-
//#region src/collection/core/recordZ.ts
|
|
1221
|
-
/** The emptiness rule shared by `required` and the "only check present
|
|
1222
|
-
* values" gate. NOT a truthiness check — `0` and `false` are filled. */
|
|
1223
|
-
var isEmptyValue = (value) => value === void 0 || value === null || value === "";
|
|
1224
|
-
/** The historical write-gate checks, verbatim: required non-empty, enum
|
|
1225
|
-
* membership (compared as strings, so a numeric `5` satisfies `"5"`). */
|
|
1226
|
-
function enforcedProblem(key, spec, value) {
|
|
1227
|
-
const empty = isEmptyValue(value);
|
|
1228
|
-
if (spec.required && empty) return `missing required field '${key}'`;
|
|
1229
|
-
if (!empty && spec.type === "enum" && !spec.values.includes(String(value))) return `'${key}' = '${String(value)}' is not one of [${spec.values.join(", ")}]`;
|
|
1230
|
-
return null;
|
|
1231
|
-
}
|
|
1232
|
-
/** Report-only per-type checks on a PRESENT value. Date / datetime reuse the
|
|
1233
|
-
* calendar's STRICT civil parsers (`parseIsoDate` / `parseIsoDateTime`), so
|
|
1234
|
-
* the lint flags exactly the values the calendar / trigger / spawn code
|
|
1235
|
-
* would silently drop — impossible days like `2026-02-30`, and datetimes
|
|
1236
|
-
* outside the canonical `YYYY-MM-DDTHH:MM[:SS]` shape (e.g. a `Z` suffix,
|
|
1237
|
-
* which the day view can't place). `string`-backed types accept anything
|
|
1238
|
-
* stringifiable; `ref` existence is out of scope. */
|
|
1239
|
-
function strictTypeProblem(key, spec, value) {
|
|
1240
|
-
switch (spec.type) {
|
|
1241
|
-
case "number":
|
|
1242
|
-
case "money": return Number.isFinite(coerceNumeric(value)) ? null : `'${key}' = '${String(value)}' is not numeric (a '${spec.type}' field stores a plain number)`;
|
|
1243
|
-
case "boolean": return value === true || value === false ? null : `'${key}' = '${String(value)}' is not a boolean (store true or false, unquoted)`;
|
|
1244
|
-
case "date": return parseIsoDate(value) !== null ? null : `'${key}' = '${String(value)}' is not a real YYYY-MM-DD date`;
|
|
1245
|
-
case "datetime": return parseIsoDateTime(value) !== null ? null : `'${key}' = '${String(value)}' is not a YYYY-MM-DDTHH:MM datetime (seconds optional, no timezone suffix — the shape the calendar parses)`;
|
|
1246
|
-
default: return null;
|
|
1162
|
+
return (await lstat(absPath)).isFile() ? "file" : "refused";
|
|
1163
|
+
} catch (err) {
|
|
1164
|
+
if (err.code === "ENOENT") return "missing";
|
|
1165
|
+
throw err;
|
|
1247
1166
|
}
|
|
1248
1167
|
}
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
*
|
|
1252
|
-
*
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1168
|
+
var CREATE_TABLE = "CREATE TABLE IF NOT EXISTS records (id TEXT PRIMARY KEY, record TEXT NOT NULL)";
|
|
1169
|
+
/** Open the database for one operation, classifying the two unavailable
|
|
1170
|
+
* states so callers can map them honestly (`refused` ⇒ path-escape,
|
|
1171
|
+
* `missing` ⇒ empty / not-found — conflating them would misreport a
|
|
1172
|
+
* containment escape as "item not found"). The containment pre-check runs
|
|
1173
|
+
* BEFORE mkdir even when the file is missing — `isContainedInRoot`
|
|
1174
|
+
* resolves through the closest existing ancestor, so a symlinked-away
|
|
1175
|
+
* parent can never make the recursive mkdir create directories outside
|
|
1176
|
+
* the workspace (same pre/post belt-and-suspenders as io.ts writes). */
|
|
1177
|
+
async function openDb(absPath, workspaceRoot, mode) {
|
|
1178
|
+
const state = await dbFileState(absPath);
|
|
1179
|
+
if (state === "refused") {
|
|
1180
|
+
log.warn("collections", "sqlite database refused: not a regular file", { path: absPath });
|
|
1181
|
+
return { kind: "refused" };
|
|
1182
|
+
}
|
|
1183
|
+
if (!isContainedInRoot(path.dirname(absPath), workspaceRoot)) {
|
|
1184
|
+
log.warn("collections", "sqlite refused: database dir escapes workspace via symlink", { path: absPath });
|
|
1185
|
+
return { kind: "refused" };
|
|
1186
|
+
}
|
|
1187
|
+
if (mode === "read" && state === "missing") return { kind: "missing" };
|
|
1188
|
+
if (mode === "write") {
|
|
1189
|
+
await mkdir(path.dirname(absPath), { recursive: true });
|
|
1190
|
+
if (!isContainedInRoot(path.dirname(absPath), workspaceRoot)) {
|
|
1191
|
+
log.warn("collections", "sqlite write refused: database dir escapes workspace via symlink (post-mkdir)", { path: absPath });
|
|
1192
|
+
return { kind: "refused" };
|
|
1262
1193
|
}
|
|
1263
1194
|
}
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
if (enforced || tier === "enforced") return enforced;
|
|
1273
|
-
if (isEmptyValue(value)) return null;
|
|
1274
|
-
if (spec.type === "table") return strictTableProblem(key, spec, value);
|
|
1275
|
-
return strictTypeProblem(key, spec, value);
|
|
1276
|
-
}
|
|
1277
|
-
var compiled = /* @__PURE__ */ new WeakMap();
|
|
1278
|
-
/** Compile `schema.fields` into a zod validator for a stored record.
|
|
1279
|
-
* Loose object: unknown keys are allowed and any declared key may be
|
|
1280
|
-
* absent (records are user files, not parse-and-rewrite targets —
|
|
1281
|
-
* callers validate, they never persist the parse output). The checks run
|
|
1282
|
-
* as ONE object-level refine iterating fields in declaration order —
|
|
1283
|
-
* per-key shape schemas can't express "key may be absent BUT its absence
|
|
1284
|
-
* must still reach the required check", and the single loop keeps the
|
|
1285
|
-
* first reported issue identical to the historical first-problem-wins
|
|
1286
|
-
* contract. */
|
|
1287
|
-
function compileRecordZ(schema, tier) {
|
|
1288
|
-
const cached = compiled.get(schema)?.[tier];
|
|
1289
|
-
if (cached) return cached;
|
|
1290
|
-
const stored = Object.entries(schema.fields).filter(([, spec]) => !COMPUTED_TYPES.has(spec.type));
|
|
1291
|
-
const validator = z.looseObject({}).superRefine((record, ctx) => {
|
|
1292
|
-
for (const [key, spec] of stored) {
|
|
1293
|
-
const problem = recordFieldProblem(key, spec, record[key], tier);
|
|
1294
|
-
if (problem) ctx.addIssue({
|
|
1295
|
-
code: "custom",
|
|
1296
|
-
message: problem,
|
|
1297
|
-
path: [key]
|
|
1298
|
-
});
|
|
1299
|
-
}
|
|
1300
|
-
});
|
|
1301
|
-
const entry = compiled.get(schema) ?? {};
|
|
1302
|
-
entry[tier] = validator;
|
|
1303
|
-
compiled.set(schema, entry);
|
|
1304
|
-
return validator;
|
|
1305
|
-
}
|
|
1306
|
-
/** First schema problem on an in-memory record under `tier`, or null. One
|
|
1307
|
-
* issue per record keeps the report short and the fix obvious (the
|
|
1308
|
-
* historical contract of `validateRecordObject`). */
|
|
1309
|
-
function firstRecordProblem(record, schema, tier) {
|
|
1310
|
-
const result = compileRecordZ(schema, tier).safeParse(record);
|
|
1311
|
-
if (result.success) return null;
|
|
1312
|
-
return result.error.issues[0]?.message ?? "record failed schema validation";
|
|
1195
|
+
const { DatabaseSync } = await loadSqlite();
|
|
1196
|
+
const database = new DatabaseSync(absPath);
|
|
1197
|
+
database.exec("PRAGMA busy_timeout = 5000");
|
|
1198
|
+
database.exec(CREATE_TABLE);
|
|
1199
|
+
return {
|
|
1200
|
+
kind: "ok",
|
|
1201
|
+
database
|
|
1202
|
+
};
|
|
1313
1203
|
}
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
/** List entries under the data dir, guarding realpath containment (against a
|
|
1321
|
-
* symlinked dir swapped in after discovery, like `listItems`) and treating a
|
|
1322
|
-
* missing dir as empty while surfacing real I/O faults. */
|
|
1323
|
-
async function listRecordFilenames(dataDir, workspaceRoot) {
|
|
1324
|
-
if (!isContainedInRoot(dataDir, workspaceRoot)) {
|
|
1325
|
-
log.warn("collections", "validate refused: dataDir escapes workspace via symlink", { dataDir });
|
|
1326
|
-
return [];
|
|
1327
|
-
}
|
|
1204
|
+
/** Run `operation` against the database and always close it; unavailable
|
|
1205
|
+
* states resolve through `onUnavailable` so each caller maps `missing`
|
|
1206
|
+
* vs `refused` to its own result kind. */
|
|
1207
|
+
async function withDb(absPath, workspaceRoot, mode, onUnavailable, operation) {
|
|
1208
|
+
const handle = await openDb(absPath, workspaceRoot, mode);
|
|
1209
|
+
if (handle.kind !== "ok") return onUnavailable(handle.kind);
|
|
1328
1210
|
try {
|
|
1329
|
-
return await
|
|
1330
|
-
}
|
|
1331
|
-
|
|
1332
|
-
throw err;
|
|
1211
|
+
return await operation(handle.database);
|
|
1212
|
+
} finally {
|
|
1213
|
+
handle.database.close();
|
|
1333
1214
|
}
|
|
1334
1215
|
}
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
if (issue) issues.push(issue);
|
|
1345
|
-
}
|
|
1346
|
-
return issues;
|
|
1216
|
+
/** node:sqlite throws ERR_SQLITE_ERROR with the SQLite extended result
|
|
1217
|
+
* code on `errcode`. Our INSERT's duplicate-id failure is
|
|
1218
|
+
* SQLITE_CONSTRAINT_PRIMARYKEY (1555); SQLITE_CONSTRAINT_UNIQUE (2067)
|
|
1219
|
+
* covers a plain UNIQUE index. Checked structurally (message text kept
|
|
1220
|
+
* only as a fallback for runtimes that don't expose `errcode`). */
|
|
1221
|
+
function isUniqueConstraintError(err) {
|
|
1222
|
+
const { errcode } = err;
|
|
1223
|
+
if (errcode !== void 0) return errcode === 1555 || errcode === 2067;
|
|
1224
|
+
return String(err).includes("UNIQUE constraint");
|
|
1347
1225
|
}
|
|
1348
|
-
|
|
1226
|
+
function parseRow(raw) {
|
|
1227
|
+
if (typeof raw !== "string") return null;
|
|
1349
1228
|
try {
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
problem: "not a regular file (symlink?) — skipped, won't appear"
|
|
1353
|
-
};
|
|
1354
|
-
return { raw: await readFile(fullPath, "utf-8") };
|
|
1229
|
+
const parsed = JSON.parse(raw);
|
|
1230
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
1355
1231
|
} catch {
|
|
1232
|
+
return null;
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
function rowsToItems(rows) {
|
|
1236
|
+
return rows.map((row) => parseRow(row.record)).filter((item) => item !== null);
|
|
1237
|
+
}
|
|
1238
|
+
async function sqliteList(absPath, workspaceRoot) {
|
|
1239
|
+
return withDb(absPath, workspaceRoot, "read", () => [], (database) => rowsToItems(database.prepare("SELECT record FROM records ORDER BY id").all()));
|
|
1240
|
+
}
|
|
1241
|
+
async function sqlitePage(absPath, primaryKey, opts, workspaceRoot) {
|
|
1242
|
+
const emptyPage = {
|
|
1243
|
+
items: [],
|
|
1244
|
+
total: 0,
|
|
1245
|
+
truncated: false
|
|
1246
|
+
};
|
|
1247
|
+
return withDb(absPath, workspaceRoot, "read", () => emptyPage, (database) => {
|
|
1248
|
+
const total = Number(database.prepare("SELECT COUNT(*) AS n FROM records").get().n);
|
|
1249
|
+
const offset = Math.max(0, opts.offset ?? 0);
|
|
1250
|
+
const limit = opts.limit === void 0 ? -1 : Math.max(0, opts.limit);
|
|
1356
1251
|
return {
|
|
1357
|
-
|
|
1358
|
-
|
|
1252
|
+
items: projectItemFields(rowsToItems(database.prepare("SELECT record FROM records ORDER BY id LIMIT ? OFFSET ?").all(limit, offset)), opts.fields, primaryKey),
|
|
1253
|
+
total,
|
|
1254
|
+
truncated: false
|
|
1359
1255
|
};
|
|
1360
|
-
}
|
|
1256
|
+
});
|
|
1361
1257
|
}
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1258
|
+
async function sqliteRead(absPath, itemId, workspaceRoot) {
|
|
1259
|
+
const safeId = safeRecordId(itemId);
|
|
1260
|
+
if (safeId === null) return null;
|
|
1261
|
+
return withDb(absPath, workspaceRoot, "read", () => null, (database) => {
|
|
1262
|
+
const row = database.prepare("SELECT record FROM records WHERE id = ?").get(safeId);
|
|
1263
|
+
return row === void 0 ? null : parseRow(row.record);
|
|
1264
|
+
});
|
|
1265
|
+
}
|
|
1266
|
+
async function sqliteWrite(absPath, itemId, item, opts) {
|
|
1267
|
+
const safeId = safeRecordId(itemId);
|
|
1268
|
+
if (safeId === null) return {
|
|
1269
|
+
kind: "invalid-id",
|
|
1270
|
+
itemId
|
|
1271
|
+
};
|
|
1272
|
+
const outcome = await withDb(absPath, opts.workspaceRoot, "write", () => ({
|
|
1273
|
+
kind: "path-escape",
|
|
1274
|
+
itemId: safeId
|
|
1275
|
+
}), (database) => {
|
|
1276
|
+
const payload = JSON.stringify(item);
|
|
1277
|
+
if (opts.refuseOverwrite) try {
|
|
1278
|
+
database.prepare("INSERT INTO records (id, record) VALUES (?, ?)").run(safeId, payload);
|
|
1279
|
+
} catch (err) {
|
|
1280
|
+
if (isUniqueConstraintError(err)) return {
|
|
1281
|
+
kind: "conflict",
|
|
1282
|
+
itemId: safeId
|
|
1283
|
+
};
|
|
1284
|
+
throw err;
|
|
1285
|
+
}
|
|
1286
|
+
else database.prepare("INSERT INTO records (id, record) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET record = excluded.record").run(safeId, payload);
|
|
1371
1287
|
return {
|
|
1372
|
-
|
|
1373
|
-
|
|
1288
|
+
kind: "ok",
|
|
1289
|
+
itemId: safeId,
|
|
1290
|
+
item
|
|
1374
1291
|
};
|
|
1292
|
+
});
|
|
1293
|
+
if (outcome.kind === "ok" && opts.slug) publishCollectionChange({
|
|
1294
|
+
slug: opts.slug,
|
|
1295
|
+
ids: [safeId],
|
|
1296
|
+
op: "upsert"
|
|
1297
|
+
});
|
|
1298
|
+
return outcome;
|
|
1299
|
+
}
|
|
1300
|
+
async function sqliteDelete(absPath, itemId, opts) {
|
|
1301
|
+
const safeId = safeRecordId(itemId);
|
|
1302
|
+
if (safeId === null) return {
|
|
1303
|
+
kind: "invalid-id",
|
|
1304
|
+
itemId
|
|
1305
|
+
};
|
|
1306
|
+
const outcome = await withDb(absPath, opts.workspaceRoot, "read", (reason) => reason === "refused" ? {
|
|
1307
|
+
kind: "path-escape",
|
|
1308
|
+
itemId: safeId
|
|
1309
|
+
} : {
|
|
1310
|
+
kind: "not-found",
|
|
1311
|
+
itemId: safeId
|
|
1312
|
+
}, (database) => {
|
|
1313
|
+
const { changes } = database.prepare("DELETE FROM records WHERE id = ?").run(safeId);
|
|
1314
|
+
return Number(changes) === 0 ? {
|
|
1315
|
+
kind: "not-found",
|
|
1316
|
+
itemId: safeId
|
|
1317
|
+
} : {
|
|
1318
|
+
kind: "ok",
|
|
1319
|
+
itemId: safeId
|
|
1320
|
+
};
|
|
1321
|
+
});
|
|
1322
|
+
if (outcome.kind === "ok" && opts.slug) publishCollectionChange({
|
|
1323
|
+
slug: opts.slug,
|
|
1324
|
+
ids: [safeId],
|
|
1325
|
+
op: "delete"
|
|
1326
|
+
});
|
|
1327
|
+
return outcome;
|
|
1328
|
+
}
|
|
1329
|
+
/** Best-effort full WAL checkpoint so the MAIN db file alone is a
|
|
1330
|
+
* complete snapshot (committed pages in `<db>-wal` are folded in and the
|
|
1331
|
+
* WAL truncated). Used by `deleteCollection` before archiving. Returns
|
|
1332
|
+
* false on any failure (runtime without node:sqlite, locked db, missing
|
|
1333
|
+
* file) — the caller then archives the sidecar files alongside the db so
|
|
1334
|
+
* no committed data is lost either way. */
|
|
1335
|
+
async function checkpointSqliteDatabase(absPath) {
|
|
1336
|
+
try {
|
|
1337
|
+
const { DatabaseSync } = await loadSqlite();
|
|
1338
|
+
const database = new DatabaseSync(absPath);
|
|
1339
|
+
try {
|
|
1340
|
+
database.exec("PRAGMA wal_checkpoint(TRUNCATE)");
|
|
1341
|
+
} finally {
|
|
1342
|
+
database.close();
|
|
1343
|
+
}
|
|
1344
|
+
return true;
|
|
1345
|
+
} catch {
|
|
1346
|
+
return false;
|
|
1375
1347
|
}
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1348
|
+
}
|
|
1349
|
+
/** A `storage: sqlite` store over `collection.storageFile`. A schema whose
|
|
1350
|
+
* `storageFile` failed to resolve yields a read-only EMPTY store rather
|
|
1351
|
+
* than a writable one — same fail-closed rule as the CSV store. */
|
|
1352
|
+
function sqliteStoreFor(collection, opts) {
|
|
1353
|
+
const file = collection.storageFile;
|
|
1354
|
+
const key = collection.schema.primaryKey;
|
|
1355
|
+
const slug = opts.slug ?? collection.slug;
|
|
1356
|
+
const root = () => opts.workspaceRoot ?? getWorkspaceRoot();
|
|
1357
|
+
if (file === void 0) return {
|
|
1358
|
+
capabilities: {
|
|
1359
|
+
writable: false,
|
|
1360
|
+
nativeQuery: false,
|
|
1361
|
+
nativePaging: false
|
|
1362
|
+
},
|
|
1363
|
+
list: () => Promise.resolve([]),
|
|
1364
|
+
page: () => Promise.resolve({
|
|
1365
|
+
items: [],
|
|
1366
|
+
total: 0,
|
|
1367
|
+
truncated: false
|
|
1368
|
+
}),
|
|
1369
|
+
read: () => Promise.resolve(null)
|
|
1379
1370
|
};
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
}
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1371
|
+
return {
|
|
1372
|
+
capabilities: {
|
|
1373
|
+
writable: true,
|
|
1374
|
+
nativeQuery: false,
|
|
1375
|
+
nativePaging: true
|
|
1376
|
+
},
|
|
1377
|
+
list: () => sqliteList(file, root()),
|
|
1378
|
+
page: (pageOpts = {}) => sqlitePage(file, key, pageOpts, root()),
|
|
1379
|
+
read: (itemId) => sqliteRead(file, itemId, root()),
|
|
1380
|
+
write: (itemId, item, writeOpts = {}) => sqliteWrite(file, itemId, item, {
|
|
1381
|
+
workspaceRoot: root(),
|
|
1382
|
+
slug,
|
|
1383
|
+
refuseOverwrite: writeOpts.refuseOverwrite
|
|
1384
|
+
}),
|
|
1385
|
+
delete: (itemId) => sqliteDelete(file, itemId, {
|
|
1386
|
+
workspaceRoot: root(),
|
|
1387
|
+
slug
|
|
1388
|
+
})
|
|
1389
|
+
};
|
|
1390
|
+
}
|
|
1391
|
+
//#endregion
|
|
1392
|
+
//#region src/collection/server/store.ts
|
|
1393
|
+
/** The file store's stable order: lexicographic by record id (codepoint
|
|
1394
|
+
* compare — locale-independent). `listItems` returns readdir order, which
|
|
1395
|
+
* is filesystem-dependent; paging needs determinism. */
|
|
1396
|
+
function sortByRecordId(items, primaryKey) {
|
|
1397
|
+
return [...items].sort((left, right) => {
|
|
1398
|
+
const leftId = String(left[primaryKey] ?? "");
|
|
1399
|
+
const rightId = String(right[primaryKey] ?? "");
|
|
1400
|
+
if (leftId < rightId) return -1;
|
|
1401
|
+
return leftId > rightId ? 1 : 0;
|
|
1402
|
+
});
|
|
1403
|
+
}
|
|
1404
|
+
/** True when the collection accepts UI/tool writes. A `dataSource`
|
|
1405
|
+
* collection is read-only: updates happen by editing/replacing the
|
|
1406
|
+
* data file itself. Every write entry point checks this BEFORE calling
|
|
1407
|
+
* `writeItem`/`deleteItem` — server-enforced, not just UI-hidden. */
|
|
1408
|
+
function collectionWritable(collection) {
|
|
1409
|
+
return !isReadOnlySchema(collection.schema);
|
|
1410
|
+
}
|
|
1411
|
+
/** The one-line refusal write paths surface (HTTP 405 / MCP error text). */
|
|
1412
|
+
function readOnlyRefusal(slug) {
|
|
1413
|
+
return `collection '${slug}' is read-only (backed by an external dataSource) — update the data file itself instead`;
|
|
1414
|
+
}
|
|
1415
|
+
/** A `dataSource` store over `file` (CSV row order; DuckDB-native query).
|
|
1416
|
+
* A schema whose `dataSourceFile` failed to resolve yields a read-only
|
|
1417
|
+
* EMPTY store rather than falling back to the (writable) file store — a
|
|
1418
|
+
* half-loaded read-only collection must never become writable. */
|
|
1419
|
+
function csvStoreFor(collection, opts) {
|
|
1420
|
+
const file = collection.dataSourceFile;
|
|
1421
|
+
const key = collection.schema.primaryKey;
|
|
1422
|
+
const listAll = () => file === void 0 ? Promise.resolve({
|
|
1423
|
+
items: [],
|
|
1424
|
+
truncated: false
|
|
1425
|
+
}) : csvList(file, key, opts.workspaceRoot);
|
|
1426
|
+
return {
|
|
1427
|
+
capabilities: {
|
|
1428
|
+
writable: false,
|
|
1429
|
+
nativeQuery: true,
|
|
1430
|
+
nativePaging: false
|
|
1431
|
+
},
|
|
1432
|
+
list: () => listAll().then((result) => result.items),
|
|
1433
|
+
page: (pageOpts = {}) => listAll().then((result) => pageFromFullRead(result.items, pageOpts, key, result.truncated)),
|
|
1434
|
+
read: (itemId) => file === void 0 ? Promise.resolve(null) : csvRead(file, key, itemId, opts.workspaceRoot),
|
|
1435
|
+
query: (query) => file === void 0 ? Promise.resolve([]) : csvRunQuery(file, key, query, opts.workspaceRoot)
|
|
1436
|
+
};
|
|
1437
|
+
}
|
|
1438
|
+
/** The classic file store over `<dataDir>/<itemId>.json` records. */
|
|
1439
|
+
function fileStoreFor(collection, opts) {
|
|
1440
|
+
const key = collection.schema.primaryKey;
|
|
1441
|
+
const ioOpts = {
|
|
1442
|
+
...opts,
|
|
1443
|
+
slug: opts.slug ?? collection.slug
|
|
1444
|
+
};
|
|
1445
|
+
return {
|
|
1446
|
+
capabilities: {
|
|
1447
|
+
writable: true,
|
|
1448
|
+
nativeQuery: false,
|
|
1449
|
+
nativePaging: false
|
|
1450
|
+
},
|
|
1451
|
+
list: () => listItems(collection.dataDir, opts),
|
|
1452
|
+
page: async (pageOpts = {}) => pageFromFullRead(sortByRecordId(await listItems(collection.dataDir, opts), key), pageOpts, key, false),
|
|
1453
|
+
read: (itemId) => readItem(collection.dataDir, itemId, opts),
|
|
1454
|
+
write: (itemId, item, writeOpts = {}) => writeItem(collection.dataDir, itemId, item, {
|
|
1455
|
+
...ioOpts,
|
|
1456
|
+
refuseOverwrite: writeOpts.refuseOverwrite
|
|
1457
|
+
}),
|
|
1458
|
+
delete: (itemId) => deleteItem(collection.dataDir, itemId, ioOpts)
|
|
1459
|
+
};
|
|
1460
|
+
}
|
|
1461
|
+
var storeFactories = /* @__PURE__ */ new Map([
|
|
1462
|
+
["file", fileStoreFor],
|
|
1463
|
+
["csv", csvStoreFor],
|
|
1464
|
+
["sqlite", sqliteStoreFor]
|
|
1465
|
+
]);
|
|
1466
|
+
/** Pick the store implementation for a discovered collection via the
|
|
1467
|
+
* factory registry. An unknown kind cannot normally reach here (the
|
|
1468
|
+
* schema's `StorageZ` union gates it), so the throw is a loud invariant
|
|
1469
|
+
* breach, not a user-facing path. */
|
|
1470
|
+
function storeFor(collection, opts = {}) {
|
|
1471
|
+
const kind = storageKindFor(collection.schema);
|
|
1472
|
+
const factory = storeFactories.get(kind);
|
|
1473
|
+
if (!factory) throw new Error(`no store factory registered for storage kind '${kind}'`);
|
|
1474
|
+
return factory(collection, opts);
|
|
1399
1475
|
}
|
|
1400
1476
|
/** The param name a `set` value references, or null when the value is a
|
|
1401
1477
|
* literal (non-strings can never be references). A bare/empty prefix
|
|
@@ -1424,70 +1500,6 @@ function resolveMutateSet(set, params) {
|
|
|
1424
1500
|
return resolved;
|
|
1425
1501
|
}
|
|
1426
1502
|
//#endregion
|
|
1427
|
-
//#region src/collection/server/mutate.ts
|
|
1428
|
-
/** First problem with the submitted params, or null. Every declared param
|
|
1429
|
-
* is checked by the shared record-field validator; keys the action never
|
|
1430
|
-
* declared are rejected outright — a stray key would otherwise ride the
|
|
1431
|
-
* resolved `set` semantics silently. */
|
|
1432
|
-
function firstMutateParamProblem(action, params) {
|
|
1433
|
-
const declared = action.params ?? {};
|
|
1434
|
-
for (const key of Object.keys(params)) if (declared[key] === void 0) return `unknown param '${key}' — not declared by action '${action.id}'`;
|
|
1435
|
-
for (const [key, spec] of Object.entries(declared)) {
|
|
1436
|
-
const problem = recordFieldProblem(key, spec, params[key], "strict");
|
|
1437
|
-
if (problem) return problem;
|
|
1438
|
-
}
|
|
1439
|
-
return null;
|
|
1440
|
-
}
|
|
1441
|
-
/** Apply one mutate action to one record. Never throws for data-shaped
|
|
1442
|
-
* failures — the outcome's `problem` doubles as agent feedback when an
|
|
1443
|
-
* LLM (not the user) pressed the button. */
|
|
1444
|
-
async function applyMutateAction(collection, action, itemId, params, opts = {}) {
|
|
1445
|
-
const paramProblem = firstMutateParamProblem(action, params);
|
|
1446
|
-
if (paramProblem) return {
|
|
1447
|
-
ok: false,
|
|
1448
|
-
status: "invalid-params",
|
|
1449
|
-
problem: paramProblem
|
|
1450
|
-
};
|
|
1451
|
-
const existing = await readItem(collection.dataDir, itemId, opts);
|
|
1452
|
-
if (!existing) return {
|
|
1453
|
-
ok: false,
|
|
1454
|
-
status: "not-found",
|
|
1455
|
-
problem: `item '${itemId}' not found`
|
|
1456
|
-
};
|
|
1457
|
-
if (!actionVisible(action, existing)) return {
|
|
1458
|
-
ok: false,
|
|
1459
|
-
status: "require-unmet",
|
|
1460
|
-
problem: `action '${action.id}' is not available for item '${itemId}' in its current state`
|
|
1461
|
-
};
|
|
1462
|
-
const stored = Object.entries(existing).filter(([key]) => {
|
|
1463
|
-
const spec = collection.schema.fields[key];
|
|
1464
|
-
return !spec || !COMPUTED_TYPES.has(spec.type);
|
|
1465
|
-
});
|
|
1466
|
-
const merged = {
|
|
1467
|
-
...Object.fromEntries(stored),
|
|
1468
|
-
...resolveMutateSet(action.set, params)
|
|
1469
|
-
};
|
|
1470
|
-
const invalid = validateRecordObject(merged, itemId, collection.schema);
|
|
1471
|
-
if (invalid) return {
|
|
1472
|
-
ok: false,
|
|
1473
|
-
status: "invalid-record",
|
|
1474
|
-
problem: invalid
|
|
1475
|
-
};
|
|
1476
|
-
const result = await writeItem(collection.dataDir, itemId, merged, {
|
|
1477
|
-
workspaceRoot: opts.workspaceRoot,
|
|
1478
|
-
slug: collection.slug
|
|
1479
|
-
});
|
|
1480
|
-
if (result.kind !== "ok") return {
|
|
1481
|
-
ok: false,
|
|
1482
|
-
status: "write-refused",
|
|
1483
|
-
problem: `write refused (${result.kind})`
|
|
1484
|
-
};
|
|
1485
|
-
return {
|
|
1486
|
-
ok: true,
|
|
1487
|
-
item: merged
|
|
1488
|
-
};
|
|
1489
|
-
}
|
|
1490
|
-
//#endregion
|
|
1491
1503
|
//#region src/collection/core/schemaZ.ts
|
|
1492
1504
|
/** Optional visibility predicate shared by actions and fields: the target
|
|
1493
1505
|
* shows only when the open record's `field` (stringified) is one of `in`.
|
|
@@ -1916,6 +1928,21 @@ var AgentIngestZ = z.object({
|
|
|
1916
1928
|
role: z.string().trim().min(1),
|
|
1917
1929
|
template: z.string().trim().min(1).refine(isSafeActionTemplatePath, "must be a safe path under `templates/` (e.g. `templates/refresh.md`; no `..`, no leading `/`, no backslash)")
|
|
1918
1930
|
});
|
|
1931
|
+
/** Marks a collection as the destination of the LLM-free Google Calendar
|
|
1932
|
+
* sync (#2095). `map` is collectionField → Google event field, so the user's
|
|
1933
|
+
* collection keeps whatever field names it already uses. */
|
|
1934
|
+
var GoogleCalendarSyncZ = z.object({
|
|
1935
|
+
/** Calendar to pull from; defaults to the user's primary. */
|
|
1936
|
+
calendarId: z.string().trim().min(1).optional(),
|
|
1937
|
+
map: z.record(z.string().trim().min(1), z.enum([
|
|
1938
|
+
"summary",
|
|
1939
|
+
"start",
|
|
1940
|
+
"end",
|
|
1941
|
+
"htmlLink",
|
|
1942
|
+
"colorId",
|
|
1943
|
+
"status"
|
|
1944
|
+
])).refine((map) => Object.keys(map).length > 0, { message: "map at least one field — a `googleCalendar` sync with an empty map writes records that carry only the event id" })
|
|
1945
|
+
});
|
|
1919
1946
|
/** `ingest` is a discriminated union on `kind`: the three declarative
|
|
1920
1947
|
* retrievers fetch-and-map; `agent` dispatches a hidden worker. Optional on
|
|
1921
1948
|
* every schema — skill-backed collections usually omit it; only feeds
|
|
@@ -2021,11 +2048,24 @@ var DataSourceZ = z.object({
|
|
|
2021
2048
|
type: z.literal("csv"),
|
|
2022
2049
|
path: z.string().min(1)
|
|
2023
2050
|
});
|
|
2051
|
+
/** Alternative WRITABLE storage backend for a collection's records —
|
|
2052
|
+
* unlike `dataSource` (external read-only file), a `storage` collection
|
|
2053
|
+
* behaves like a normal writable collection; only where the rows live
|
|
2054
|
+
* changes. v1: `sqlite` — records in a single SQLite database file
|
|
2055
|
+
* (`node:sqlite`, one JSON record per row keyed by the primaryKey).
|
|
2056
|
+
* `path` is workspace-relative and containment-checked exactly like
|
|
2057
|
+
* `dataPath`. The store factory registry (`server/store.ts`) picks the
|
|
2058
|
+
* implementation by `type` (plans/refactor-storage-virtualization.md). */
|
|
2059
|
+
var StorageZ = z.object({
|
|
2060
|
+
type: z.literal("sqlite"),
|
|
2061
|
+
path: z.string().min(1)
|
|
2062
|
+
});
|
|
2024
2063
|
var BareCollectionSchemaZ = z.object({
|
|
2025
2064
|
title: z.string().min(1),
|
|
2026
2065
|
icon: z.string().min(1),
|
|
2027
2066
|
dataPath: z.string().min(1).optional(),
|
|
2028
2067
|
dataSource: DataSourceZ.optional(),
|
|
2068
|
+
storage: StorageZ.optional(),
|
|
2029
2069
|
primaryKey: z.string().min(1),
|
|
2030
2070
|
singleton: z.string().trim().min(1).optional(),
|
|
2031
2071
|
fields: z.record(z.string(), FieldSpecZ),
|
|
@@ -2044,13 +2084,24 @@ var BareCollectionSchemaZ = z.object({
|
|
|
2044
2084
|
views: z.array(CustomViewZ).optional(),
|
|
2045
2085
|
notifyWhen: WhenZ.optional(),
|
|
2046
2086
|
ingest: IngestZ.optional(),
|
|
2087
|
+
googleCalendar: GoogleCalendarSyncZ.optional(),
|
|
2047
2088
|
dynamicIcon: DynamicIconSpecZ.optional()
|
|
2048
|
-
}).refine((schema) =>
|
|
2049
|
-
|
|
2089
|
+
}).refine((schema) => [
|
|
2090
|
+
schema.dataPath,
|
|
2091
|
+
schema.dataSource,
|
|
2092
|
+
schema.storage
|
|
2093
|
+
].filter((declared) => declared !== void 0).length === 1, {
|
|
2094
|
+
message: "declare exactly one of `dataPath` (native JSON records), `dataSource` (external read-only data file), or `storage` (alternative writable backend)",
|
|
2050
2095
|
path: ["dataPath"]
|
|
2051
|
-
}).refine((schema) => schema.dataSource === void 0 || schema.singleton === void 0 && schema.ingest === void 0 && schema.spawn === void 0, {
|
|
2052
|
-
message: "a `dataSource` collection is read-only — it cannot declare `singleton`, `ingest`, or `
|
|
2096
|
+
}).refine((schema) => schema.dataSource === void 0 || schema.singleton === void 0 && schema.ingest === void 0 && schema.spawn === void 0 && schema.googleCalendar === void 0, {
|
|
2097
|
+
message: "a `dataSource` collection is read-only — it cannot declare `singleton`, `ingest`, `spawn`, or `googleCalendar` (all of them write records)",
|
|
2053
2098
|
path: ["dataSource"]
|
|
2099
|
+
}).refine((schema) => schema.googleCalendar === void 0 || Object.keys(schema.googleCalendar.map).every((key) => {
|
|
2100
|
+
const target = schema.fields[key];
|
|
2101
|
+
return target !== void 0 && !COMPUTED_TYPES.has(target.type) && key !== schema.primaryKey;
|
|
2102
|
+
}), {
|
|
2103
|
+
message: "a `googleCalendar` map key must name a declared, non-computed field, and never the primaryKey (that always holds the Google event id)",
|
|
2104
|
+
path: ["googleCalendar"]
|
|
2054
2105
|
}).refine((schema) => schema.dataSource === void 0 || [...schema.actions ?? [], ...schema.collectionActions ?? []].every((action) => action.kind !== "mutate"), {
|
|
2055
2106
|
message: "a `dataSource` collection is read-only — its actions cannot use `kind: \"mutate\"` (a host write); use `chat`/`agent` actions instead",
|
|
2056
2107
|
path: ["dataSource"]
|
|
@@ -2291,6 +2342,23 @@ function acceptParsedSchema(schema, opts) {
|
|
|
2291
2342
|
dataSourceFile
|
|
2292
2343
|
};
|
|
2293
2344
|
}
|
|
2345
|
+
if (schema.storage !== void 0) {
|
|
2346
|
+
const storageFile = resolveDataDir(schema.storage.path, opts.workspaceRoot);
|
|
2347
|
+
if (storageFile === null) return {
|
|
2348
|
+
ok: false,
|
|
2349
|
+
reason: `storage.path '${schema.storage.path}' escapes the workspace`
|
|
2350
|
+
};
|
|
2351
|
+
const dataDir = resolveDataDir(conventionalDataPath(opts.slug), opts.workspaceRoot);
|
|
2352
|
+
if (dataDir === null) return {
|
|
2353
|
+
ok: false,
|
|
2354
|
+
reason: `slug '${opts.slug}' yields no workspace-contained data dir`
|
|
2355
|
+
};
|
|
2356
|
+
return {
|
|
2357
|
+
ok: true,
|
|
2358
|
+
dataDir,
|
|
2359
|
+
storageFile
|
|
2360
|
+
};
|
|
2361
|
+
}
|
|
2294
2362
|
const dataDir = resolveDataDir(schema.dataPath ?? "", opts.workspaceRoot);
|
|
2295
2363
|
if (dataDir === null) return {
|
|
2296
2364
|
ok: false,
|
|
@@ -2304,7 +2372,7 @@ function acceptParsedSchema(schema, opts) {
|
|
|
2304
2372
|
async function loadOneCollection(skillsRoot, slug, source, workspaceRoot) {
|
|
2305
2373
|
const safeName = safeSlugName(slug);
|
|
2306
2374
|
if (safeName === null) return null;
|
|
2307
|
-
const schemaPath = path.join(skillsRoot, safeName, SCHEMA_FILE
|
|
2375
|
+
const schemaPath = path.join(skillsRoot, safeName, SCHEMA_FILE);
|
|
2308
2376
|
let raw;
|
|
2309
2377
|
try {
|
|
2310
2378
|
if (!(await stat(schemaPath)).isFile()) return null;
|
|
@@ -2355,6 +2423,7 @@ async function loadOneCollection(skillsRoot, slug, source, workspaceRoot) {
|
|
|
2355
2423
|
schema,
|
|
2356
2424
|
dataDir: acceptance.dataDir,
|
|
2357
2425
|
...acceptance.dataSourceFile !== void 0 ? { dataSourceFile: acceptance.dataSourceFile } : {},
|
|
2426
|
+
...acceptance.storageFile !== void 0 ? { storageFile: acceptance.storageFile } : {},
|
|
2358
2427
|
skillDir: path.join(skillsRoot, safeName)
|
|
2359
2428
|
};
|
|
2360
2429
|
}
|
|
@@ -2437,1156 +2506,6 @@ function toDetail(collection) {
|
|
|
2437
2506
|
};
|
|
2438
2507
|
}
|
|
2439
2508
|
//#endregion
|
|
2440
|
-
|
|
2441
|
-
/** Extract the relations a schema declares, in field declaration order:
|
|
2442
|
-
* top-level `ref` / `embed` / `backlinks` fields plus `ref` sub-fields
|
|
2443
|
-
* inside `table` columns. Pure — exported so the phase-2 graph panel
|
|
2444
|
-
* can reuse it on already-loaded schemas. */
|
|
2445
|
-
function schemaRelations(schema) {
|
|
2446
|
-
const relations = [];
|
|
2447
|
-
for (const [key, spec] of Object.entries(schema.fields)) {
|
|
2448
|
-
if (spec.type === "ref" || spec.type === "embed") relations.push({
|
|
2449
|
-
field: key,
|
|
2450
|
-
kind: spec.type,
|
|
2451
|
-
to: spec.to
|
|
2452
|
-
});
|
|
2453
|
-
if (spec.type === "backlinks" || spec.type === "rollup") relations.push({
|
|
2454
|
-
field: key,
|
|
2455
|
-
kind: spec.type,
|
|
2456
|
-
to: spec.from
|
|
2457
|
-
});
|
|
2458
|
-
if (spec.type !== "table") continue;
|
|
2459
|
-
for (const [subKey, subSpec] of Object.entries(spec.of)) if (subSpec.type === "ref") relations.push({
|
|
2460
|
-
field: `${key}.${subKey}`,
|
|
2461
|
-
kind: "ref",
|
|
2462
|
-
to: subSpec.to
|
|
2463
|
-
});
|
|
2464
|
-
}
|
|
2465
|
-
return relations;
|
|
2466
|
-
}
|
|
2467
|
-
/** Count the record files in a collection's data dir — the same
|
|
2468
|
-
* `<id>.json` entries `listItems` considers, WITHOUT parsing them (the
|
|
2469
|
-
* ontology is a summary; a malformed record is still a record). Each
|
|
2470
|
-
* candidate is classified by the SHARED `isRegularFile` lstat helper —
|
|
2471
|
-
* the exact file-disclosure defense `listItems` applies — so a
|
|
2472
|
-
* symlinked record never counts and the count can't diverge from the
|
|
2473
|
-
* readable set on any filesystem (Codex review on PR #2099, twice).
|
|
2474
|
-
* Fail-soft: a missing dir or a dataDir escaping the workspace via
|
|
2475
|
-
* symlink counts 0. */
|
|
2476
|
-
async function countRecordFiles(dataDir, workspaceRoot) {
|
|
2477
|
-
if (!isContainedInRoot(dataDir, workspaceRoot)) return 0;
|
|
2478
|
-
try {
|
|
2479
|
-
const names = (await readdir(dataDir)).filter((name) => name.endsWith(".json") && !name.startsWith("."));
|
|
2480
|
-
return (await Promise.all(names.map((name) => isRegularFile(path.join(dataDir, name))))).filter(Boolean).length;
|
|
2481
|
-
} catch {
|
|
2482
|
-
return 0;
|
|
2483
|
-
}
|
|
2484
|
-
}
|
|
2485
|
-
/** A collection's record count for the ontology. File-backed: the cheap
|
|
2486
|
-
* readdir count. `dataSource`-backed: the row count via the CSV store
|
|
2487
|
-
* (subject to its row cap) — the dataDir is a phantom there, so the
|
|
2488
|
-
* readdir count would always misreport 0. Fail-soft to 0 like
|
|
2489
|
-
* `countRecordFiles` (an unreadable file / missing DuckDB must not
|
|
2490
|
-
* break the whole ontology). */
|
|
2491
|
-
async function countRecords(collection, workspaceRoot) {
|
|
2492
|
-
if (!collectionWritable(collection)) try {
|
|
2493
|
-
return (await storeFor(collection, { workspaceRoot }).list()).length;
|
|
2494
|
-
} catch {
|
|
2495
|
-
return 0;
|
|
2496
|
-
}
|
|
2497
|
-
return countRecordFiles(collection.dataDir, workspaceRoot);
|
|
2498
|
-
}
|
|
2499
|
-
async function toOntologyEntry(collection, workspaceRoot) {
|
|
2500
|
-
const { schema } = collection;
|
|
2501
|
-
return {
|
|
2502
|
-
slug: collection.slug,
|
|
2503
|
-
title: schema.title,
|
|
2504
|
-
icon: schema.icon,
|
|
2505
|
-
primaryKey: schema.primaryKey,
|
|
2506
|
-
displayField: schema.displayField ?? schema.primaryKey,
|
|
2507
|
-
recordCount: await countRecords(collection, workspaceRoot),
|
|
2508
|
-
relations: schemaRelations(schema)
|
|
2509
|
-
};
|
|
2510
|
-
}
|
|
2511
|
-
/** Build the workspace ontology: every discovered collection (slug-sorted,
|
|
2512
|
-
* discovery's order), each with its outbound relations and a
|
|
2513
|
-
* readdir-cheap record count — fine to call on demand. */
|
|
2514
|
-
async function buildWorkspaceOntology(opts = {}) {
|
|
2515
|
-
const workspaceRoot = opts.workspaceRoot ?? getWorkspaceRoot();
|
|
2516
|
-
const collections = await discoverCollections(opts);
|
|
2517
|
-
return Promise.all(collections.map((collection) => toOntologyEntry(collection, workspaceRoot)));
|
|
2518
|
-
}
|
|
2519
|
-
//#endregion
|
|
2520
|
-
//#region src/collection/server/derive.ts
|
|
2521
|
-
/** Slugs of every collection referenced by a `ref` field — top-level
|
|
2522
|
-
* and one level into `table` sub-fields (nested tables are
|
|
2523
|
-
* schema-rejected). Mirrors the client's `uniqueRefTargets`. */
|
|
2524
|
-
function uniqueRefTargets(schema) {
|
|
2525
|
-
const targets = /* @__PURE__ */ new Set();
|
|
2526
|
-
const walk = (fields) => {
|
|
2527
|
-
for (const field of Object.values(fields)) {
|
|
2528
|
-
if (field.type === "ref" && typeof field.to === "string" && field.to.length > 0) targets.add(field.to);
|
|
2529
|
-
if (field.type === "table" && field.of) walk(field.of);
|
|
2530
|
-
}
|
|
2531
|
-
};
|
|
2532
|
-
walk(schema.fields);
|
|
2533
|
-
return [...targets];
|
|
2534
|
-
}
|
|
2535
|
-
/** Slugs of every collection referenced by an `embed` field (top-level
|
|
2536
|
-
* only, like the client). */
|
|
2537
|
-
function uniqueEmbedTargets(schema) {
|
|
2538
|
-
const targets = /* @__PURE__ */ new Set();
|
|
2539
|
-
for (const field of Object.values(schema.fields)) if (field.type === "embed" && typeof field.to === "string" && field.to.length > 0) targets.add(field.to);
|
|
2540
|
-
return [...targets];
|
|
2541
|
-
}
|
|
2542
|
-
/** Slugs of every SOURCE collection a `backlinks` or `rollup` field
|
|
2543
|
-
* reverses over — loaded exactly like ref/embed targets (whole
|
|
2544
|
-
* collection, once; the two field kinds share one load). */
|
|
2545
|
-
function uniqueBacklinkSources(schema) {
|
|
2546
|
-
const sources = /* @__PURE__ */ new Set();
|
|
2547
|
-
for (const field of Object.values(schema.fields)) if ((field.type === "backlinks" || field.type === "rollup") && field.from.length > 0) sources.add(field.from);
|
|
2548
|
-
return [...sources];
|
|
2549
|
-
}
|
|
2550
|
-
async function loadTarget(slug, opts) {
|
|
2551
|
-
const target = await loadCollection(slug, opts);
|
|
2552
|
-
if (!target) return null;
|
|
2553
|
-
const items = await storeFor(target, { workspaceRoot: opts.workspaceRoot }).list();
|
|
2554
|
-
const byId = {};
|
|
2555
|
-
for (const item of items) {
|
|
2556
|
-
const itemId = item[target.schema.primaryKey];
|
|
2557
|
-
if (typeof itemId === "string" && itemId.length > 0) byId[itemId] = deriveAll(target.schema, item, {});
|
|
2558
|
-
}
|
|
2559
|
-
return {
|
|
2560
|
-
schema: target.schema,
|
|
2561
|
-
byId
|
|
2562
|
-
};
|
|
2563
|
-
}
|
|
2564
|
-
/** Load every ref/embed target and backlink source collection once.
|
|
2565
|
-
* Unknown / unloadable targets are simply absent — downstream derefs
|
|
2566
|
-
* resolve to null (em-dash) and backlinks to an empty row set, the
|
|
2567
|
-
* same fail-soft the UI renders. */
|
|
2568
|
-
async function loadLinkedTargets(schema, opts) {
|
|
2569
|
-
const slugs = [.../* @__PURE__ */ new Set([
|
|
2570
|
-
...uniqueRefTargets(schema),
|
|
2571
|
-
...uniqueEmbedTargets(schema),
|
|
2572
|
-
...uniqueBacklinkSources(schema)
|
|
2573
|
-
])];
|
|
2574
|
-
const loaded = {};
|
|
2575
|
-
for (const slug of slugs) {
|
|
2576
|
-
const target = await loadTarget(slug, opts);
|
|
2577
|
-
if (target) loaded[slug] = target;
|
|
2578
|
-
}
|
|
2579
|
-
return loaded;
|
|
2580
|
-
}
|
|
2581
|
-
function toRefRecords(linked) {
|
|
2582
|
-
return Object.fromEntries(Object.entries(linked).map(([slug, target]) => [slug, target.byId]));
|
|
2583
|
-
}
|
|
2584
|
-
/** The matching source rows for one `backlinks` field, projected to the
|
|
2585
|
-
* source primaryKey + `display` columns — so getItems on a
|
|
2586
|
-
* heavily-referenced record stays a summary, not a dump of the source
|
|
2587
|
-
* collection. Missing source ⇒ [] (fail-soft). The rows come from the
|
|
2588
|
-
* DERIVED source records (`byId`), so `display`/`filter` on a derived
|
|
2589
|
-
* source column (an invoice `total`) works. */
|
|
2590
|
-
function projectBacklinks(field, schema, enriched, linked) {
|
|
2591
|
-
const source = linked[field.from];
|
|
2592
|
-
if (!source) return [];
|
|
2593
|
-
return backlinkRows(field, String(enriched[schema.primaryKey] ?? ""), Object.values(source.byId)).map((row) => projectBacklinkRow(row, field.display, source.schema.primaryKey));
|
|
2594
|
-
}
|
|
2595
|
-
/** Project the computed (never-stored) field kinds onto one derived
|
|
2596
|
-
* record: `toggle` → boolean off its enum, `embed` → the target record
|
|
2597
|
-
* (fixed `id` or per-record `idField`), or null when missing,
|
|
2598
|
-
* `backlinks` → the matching source rows (see `projectBacklinks`). */
|
|
2599
|
-
function projectComputed(schema, enriched, linked) {
|
|
2600
|
-
for (const [key, field] of Object.entries(schema.fields)) {
|
|
2601
|
-
if (field.type === "toggle" && field.field) enriched[key] = String(enriched[field.field] ?? "") === field.onValue;
|
|
2602
|
-
if (field.type === "embed" && field.to) {
|
|
2603
|
-
const targetId = embedTargetId(field, enriched);
|
|
2604
|
-
enriched[key] = targetId && linked[field.to]?.byId[targetId] || null;
|
|
2605
|
-
}
|
|
2606
|
-
if (field.type === "backlinks") enriched[key] = projectBacklinks(field, schema, enriched, linked);
|
|
2607
|
-
}
|
|
2608
|
-
return enriched;
|
|
2609
|
-
}
|
|
2610
|
-
/** Resolve every rollup field onto a COPY of the record, BEFORE the
|
|
2611
|
-
* formula pass — a `derived` formula may reference rollup values as
|
|
2612
|
-
* plain identifiers (`played = homePlayed + awayPlayed`). Same reverse
|
|
2613
|
-
* machinery as backlinks, collapsed to a number: an unresolvable source
|
|
2614
|
-
* is null (a formula reading it fails soft to em-dash); an empty match
|
|
2615
|
-
* set is a real 0. Returns `record` unchanged when the schema declares
|
|
2616
|
-
* no rollups. */
|
|
2617
|
-
function projectRollups(schema, record, linked) {
|
|
2618
|
-
let out = record;
|
|
2619
|
-
for (const [key, field] of Object.entries(schema.fields)) {
|
|
2620
|
-
if (field.type !== "rollup") continue;
|
|
2621
|
-
if (out === record) out = { ...record };
|
|
2622
|
-
const source = linked[field.from];
|
|
2623
|
-
const selfId = String(record[schema.primaryKey] ?? "");
|
|
2624
|
-
out[key] = source ? rollupValue(field, selfId, Object.values(source.byId)) : null;
|
|
2625
|
-
}
|
|
2626
|
-
return out;
|
|
2627
|
-
}
|
|
2628
|
-
/** Enrich records with every host-computed field: derived formulas
|
|
2629
|
-
* evaluated (cross-collection derefs included), toggles projected,
|
|
2630
|
-
* embeds resolved. Loads each linked collection ONCE per call. Input
|
|
2631
|
-
* records are not mutated. */
|
|
2632
|
-
async function enrichItems(collection, items, opts = {}) {
|
|
2633
|
-
const { schema } = collection;
|
|
2634
|
-
const linked = await loadLinkedTargets(schema, opts);
|
|
2635
|
-
const refRecords = toRefRecords(linked);
|
|
2636
|
-
return items.map((item) => projectComputed(schema, deriveAll(schema, projectRollups(schema, item, linked), refRecords), linked));
|
|
2637
|
-
}
|
|
2638
|
-
//#endregion
|
|
2639
|
-
//#region src/collection/server/dynamicIcon.ts
|
|
2640
|
-
/** Index `items` by their `primaryKey` value — the `recordsById` map a
|
|
2641
|
-
* `valueFrom` reference (e.g. `_config.defaultCity`) resolves against.
|
|
2642
|
-
* Items whose primary key isn't a string (shouldn't happen for a valid
|
|
2643
|
-
* schema, but records are untyped storage) are skipped rather than
|
|
2644
|
-
* coerced, so a broken key never silently shadows a real one. */
|
|
2645
|
-
function buildRecordsById(items, primaryKey) {
|
|
2646
|
-
const entries = items.filter((item) => typeof item[primaryKey] === "string").map((item) => [String(item[primaryKey]), item]);
|
|
2647
|
-
return Object.fromEntries(entries);
|
|
2648
|
-
}
|
|
2649
|
-
/** Order records by their `primaryKey` so record selection is deterministic:
|
|
2650
|
-
* `listItems` returns filesystem `readdir` order (arbitrary across machines),
|
|
2651
|
-
* which would let `from: "first"`, the no-`orderBy` `latest`, and `orderBy`
|
|
2652
|
-
* ties pick a different record — and thus a different icon — between
|
|
2653
|
-
* reconciles. A stable id sort pins one answer. */
|
|
2654
|
-
function sortByPrimaryKey(items, primaryKey) {
|
|
2655
|
-
return [...items].sort((left, right) => String(left[primaryKey] ?? "").localeCompare(String(right[primaryKey] ?? "")));
|
|
2656
|
-
}
|
|
2657
|
-
/** Compute the effective launcher icon for `collection`: its static
|
|
2658
|
-
* `schema.icon` when it declares no `dynamicIcon`, else the icon
|
|
2659
|
-
* resolved from `dynamicIcon.source`'s RAW stored records (no
|
|
2660
|
-
* derive/enrich — the icon rules match against stored values) via the
|
|
2661
|
-
* pure resolver. Fails soft on any read/discovery error (missing source
|
|
2662
|
-
* collection, filesystem error): falls back to `dynamicIcon.fallback ??
|
|
2663
|
-
* schema.icon` rather than surfacing to the collections list. */
|
|
2664
|
-
async function computeCollectionIcon(collection, opts = {}) {
|
|
2665
|
-
const { schema } = collection;
|
|
2666
|
-
const spec = schema.dynamicIcon;
|
|
2667
|
-
if (!spec) return schema.icon;
|
|
2668
|
-
try {
|
|
2669
|
-
const source = await loadCollection(spec.source.collection, opts);
|
|
2670
|
-
if (!source) return spec.fallback ?? schema.icon;
|
|
2671
|
-
const ordered = sortByPrimaryKey(await storeFor(source, { workspaceRoot: opts.workspaceRoot }).list(), source.schema.primaryKey);
|
|
2672
|
-
const orderBy = spec.source.orderBy ?? firstDateField(source.schema);
|
|
2673
|
-
const recordsById = buildRecordsById(ordered, source.schema.primaryKey);
|
|
2674
|
-
return resolveIcon(selectDynamicRecord(ordered, spec.source, orderBy, recordsById), spec, schema.icon, recordsById);
|
|
2675
|
-
} catch (err) {
|
|
2676
|
-
log.warn("collections", "dynamic icon compute failed, falling back", {
|
|
2677
|
-
slug: collection.slug,
|
|
2678
|
-
source: spec.source.collection,
|
|
2679
|
-
error: String(err)
|
|
2680
|
-
});
|
|
2681
|
-
return spec.fallback ?? schema.icon;
|
|
2682
|
-
}
|
|
2683
|
-
}
|
|
2684
|
-
//#endregion
|
|
2685
|
-
//#region src/collection/server/util.ts
|
|
2686
|
-
/** Human-readable message from an unknown thrown value. */
|
|
2687
|
-
function errorMessage(err) {
|
|
2688
|
-
return err instanceof Error ? err.message : String(err);
|
|
2689
|
-
}
|
|
2690
|
-
var ONE_SECOND_MS = 1e3;
|
|
2691
|
-
var ONE_DAY_MS = 1440 * 60 * 1e3;
|
|
2692
|
-
//#endregion
|
|
2693
|
-
//#region src/collection/server/spawn.ts
|
|
2694
|
-
var YMD_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/;
|
|
2695
|
-
function pad2(value) {
|
|
2696
|
-
return String(value).padStart(2, "0");
|
|
2697
|
-
}
|
|
2698
|
-
function pad4(value) {
|
|
2699
|
-
return String(value).padStart(4, "0");
|
|
2700
|
-
}
|
|
2701
|
-
/** Days in `month` (1-12) of `year`, leap-year-aware. `new Date(y, m, 0)`
|
|
2702
|
-
* is day 0 of the *next* month = the last day of `month`; `.getDate()`
|
|
2703
|
-
* reads the civil day regardless of timezone. */
|
|
2704
|
-
function daysInMonth(year, month) {
|
|
2705
|
-
return new Date(year, month, 0).getDate();
|
|
2706
|
-
}
|
|
2707
|
-
/** Parse a `YYYY-MM-DD` string into a CivilDate, or null when the value
|
|
2708
|
-
* isn't a well-formed in-range calendar date. */
|
|
2709
|
-
function parseCivil(raw) {
|
|
2710
|
-
if (typeof raw !== "string") return null;
|
|
2711
|
-
const match = YMD_PATTERN.exec(raw.trim());
|
|
2712
|
-
if (!match) return null;
|
|
2713
|
-
const year = Number(match[1]);
|
|
2714
|
-
const month = Number(match[2]);
|
|
2715
|
-
const day = Number(match[3]);
|
|
2716
|
-
if (month < 1 || month > 12) return null;
|
|
2717
|
-
if (day < 1 || day > daysInMonth(year, month)) return null;
|
|
2718
|
-
return {
|
|
2719
|
-
y: year,
|
|
2720
|
-
m: month,
|
|
2721
|
-
d: day
|
|
2722
|
-
};
|
|
2723
|
-
}
|
|
2724
|
-
/** `YYYY-MM-DD` for storage in a `date` field. */
|
|
2725
|
-
function formatCivil(date) {
|
|
2726
|
-
return `${pad4(date.y)}-${pad2(date.m)}-${pad2(date.d)}`;
|
|
2727
|
-
}
|
|
2728
|
-
/** A monotonic integer key for a civil date (YYYYMMDD), for ordering /
|
|
2729
|
-
* equality without timezone concerns. */
|
|
2730
|
-
function ordinal(date) {
|
|
2731
|
-
return date.y * 1e4 + date.m * 100 + date.d;
|
|
2732
|
-
}
|
|
2733
|
-
/** Add `n` whole days to a civil date. Uses UTC epoch arithmetic so it
|
|
2734
|
-
* is DST-immune (we only ever read back the civil Y/M/D). */
|
|
2735
|
-
function addDays(date, days) {
|
|
2736
|
-
const shifted = new Date(Date.UTC(date.y, date.m - 1, date.d) + days * ONE_DAY_MS);
|
|
2737
|
-
return {
|
|
2738
|
-
y: shifted.getUTCFullYear(),
|
|
2739
|
-
m: shifted.getUTCMonth() + 1,
|
|
2740
|
-
d: shifted.getUTCDate()
|
|
2741
|
-
};
|
|
2742
|
-
}
|
|
2743
|
-
/** Advance a civil date by one `every` step. Month/year units preserve
|
|
2744
|
-
* the rule's day-of-month anchor, clamped to the target month's length
|
|
2745
|
-
* (no drift); day/week units do civil day arithmetic. */
|
|
2746
|
-
function advanceTriggerDate(source, every) {
|
|
2747
|
-
const { unit, interval } = every;
|
|
2748
|
-
if (unit === "day") return addDays(source, interval);
|
|
2749
|
-
if (unit === "week") return addDays(source, interval * 7);
|
|
2750
|
-
const monthsToAdd = interval * (unit === "year" ? 12 : 1);
|
|
2751
|
-
const total = source.y * 12 + (source.m - 1) + monthsToAdd;
|
|
2752
|
-
const nextYear = Math.floor(total / 12);
|
|
2753
|
-
const nextMonth = total % 12 + 1;
|
|
2754
|
-
const dim = daysInMonth(nextYear, nextMonth);
|
|
2755
|
-
const anchor = every.dayOfMonth === "last" ? dim : every.dayOfMonth ?? source.d;
|
|
2756
|
-
return {
|
|
2757
|
-
y: nextYear,
|
|
2758
|
-
m: nextMonth,
|
|
2759
|
-
d: Math.min(anchor, dim)
|
|
2760
|
-
};
|
|
2761
|
-
}
|
|
2762
|
-
/** True iff `now`'s civil date (local timezone) has reached the fire date
|
|
2763
|
-
* for `triggerRaw` — i.e. the trigger date minus `leadDays` (so a 10-day
|
|
2764
|
-
* lead fires 10 days early). Returns null when `triggerRaw` isn't a
|
|
2765
|
-
* parseable date — callers treat that as "don't fire" and warn. */
|
|
2766
|
-
function isTriggerDue(triggerRaw, now, leadDays = 0) {
|
|
2767
|
-
const due = parseCivil(triggerRaw);
|
|
2768
|
-
if (due === null) return null;
|
|
2769
|
-
const fireDate = leadDays > 0 ? addDays(due, -leadDays) : due;
|
|
2770
|
-
return ordinal({
|
|
2771
|
-
y: now.getFullYear(),
|
|
2772
|
-
m: now.getMonth() + 1,
|
|
2773
|
-
d: now.getDate()
|
|
2774
|
-
}) >= ordinal(fireDate);
|
|
2775
|
-
}
|
|
2776
|
-
var DATE_SUFFIX_PATTERN = /-\d{8}$/;
|
|
2777
|
-
/** Deterministic successor id: `<stem>-<YYYYMMDD>`, where `<stem>` is the
|
|
2778
|
-
* source id with a trailing `-YYYYMMDD` stripped if present. So a chain
|
|
2779
|
-
* shares one stem and each instance is dated:
|
|
2780
|
-
* `rent` → `rent-20260610`
|
|
2781
|
-
* `rent-20260610` → `rent-20260710`
|
|
2782
|
-
* Slug-safe (alphanumeric + hyphen) and a pure function of the inputs,
|
|
2783
|
-
* which is what makes create-if-absent idempotent. */
|
|
2784
|
-
function successorId(sourceId, next) {
|
|
2785
|
-
return `${sourceId.replace(DATE_SUFFIX_PATTERN, "")}-${pad4(next.y)}${pad2(next.m)}${pad2(next.d)}`;
|
|
2786
|
-
}
|
|
2787
|
-
/** True iff `item` satisfies the spawn predicate. With an explicit
|
|
2788
|
-
* `when`, matches `String(item[when.field]) ∈ when.in`. Without one,
|
|
2789
|
-
* defaults to the completion-done condition — the shared, flag-aware
|
|
2790
|
-
* `itemIsDone` (core/completion; downhill import, so the module graph
|
|
2791
|
-
* stays acyclic). */
|
|
2792
|
-
function matchesWhen(when, schema, item) {
|
|
2793
|
-
if (when) {
|
|
2794
|
-
const raw = item[when.field];
|
|
2795
|
-
return raw !== void 0 && raw !== null && when.in.includes(String(raw));
|
|
2796
|
-
}
|
|
2797
|
-
return itemIsDone(schema, item);
|
|
2798
|
-
}
|
|
2799
|
-
/** Resolve the literal `every` that applies to `sourceItem`. Literal-arm
|
|
2800
|
-
* `spawn.every` passes through unchanged. Field-driven `spawn.every` reads
|
|
2801
|
-
* `sourceItem[fromField]` and looks it up in `map`; an empty field or a
|
|
2802
|
-
* value with no map entry yields null (caller skips + logs — see plan §5).
|
|
2803
|
-
* Discovery rejects a map that doesn't cover the enum's values, so null
|
|
2804
|
-
* here means a record that predates a map/enum edit. */
|
|
2805
|
-
function resolveEvery(every, sourceItem) {
|
|
2806
|
-
if (!isFieldDrivenEvery(every)) return every;
|
|
2807
|
-
const raw = sourceItem[every.fromField];
|
|
2808
|
-
if (raw === void 0 || raw === null || raw === "") return null;
|
|
2809
|
-
return every.map[String(raw)] ?? null;
|
|
2810
|
-
}
|
|
2811
|
-
/** Build the successor record purely from (schema, source record, source
|
|
2812
|
-
* id). Returns null when the schema has no spawn/triggerField or the
|
|
2813
|
-
* source's trigger date is unparseable. */
|
|
2814
|
-
function computeSuccessor(schema, sourceItem, sourceId) {
|
|
2815
|
-
const { spawn, triggerField } = schema;
|
|
2816
|
-
if (!spawn || !triggerField) return null;
|
|
2817
|
-
const srcDate = parseCivil(sourceItem[triggerField]);
|
|
2818
|
-
if (srcDate === null) return null;
|
|
2819
|
-
const every = resolveEvery(spawn.every, sourceItem);
|
|
2820
|
-
if (every === null) return null;
|
|
2821
|
-
const next = advanceTriggerDate(srcDate, every);
|
|
2822
|
-
const nextId = successorId(sourceId, next);
|
|
2823
|
-
const record = {};
|
|
2824
|
-
for (const field of spawn.carry ?? []) if (Object.prototype.hasOwnProperty.call(sourceItem, field)) record[field] = sourceItem[field];
|
|
2825
|
-
Object.assign(record, spawn.set ?? {});
|
|
2826
|
-
record[triggerField] = formatCivil(next);
|
|
2827
|
-
record[schema.primaryKey] = nextId;
|
|
2828
|
-
return {
|
|
2829
|
-
id: nextId,
|
|
2830
|
-
record
|
|
2831
|
-
};
|
|
2832
|
-
}
|
|
2833
|
-
/** Warn precisely about which of `computeSuccessor`'s two null causes fired
|
|
2834
|
-
* (plan §5): an unparseable source trigger date (the original cause), or a
|
|
2835
|
-
* field-driven `every` whose record value has no `map` entry (a record that
|
|
2836
|
-
* predates a map/enum edit — discovery rejects this statically otherwise). */
|
|
2837
|
-
function logSpawnSkip(slug, triggerField, every, sourceItem, sourceId) {
|
|
2838
|
-
if (parseCivil(sourceItem[triggerField]) === null) {
|
|
2839
|
-
log.warn("collections", "spawn skipped: source trigger date unparseable", {
|
|
2840
|
-
slug,
|
|
2841
|
-
sourceId,
|
|
2842
|
-
triggerField
|
|
2843
|
-
});
|
|
2844
|
-
return;
|
|
2845
|
-
}
|
|
2846
|
-
const fromField = isFieldDrivenEvery(every) ? every.fromField : void 0;
|
|
2847
|
-
log.warn("collections", "spawn skipped: no `every` mapping for frequency value", {
|
|
2848
|
-
slug,
|
|
2849
|
-
sourceId,
|
|
2850
|
-
fromField,
|
|
2851
|
-
value: fromField === void 0 ? void 0 : sourceItem[fromField]
|
|
2852
|
-
});
|
|
2853
|
-
}
|
|
2854
|
-
/** Idempotently create the successor for `sourceItem` when it matches the
|
|
2855
|
-
* spawn predicate. No-op when the schema declares no spawn, the
|
|
2856
|
-
* predicate doesn't match, the trigger date is unparseable, or the
|
|
2857
|
-
* successor already exists (create-if-absent). Never overwrites an
|
|
2858
|
-
* existing successor — protects any edits the user made to it. */
|
|
2859
|
-
async function maybeSpawnSuccessor(slug, schema, dataDir, sourceItem, sourceId, ioOpts = {}) {
|
|
2860
|
-
const { spawn } = schema;
|
|
2861
|
-
if (!spawn || !schema.triggerField) return;
|
|
2862
|
-
if (!matchesWhen(spawn.when, schema, sourceItem)) return;
|
|
2863
|
-
const computed = computeSuccessor(schema, sourceItem, sourceId);
|
|
2864
|
-
if (computed === null) {
|
|
2865
|
-
logSpawnSkip(slug, schema.triggerField, spawn.every, sourceItem, sourceId);
|
|
2866
|
-
return;
|
|
2867
|
-
}
|
|
2868
|
-
if (matchesWhen(spawn.when, schema, computed.record)) {
|
|
2869
|
-
log.warn("collections", "spawn skipped: successor would be born matching its own predicate (unbounded respawn)", {
|
|
2870
|
-
slug,
|
|
2871
|
-
sourceId,
|
|
2872
|
-
successorId: computed.id
|
|
2873
|
-
});
|
|
2874
|
-
return;
|
|
2875
|
-
}
|
|
2876
|
-
try {
|
|
2877
|
-
const result = await writeItem(dataDir, computed.id, computed.record, {
|
|
2878
|
-
...ioOpts,
|
|
2879
|
-
refuseOverwrite: true,
|
|
2880
|
-
slug
|
|
2881
|
-
});
|
|
2882
|
-
if (result.kind === "ok") log.info("collections", "spawned successor", {
|
|
2883
|
-
slug,
|
|
2884
|
-
sourceId,
|
|
2885
|
-
successorId: computed.id
|
|
2886
|
-
});
|
|
2887
|
-
else if (result.kind !== "conflict") log.warn("collections", "spawn write failed", {
|
|
2888
|
-
slug,
|
|
2889
|
-
sourceId,
|
|
2890
|
-
successorId: computed.id,
|
|
2891
|
-
kind: result.kind
|
|
2892
|
-
});
|
|
2893
|
-
} catch (err) {
|
|
2894
|
-
log.warn("collections", "spawn write threw", {
|
|
2895
|
-
slug,
|
|
2896
|
-
sourceId,
|
|
2897
|
-
successorId: computed.id,
|
|
2898
|
-
error: errorMessage(err)
|
|
2899
|
-
});
|
|
2900
|
-
}
|
|
2901
|
-
}
|
|
2902
|
-
//#endregion
|
|
2903
|
-
//#region src/collection/server/delete.ts
|
|
2904
|
-
/** Human-readable reason for a non-`ok` delete result. Exported so the
|
|
2905
|
-
* route maps `kind` → message without inlining the switch (keeps the
|
|
2906
|
-
* handler short and the mapping unit-testable). The `Record` is
|
|
2907
|
-
* exhaustive — a new refusal kind won't compile until it's added. */
|
|
2908
|
-
function deleteCollectionRefusalMessage(result) {
|
|
2909
|
-
const { slug } = result;
|
|
2910
|
-
return {
|
|
2911
|
-
"user-scope": `collection '${slug}' is user-scope (~/.claude/skills/) and is read-only from MulmoClaude`,
|
|
2912
|
-
preset: `collection '${slug}' is a preset (mc-*) and re-seeds on restart; unstar it from the catalog instead`,
|
|
2913
|
-
"unsafe-data-path": `collection '${slug}' declares a dataPath outside its own data/${slug}/ subtree; refusing to delete`,
|
|
2914
|
-
"path-escape": `a directory for collection '${slug}' escapes the workspace`
|
|
2915
|
-
}[result.kind];
|
|
2916
|
-
}
|
|
2917
|
-
/** The canonical staging dir for a slug: `data/skills/<slug>`. */
|
|
2918
|
-
function stagingSkillDir(workspaceRoot, slug) {
|
|
2919
|
-
return path.join(skillsStagingDir(workspaceRoot), slug);
|
|
2920
|
-
}
|
|
2921
|
-
async function pathExists(target) {
|
|
2922
|
-
try {
|
|
2923
|
-
await stat(target);
|
|
2924
|
-
return true;
|
|
2925
|
-
} catch {
|
|
2926
|
-
return false;
|
|
2927
|
-
}
|
|
2928
|
-
}
|
|
2929
|
-
/** UTC `YYYY-MM-DD` — keeps the archive folder human-sortable. */
|
|
2930
|
-
function todayStamp() {
|
|
2931
|
-
return (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
2932
|
-
}
|
|
2933
|
-
/** Every directory the delete will touch must resolve under the
|
|
2934
|
-
* workspace root — guards against a symlinked ancestor escaping it. */
|
|
2935
|
-
function deleteTargets(collection, workspaceRoot) {
|
|
2936
|
-
return [
|
|
2937
|
-
stagingSkillDir(workspaceRoot, collection.slug),
|
|
2938
|
-
collection.skillDir,
|
|
2939
|
-
collection.dataDir
|
|
2940
|
-
];
|
|
2941
|
-
}
|
|
2942
|
-
/** The records directory the delete recursively archives + removes
|
|
2943
|
-
* (`collection.dataDir`) must live in this collection's OWN per-slug
|
|
2944
|
-
* subtree. `dataDir` is normally derived from `schema.dataPath`, but
|
|
2945
|
-
* `deleteCollection` accepts a `LoadedCollection` whose fields could be
|
|
2946
|
-
* inconsistent — so we validate the RESOLVED target the destructive ops
|
|
2947
|
-
* actually touch, not the schema string. A shared root like `data` or
|
|
2948
|
-
* `data/skills` would otherwise turn the recursive removal into a
|
|
2949
|
-
* workspace-wide wipe whose archive captures only this collection.
|
|
2950
|
-
* `path.resolve` collapses any `..` before the prefix test (symlink
|
|
2951
|
-
* escapes are caught separately by the realpath containment check in
|
|
2952
|
-
* `deleteTargets`).
|
|
2953
|
-
*
|
|
2954
|
-
* TWO acceptable per-slug subtrees:
|
|
2955
|
-
* - `data/<slug>/...` — the convention authored collections default to.
|
|
2956
|
-
* - `data/collections/<slug>/...` — what `normalizedDataPath` in the
|
|
2957
|
-
* registry-import path stamps onto every imported schema (so imported
|
|
2958
|
-
* collections never collide with `data/<slug>/`-shaped authored ones).
|
|
2959
|
-
* Both are equally per-collection and equally safe; a delete that targets
|
|
2960
|
-
* anything else (e.g. raw `data/`, `data/wiki/`, a sibling slug) is
|
|
2961
|
-
* refused. */
|
|
2962
|
-
function isDataDirSafe(dataDir, slug, workspaceRoot) {
|
|
2963
|
-
const acceptableRoots = [path.resolve(workspaceRoot, "data", slug), path.resolve(workspaceRoot, "data", "collections", slug)];
|
|
2964
|
-
const resolved = path.resolve(dataDir);
|
|
2965
|
-
return acceptableRoots.some((root) => resolved === root || resolved.startsWith(root + path.sep));
|
|
2966
|
-
}
|
|
2967
|
-
/** Step 2 of the restore doc — how to bring the records back. A
|
|
2968
|
-
* `dataSource` collection has no record files to copy (its rows live in
|
|
2969
|
-
* the external data file, which the delete never touches). */
|
|
2970
|
-
function restoreRecordsStep(schema) {
|
|
2971
|
-
if (schema.dataPath === void 0) return `2. Records: nothing to copy. This is a \`dataSource\` collection —
|
|
2972
|
-
its records are the rows of \`${schema.dataSource?.path}\`, which the
|
|
2973
|
-
delete never touched.`;
|
|
2974
|
-
return `2. Copy the item data: \`cp\` every file under \`records/\` into
|
|
2975
|
-
\`${schema.dataPath}/\`. The records are part of the collection and
|
|
2976
|
-
must be restored. They are plain data files (NOT bridged), so use
|
|
2977
|
-
\`cp\` — the Write-tool rule in step 1 applies ONLY to the skill
|
|
2978
|
-
files, not to these records (there may be many; copy them, do not
|
|
2979
|
-
Write them one by one).`;
|
|
2980
|
-
}
|
|
2981
|
-
function buildRestoreDoc(collection) {
|
|
2982
|
-
const { slug, schema } = collection;
|
|
2983
|
-
return `# Restore "${schema.title}" (collection \`${slug}\`)
|
|
2984
|
-
|
|
2985
|
-
This folder is an automatic backup made when the collection was deleted.
|
|
2986
|
-
Follow these steps to restore it.
|
|
2987
|
-
|
|
2988
|
-
1. Recreate the skill files in \`data/skills/${slug}/\` using the **Write
|
|
2989
|
-
tool**: read each file under \`skill/\` and Write it to the matching
|
|
2990
|
-
path — \`SKILL.md\`, \`schema.json\`, and any \`templates/*\`.
|
|
2991
|
-
|
|
2992
|
-
IMPORTANT — use the Write tool, NOT \`cp\` / \`mv\` / a shell redirect.
|
|
2993
|
-
The skill-bridge hook mirrors \`data/skills/${slug}/\` into
|
|
2994
|
-
\`.claude/skills/${slug}/\`, and that mirror is what actually registers
|
|
2995
|
-
the collection. The hook only fires on Write/Edit tool calls, so a
|
|
2996
|
-
\`cp\` would leave the files in staging with no \`.claude/skills/\`
|
|
2997
|
-
mirror — the collection would stay invisible. (Writing
|
|
2998
|
-
\`.claude/skills/\` directly is not an option either: that path is
|
|
2999
|
-
permission-gated.)
|
|
3000
|
-
|
|
3001
|
-
${restoreRecordsStep(schema)}
|
|
3002
|
-
|
|
3003
|
-
3. Confirm the collection reappears at \`/collections/${slug}\`.
|
|
3004
|
-
|
|
3005
|
-
- slug: \`${slug}\`
|
|
3006
|
-
- title: ${schema.title}
|
|
3007
|
-
- dataPath: \`${schema.dataPath ?? `(dataSource) ${schema.dataSource?.path}`}\`
|
|
3008
|
-
`;
|
|
3009
|
-
}
|
|
3010
|
-
/** Copy one skill copy + the records + RESTORE.md into `archiveDir`. */
|
|
3011
|
-
async function writeArchive(collection, archiveDir, workspaceRoot) {
|
|
3012
|
-
const staging = stagingSkillDir(workspaceRoot, collection.slug);
|
|
3013
|
-
await cp(await pathExists(staging) ? staging : collection.skillDir, path.join(archiveDir, "skill"), { recursive: true });
|
|
3014
|
-
if (await pathExists(collection.dataDir)) await cp(collection.dataDir, path.join(archiveDir, "records"), { recursive: true });
|
|
3015
|
-
await writeFile(path.join(archiveDir, "RESTORE.md"), buildRestoreDoc(collection), "utf-8");
|
|
3016
|
-
}
|
|
3017
|
-
/** Remove all three locations. `rm -rf`-style (force) so a missing dir
|
|
3018
|
-
* is a no-op; the now-empty data parent (`data/<slug>/` after its
|
|
3019
|
-
* `items/` is gone) is swept too, but only when empty. */
|
|
3020
|
-
async function removeLocations(collection, workspaceRoot) {
|
|
3021
|
-
await rm(stagingSkillDir(workspaceRoot, collection.slug), {
|
|
3022
|
-
recursive: true,
|
|
3023
|
-
force: true
|
|
3024
|
-
});
|
|
3025
|
-
await rm(collection.skillDir, {
|
|
3026
|
-
recursive: true,
|
|
3027
|
-
force: true
|
|
3028
|
-
});
|
|
3029
|
-
await rm(collection.dataDir, {
|
|
3030
|
-
recursive: true,
|
|
3031
|
-
force: true
|
|
3032
|
-
});
|
|
3033
|
-
await rmdir(path.dirname(collection.dataDir)).catch(() => void 0);
|
|
3034
|
-
}
|
|
3035
|
-
async function deleteCollection(collection, opts = {}) {
|
|
3036
|
-
const { slug } = collection;
|
|
3037
|
-
const workspaceRoot = opts.workspaceRoot ?? getWorkspaceRoot();
|
|
3038
|
-
if (collection.source === "user") return {
|
|
3039
|
-
kind: "user-scope",
|
|
3040
|
-
slug
|
|
3041
|
-
};
|
|
3042
|
-
if (isPresetSlug$1(slug)) return {
|
|
3043
|
-
kind: "preset",
|
|
3044
|
-
slug
|
|
3045
|
-
};
|
|
3046
|
-
if (!isDataDirSafe(collection.dataDir, slug, workspaceRoot)) {
|
|
3047
|
-
log.warn("collections", "deleteCollection refused: dataDir is not under the per-collection root", {
|
|
3048
|
-
slug,
|
|
3049
|
-
dataDir: collection.dataDir
|
|
3050
|
-
});
|
|
3051
|
-
return {
|
|
3052
|
-
kind: "unsafe-data-path",
|
|
3053
|
-
slug
|
|
3054
|
-
};
|
|
3055
|
-
}
|
|
3056
|
-
if (deleteTargets(collection, workspaceRoot).some((target) => !isContainedInRoot(target, workspaceRoot))) {
|
|
3057
|
-
log.warn("collections", "deleteCollection refused: a target escapes the workspace", { slug });
|
|
3058
|
-
return {
|
|
3059
|
-
kind: "path-escape",
|
|
3060
|
-
slug
|
|
3061
|
-
};
|
|
3062
|
-
}
|
|
3063
|
-
const archiveRel = path.join(archiveDir(), `${opts.dateStamp ?? todayStamp()}-${randomUUID()}`);
|
|
3064
|
-
const archiveDir$1 = path.join(workspaceRoot, archiveRel);
|
|
3065
|
-
await mkdir(archiveDir$1, { recursive: true });
|
|
3066
|
-
await writeArchive(collection, archiveDir$1, workspaceRoot);
|
|
3067
|
-
await removeLocations(collection, workspaceRoot);
|
|
3068
|
-
log.info("collections", "collection deleted + archived", {
|
|
3069
|
-
slug,
|
|
3070
|
-
archive: archiveRel
|
|
3071
|
-
});
|
|
3072
|
-
return {
|
|
3073
|
-
kind: "ok",
|
|
3074
|
-
slug,
|
|
3075
|
-
archivePath: archiveRel
|
|
3076
|
-
};
|
|
3077
|
-
}
|
|
3078
|
-
//#endregion
|
|
3079
|
-
//#region src/collection/server/views.ts
|
|
3080
|
-
async function fileExists(target) {
|
|
3081
|
-
try {
|
|
3082
|
-
await stat(target);
|
|
3083
|
-
return true;
|
|
3084
|
-
} catch (err) {
|
|
3085
|
-
const { code } = err;
|
|
3086
|
-
if (code === "ENOENT" || code === "ENOTDIR") return false;
|
|
3087
|
-
throw err;
|
|
3088
|
-
}
|
|
3089
|
-
}
|
|
3090
|
-
/** The authoritative base dir for a collection's schema.json + view HTML.
|
|
3091
|
-
* For a project collection, prefer the staging tree when its schema.json is
|
|
3092
|
-
* present (authoring layout); otherwise fall back to the active skill dir
|
|
3093
|
-
* (imported layout — staging never materialised). For feed / user, it's
|
|
3094
|
-
* always the discovered skillDir. Matches `readCustomViewHtml` so reads and
|
|
3095
|
-
* deletes agree on both layouts. */
|
|
3096
|
-
async function canonicalBase(collection, workspaceRoot, safeSlug) {
|
|
3097
|
-
if (collection.source !== "project") return collection.skillDir;
|
|
3098
|
-
const staging = path.join(skillsStagingDir(workspaceRoot), safeSlug);
|
|
3099
|
-
if (await fileExists(path.join(staging, "schema.json"))) return staging;
|
|
3100
|
-
return collection.skillDir;
|
|
3101
|
-
}
|
|
3102
|
-
/** Every on-disk schema.json that must reflect the removal. The active
|
|
3103
|
-
* `<skillDir>/schema.json` is the discovery anchor and is always present.
|
|
3104
|
-
* The staging copy is included only when it actually exists, so an imported
|
|
3105
|
-
* project collection (no staging mirror) doesn't have an empty staging tree
|
|
3106
|
-
* materialised by a side effect of the delete. */
|
|
3107
|
-
async function schemaWriteTargets(collection, workspaceRoot, safeSlug) {
|
|
3108
|
-
const active = path.join(collection.skillDir, SCHEMA_FILE$1);
|
|
3109
|
-
if (collection.source !== "project") return [active];
|
|
3110
|
-
const stagingSchema = path.join(skillsStagingDir(workspaceRoot), safeSlug, SCHEMA_FILE$1);
|
|
3111
|
-
const targets = [];
|
|
3112
|
-
if (await fileExists(stagingSchema)) targets.push(stagingSchema);
|
|
3113
|
-
targets.push(active);
|
|
3114
|
-
return targets;
|
|
3115
|
-
}
|
|
3116
|
-
/** Idempotent unlink — a missing file is fine (the schema entry still gets
|
|
3117
|
-
* cleaned), but a real error (permissions, etc.) propagates. */
|
|
3118
|
-
async function unlinkIfPresent(target) {
|
|
3119
|
-
try {
|
|
3120
|
-
await unlink(target);
|
|
3121
|
-
} catch (err) {
|
|
3122
|
-
if (err.code !== "ENOENT") throw err;
|
|
3123
|
-
}
|
|
3124
|
-
}
|
|
3125
|
-
/** Re-read the canonical schema.json, drop the `views[]` entry, and write the
|
|
3126
|
-
* result back to every on-disk copy so staging + active stay identical. Reads
|
|
3127
|
-
* raw (not `collection.schema`) so fields the typed schema doesn't model are
|
|
3128
|
-
* preserved verbatim. */
|
|
3129
|
-
async function removeViewFromSchemas(collection, viewId, workspaceRoot, safeSlug) {
|
|
3130
|
-
const base = await canonicalBase(collection, workspaceRoot, safeSlug);
|
|
3131
|
-
const canonical = path.join(base, SCHEMA_FILE$1);
|
|
3132
|
-
const parsed = JSON.parse(await readFile(canonical, "utf-8"));
|
|
3133
|
-
if (Array.isArray(parsed.views)) parsed.views = parsed.views.filter((entry) => entry?.id !== viewId);
|
|
3134
|
-
const serialized = `${JSON.stringify(parsed, null, 2)}\n`;
|
|
3135
|
-
for (const target of await schemaWriteTargets(collection, workspaceRoot, safeSlug)) await writeFileAtomic(target, serialized);
|
|
3136
|
-
}
|
|
3137
|
-
/** Delete one custom view from `collection`: unlink its HTML file and drop it
|
|
3138
|
-
* from every schema.json copy. User-scope and preset (mc-*) collections are
|
|
3139
|
-
* refused (read-only / re-seeded on boot), consistent with `deleteCollection`. */
|
|
3140
|
-
async function deleteCustomView(collection, viewId, opts = {}) {
|
|
3141
|
-
if (collection.source === "user") return { kind: "user-scope" };
|
|
3142
|
-
if (isPresetSlug$1(collection.slug)) return { kind: "preset" };
|
|
3143
|
-
const safeSlug = safeSlugName(collection.slug);
|
|
3144
|
-
if (safeSlug === null) return {
|
|
3145
|
-
kind: "unsafe-path",
|
|
3146
|
-
viewId
|
|
3147
|
-
};
|
|
3148
|
-
const views = collection.schema.views ?? [];
|
|
3149
|
-
const view = views.find((entry) => entry.id === viewId);
|
|
3150
|
-
if (!view) return {
|
|
3151
|
-
kind: "not-found",
|
|
3152
|
-
viewId
|
|
3153
|
-
};
|
|
3154
|
-
const workspaceRoot = opts.workspaceRoot ?? getWorkspaceRoot();
|
|
3155
|
-
const htmlPath = resolveTemplatePath(await canonicalBase(collection, workspaceRoot, safeSlug), view.file);
|
|
3156
|
-
if (htmlPath === null) return {
|
|
3157
|
-
kind: "unsafe-path",
|
|
3158
|
-
viewId
|
|
3159
|
-
};
|
|
3160
|
-
await removeViewFromSchemas(collection, viewId, workspaceRoot, safeSlug);
|
|
3161
|
-
if (!views.some((entry) => entry.id !== viewId && entry.file === view.file)) await unlinkIfPresent(htmlPath);
|
|
3162
|
-
return {
|
|
3163
|
-
kind: "ok",
|
|
3164
|
-
viewId
|
|
3165
|
-
};
|
|
3166
|
-
}
|
|
3167
|
-
/** True for a launcher-managed preset slug (`mc-<something>`). The sync logic uses
|
|
3168
|
-
* this to bound which active skills it may refresh/prune, so user-authored skills are
|
|
3169
|
-
* never touched. */
|
|
3170
|
-
function isPresetSlug(slug) {
|
|
3171
|
-
return slug.startsWith("mc-") && slug.length > 3;
|
|
3172
|
-
}
|
|
3173
|
-
//#endregion
|
|
3174
|
-
//#region src/collection/server/manageTool.ts
|
|
3175
|
-
/** Refuse an unselective getItems beyond this many records — a silent
|
|
3176
|
-
* truncation would read as "covered everything", and an unbounded dump
|
|
3177
|
-
* of a large collection is a token bomb. `ids` or `fields` lifts it. */
|
|
3178
|
-
var MAX_UNSELECTIVE_ITEMS = 200;
|
|
3179
|
-
/** schema.json basename under a skill dir (canonical staging + active mirror). */
|
|
3180
|
-
var SCHEMA_FILE = "schema.json";
|
|
3181
|
-
/** The collection-authoring reference, served by the `schemaDocs` action. */
|
|
3182
|
-
var SCHEMA_DOCS_FILE = "collection-skills.md";
|
|
3183
|
-
/** Cap the rejected-schema issue list so a deeply-broken schema can't flood the result. */
|
|
3184
|
-
var MAX_SCHEMA_ISSUES = 20;
|
|
3185
|
-
/** The workspace help-docs dir both hosts seed (`@mulmoclaude/core/workspace-setup`
|
|
3186
|
-
* syncs the bundled assets here) — the user-editable copy schemaDocs prefers. */
|
|
3187
|
-
var HELPS_DIR = "config/helps";
|
|
3188
|
-
/** Resolve the workspace root the same way every collections call does:
|
|
3189
|
-
* the injected override (tests) or the configured collection host. */
|
|
3190
|
-
function resolveBase(deps) {
|
|
3191
|
-
return deps.workspaceRoot ?? getWorkspaceRoot();
|
|
3192
|
-
}
|
|
3193
|
-
/** Shared "unknown collection" message — its schema.json is missing or
|
|
3194
|
-
* failed validation, so discovery skipped it. */
|
|
3195
|
-
function unknownCollection(slug) {
|
|
3196
|
-
return `manageCollection: unknown collection '${defangForPrompt(slug)}' — its schema.json is missing or failed validation.`;
|
|
3197
|
-
}
|
|
3198
|
-
function optionalStringArray(value, name) {
|
|
3199
|
-
if (value === void 0) return { ok: true };
|
|
3200
|
-
if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string" && entry.length > 0)) return {
|
|
3201
|
-
ok: false,
|
|
3202
|
-
error: `manageCollection: \`${name}\` must be an array of non-empty strings when present.`
|
|
3203
|
-
};
|
|
3204
|
-
return {
|
|
3205
|
-
ok: true,
|
|
3206
|
-
value
|
|
3207
|
-
};
|
|
3208
|
-
}
|
|
3209
|
-
/** Project a record down to the requested fields. The primary key is
|
|
3210
|
-
* always kept so every returned record stays addressable for a
|
|
3211
|
-
* follow-up getItems/putItems. */
|
|
3212
|
-
function projectFields(record, fields, primaryKey) {
|
|
3213
|
-
const keys = fields.includes(primaryKey) ? fields : [primaryKey, ...fields];
|
|
3214
|
-
return Object.fromEntries(keys.filter((key) => key in record).map((key) => [key, record[key]]));
|
|
3215
|
-
}
|
|
3216
|
-
/** The validation warning appended to a getItems result when stored
|
|
3217
|
-
* record files are malformed (they're silently skipped at read time,
|
|
3218
|
-
* so without this they'd just look missing). Issue strings quote
|
|
3219
|
-
* record-controlled text → defanged, mirroring the presentCollection
|
|
3220
|
-
* dispatch. The record VALUES in `items` ride verbatim, like a raw
|
|
3221
|
-
* file Read — only host-composed report strings are defanged. */
|
|
3222
|
-
async function recordIssuesWarning(collection, deps) {
|
|
3223
|
-
if (deps.ablateValidation) return void 0;
|
|
3224
|
-
const issues = await validateCollectionRecords(collection, { workspaceRoot: deps.workspaceRoot });
|
|
3225
|
-
if (issues.length === 0) return void 0;
|
|
3226
|
-
const lines = issues.map((issue) => `- ${defangForPrompt(issue.file)}: ${defangForPrompt(issue.problem)}`).join("\n");
|
|
3227
|
-
return `${issues.length} record file(s) have data problems and are missing from this result. Fix each (Read → correct → Write):\n${lines}`;
|
|
3228
|
-
}
|
|
3229
|
-
async function loadRequestedItems(collection, ids, deps) {
|
|
3230
|
-
const store = storeFor(collection, { workspaceRoot: deps.workspaceRoot });
|
|
3231
|
-
if (!ids) return {
|
|
3232
|
-
items: await store.list(),
|
|
3233
|
-
missing: []
|
|
3234
|
-
};
|
|
3235
|
-
const items = [];
|
|
3236
|
-
const missing = [];
|
|
3237
|
-
for (const recordId of ids) {
|
|
3238
|
-
const item = await store.read(recordId).catch(() => null);
|
|
3239
|
-
if (item) items.push(item);
|
|
3240
|
-
else missing.push(recordId);
|
|
3241
|
-
}
|
|
3242
|
-
return {
|
|
3243
|
-
items,
|
|
3244
|
-
missing
|
|
3245
|
-
};
|
|
3246
|
-
}
|
|
3247
|
-
async function handleGetItems(collection, args, deps) {
|
|
3248
|
-
const { items, missing } = await loadRequestedItems(collection, args.ids, deps);
|
|
3249
|
-
if (!args.ids && !args.fields && items.length > 200) return `manageCollection: refused — '${collection.slug}' has ${items.length} records, over the unselective limit of 200. Pass \`ids\` for specific records or \`fields\` to project only the columns you need.`;
|
|
3250
|
-
const enriched = await enrichItems(collection, items, deps);
|
|
3251
|
-
const projected = args.fields ? enriched.map((item) => projectFields(item, args.fields, collection.schema.primaryKey)) : enriched;
|
|
3252
|
-
const warning = !args.ids || missing.length > 0 ? await recordIssuesWarning(collection, deps) : void 0;
|
|
3253
|
-
return JSON.stringify({
|
|
3254
|
-
collection: collection.slug,
|
|
3255
|
-
count: projected.length,
|
|
3256
|
-
items: projected,
|
|
3257
|
-
...missing.length > 0 ? { missing: missing.map((recordId) => defangForPrompt(recordId)) } : {},
|
|
3258
|
-
...warning ? { warning } : {}
|
|
3259
|
-
});
|
|
3260
|
-
}
|
|
3261
|
-
/** Reject writes that set host-computed keys, with a pointer at the
|
|
3262
|
-
* writable source of truth (the toggle's enum) where one exists. */
|
|
3263
|
-
function computedKeyProblem(record, schema) {
|
|
3264
|
-
for (const key of Object.keys(record)) {
|
|
3265
|
-
const spec = schema.fields[key];
|
|
3266
|
-
if (!spec || !COMPUTED_TYPES.has(spec.type)) continue;
|
|
3267
|
-
if (spec.type === "toggle" && spec.field) return `'${key}' is a toggle projection — write the enum field '${spec.field}' instead`;
|
|
3268
|
-
return `'${key}' is ${{
|
|
3269
|
-
derived: "derived",
|
|
3270
|
-
embed: "an embed",
|
|
3271
|
-
backlinks: "a backlinks view",
|
|
3272
|
-
rollup: "a rollup"
|
|
3273
|
-
}[spec.type] ?? "computed"} — computed by the host, remove it from the record`;
|
|
3274
|
-
}
|
|
3275
|
-
return null;
|
|
3276
|
-
}
|
|
3277
|
-
/** `mode: "merge"` resolves the row against the EXISTING record —
|
|
3278
|
-
* a partial row updates just the fields it carries, instead of a
|
|
3279
|
-
* whole-record upsert silently erasing the optional fields it omits
|
|
3280
|
-
* (an upsert of `{id, status}` would pass validation yet drop
|
|
3281
|
-
* `notes`/`lesson`/…). Merge is a partial UPDATE by definition, so a
|
|
3282
|
-
* missing record is a reject, not an implicit create — a merged-over-
|
|
3283
|
-
* nothing partial record is exactly the data shape this mode exists
|
|
3284
|
-
* to prevent.
|
|
3285
|
-
*
|
|
3286
|
-
* Computed keys found in the STORED record are stripped before the
|
|
3287
|
-
* merge: the caller's own row was already computed-key-rejected, but a
|
|
3288
|
-
* raw-written / legacy record can carry a stale `derived`/`embed`/
|
|
3289
|
-
* `toggle` value, and re-writing it would perpetuate a forged
|
|
3290
|
-
* host-computed value. A merge heals the record instead.
|
|
3291
|
-
*
|
|
3292
|
-
* readItem THROWS on a malformed stored file (only ENOENT is null) —
|
|
3293
|
-
* downgraded to a per-row rejection here, like loadRequestedItems'
|
|
3294
|
-
* `missing`, so one broken file can't abort the whole putItems batch. */
|
|
3295
|
-
async function mergeWithExisting(collection, record, itemId, deps) {
|
|
3296
|
-
let existing;
|
|
3297
|
-
try {
|
|
3298
|
-
existing = await readItem(collection.dataDir, itemId, { workspaceRoot: deps.workspaceRoot });
|
|
3299
|
-
} catch {
|
|
3300
|
-
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"`;
|
|
3301
|
-
}
|
|
3302
|
-
if (!existing) return `'${itemId}' not found — mode "merge" updates an existing record; use "upsert" or "create" to add it`;
|
|
3303
|
-
const stored = Object.entries(existing).filter(([key]) => {
|
|
3304
|
-
const spec = collection.schema.fields[key];
|
|
3305
|
-
return !spec || !COMPUTED_TYPES.has(spec.type);
|
|
3306
|
-
});
|
|
3307
|
-
return {
|
|
3308
|
-
...Object.fromEntries(stored),
|
|
3309
|
-
...record
|
|
3310
|
-
};
|
|
3311
|
-
}
|
|
3312
|
-
async function putOneItem(collection, record, mode, deps) {
|
|
3313
|
-
const { schema } = collection;
|
|
3314
|
-
const itemId = resolveCreateItemId(schema, record);
|
|
3315
|
-
const reject = (about, problem) => ({ rejected: {
|
|
3316
|
-
id: defangForPrompt(about),
|
|
3317
|
-
problem: defangForPrompt(problem)
|
|
3318
|
-
} });
|
|
3319
|
-
if (itemId === null) return reject("(no id)", `record has no '${schema.primaryKey}' value — set it (it doubles as the filename)`);
|
|
3320
|
-
const computed = computedKeyProblem(record, schema);
|
|
3321
|
-
if (computed) return reject(itemId, computed);
|
|
3322
|
-
let toWrite = record;
|
|
3323
|
-
if (mode === "merge") {
|
|
3324
|
-
const merged = await mergeWithExisting(collection, record, itemId, deps);
|
|
3325
|
-
if (typeof merged === "string") return reject(itemId, merged);
|
|
3326
|
-
toWrite = merged;
|
|
3327
|
-
}
|
|
3328
|
-
if (!deps.ablateValidation) {
|
|
3329
|
-
const invalid = validateRecordObject(toWrite, itemId, schema);
|
|
3330
|
-
if (invalid) return reject(itemId, invalid);
|
|
3331
|
-
}
|
|
3332
|
-
const result = await writeItem(collection.dataDir, itemId, toWrite, {
|
|
3333
|
-
refuseOverwrite: mode === "create",
|
|
3334
|
-
workspaceRoot: deps.workspaceRoot,
|
|
3335
|
-
slug: collection.slug
|
|
3336
|
-
});
|
|
3337
|
-
if (result.kind === "ok") return { written: result.itemId };
|
|
3338
|
-
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)`);
|
|
3339
|
-
if (result.kind === "conflict") return reject(itemId, `'${itemId}' already exists — mode "create" refuses overwrite; use "upsert" to update it`);
|
|
3340
|
-
return reject(itemId, "write refused: the collection's data dir escapes the workspace");
|
|
3341
|
-
}
|
|
3342
|
-
/** Aggregation over a collection via the structured query DSL
|
|
3343
|
-
* (`core/queryZ.ts`) — the paved road for counts / sums / group-bys
|
|
3344
|
-
* that a row listing can't answer honestly. Two engines behind one
|
|
3345
|
-
* shape: a dataSource collection queries its CSV natively through the
|
|
3346
|
-
* store (`store.query`, uncapped whole-file scan); a file-backed
|
|
3347
|
-
* collection aggregates its ENRICHED records (computed fields —
|
|
3348
|
-
* `derived` / `rollup` / `toggle` — are real columns) through the same
|
|
3349
|
-
* compiled SQL over a temp JSONL (`runQueryOverRows`). */
|
|
3350
|
-
async function handleQueryItems(collection, queryArg, deps) {
|
|
3351
|
-
const parsed = CollectionQueryZ.safeParse(queryArg);
|
|
3352
|
-
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")}`;
|
|
3353
|
-
const store = storeFor(collection, { workspaceRoot: deps.workspaceRoot });
|
|
3354
|
-
if (store.query) {
|
|
3355
|
-
const rows = await store.query(parsed.data);
|
|
3356
|
-
return JSON.stringify({
|
|
3357
|
-
collection: collection.slug,
|
|
3358
|
-
count: rows.length,
|
|
3359
|
-
rows
|
|
3360
|
-
});
|
|
3361
|
-
}
|
|
3362
|
-
const rows = await runQueryOverRows(await enrichItems(collection, await store.list(), deps), parsed.data);
|
|
3363
|
-
return JSON.stringify({
|
|
3364
|
-
collection: collection.slug,
|
|
3365
|
-
count: rows.length,
|
|
3366
|
-
rows
|
|
3367
|
-
});
|
|
3368
|
-
}
|
|
3369
|
-
async function handlePutItems(collection, args, deps) {
|
|
3370
|
-
if (!collectionWritable(collection)) return `manageCollection: ${readOnlyRefusal(collection.slug)} (its records are the rows of '${collection.schema.dataSource?.path}'; edit that file to change the data).`;
|
|
3371
|
-
const written = [];
|
|
3372
|
-
const rejected = [];
|
|
3373
|
-
for (const record of args.items) {
|
|
3374
|
-
const outcome = await putOneItem(collection, record, args.mode, deps);
|
|
3375
|
-
if (outcome.written) written.push(outcome.written);
|
|
3376
|
-
if (outcome.rejected) rejected.push(outcome.rejected);
|
|
3377
|
-
}
|
|
3378
|
-
return JSON.stringify({
|
|
3379
|
-
collection: collection.slug,
|
|
3380
|
-
written,
|
|
3381
|
-
rejected
|
|
3382
|
-
});
|
|
3383
|
-
}
|
|
3384
|
-
function parsePutItems(args, slug) {
|
|
3385
|
-
const { items, mode } = args;
|
|
3386
|
-
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.";
|
|
3387
|
-
if (mode !== void 0 && mode !== "upsert" && mode !== "create" && mode !== "merge") return "manageCollection: `mode` must be \"upsert\" (default), \"create\", or \"merge\".";
|
|
3388
|
-
return {
|
|
3389
|
-
slug,
|
|
3390
|
-
items,
|
|
3391
|
-
mode: mode ?? "upsert"
|
|
3392
|
-
};
|
|
3393
|
-
}
|
|
3394
|
-
/** The machine-readable workspace ontology: every collection with its
|
|
3395
|
-
* identity, record count, and outbound `ref`/`embed` relations. Slugs
|
|
3396
|
-
* are discovery-sanitized; titles/labels are workspace-authored schema
|
|
3397
|
-
* text and ride verbatim — the same trust class as the record values
|
|
3398
|
-
* getItems returns. */
|
|
3399
|
-
async function handleGetOntology(deps) {
|
|
3400
|
-
const collections = await buildWorkspaceOntology(deps);
|
|
3401
|
-
return JSON.stringify({
|
|
3402
|
-
count: collections.length,
|
|
3403
|
-
collections
|
|
3404
|
-
});
|
|
3405
|
-
}
|
|
3406
|
-
/** Return the collection-authoring reference (`collection-skills.md`).
|
|
3407
|
-
* Workspace copy first (reflects user edits), bundled asset as the
|
|
3408
|
-
* always-present fallback. Both reads guarded; if neither resolves the
|
|
3409
|
-
* agent still gets an actionable message instead of a thrown call. */
|
|
3410
|
-
async function handleSchemaDocs(deps) {
|
|
3411
|
-
const candidates = [path.join(resolveBase(deps), HELPS_DIR, SCHEMA_DOCS_FILE), ...deps.bundledHelpsDir ? [path.join(deps.bundledHelpsDir(), SCHEMA_DOCS_FILE)] : []];
|
|
3412
|
-
for (const candidate of candidates) try {
|
|
3413
|
-
return await readFile(candidate, "utf-8");
|
|
3414
|
-
} catch {}
|
|
3415
|
-
return `manageCollection: could not read the collection-authoring reference (${SCHEMA_DOCS_FILE}).`;
|
|
3416
|
-
}
|
|
3417
|
-
/** Return the raw schema.json of an existing collection, for editing.
|
|
3418
|
-
* Staging (the canonical writable copy) first, the active mirror as a
|
|
3419
|
-
* fallback for user-scope skills that have no staging copy. Raw text —
|
|
3420
|
-
* not the parsed schema — so the agent edits the true on-disk source. */
|
|
3421
|
-
async function handleGetSchema(slug, deps) {
|
|
3422
|
-
const collection = await loadCollection(slug, deps);
|
|
3423
|
-
if (!collection) return unknownCollection(slug);
|
|
3424
|
-
const candidates = [path.join(dataSkillDir(resolveBase(deps), collection.slug), SCHEMA_FILE), path.join(collection.skillDir, SCHEMA_FILE)];
|
|
3425
|
-
for (const candidate of candidates) try {
|
|
3426
|
-
return await readFile(candidate, "utf-8");
|
|
3427
|
-
} catch {}
|
|
3428
|
-
return `manageCollection: '${defangForPrompt(slug)}' has no readable ${SCHEMA_FILE}.`;
|
|
3429
|
-
}
|
|
3430
|
-
/** Refuse a schema edit the host can't honour: user-scope/feed collections
|
|
3431
|
-
* are read-only, and presets (mc-*) re-seed on restart so an edit is lost. */
|
|
3432
|
-
function schemaEditRefusal(collection, slug) {
|
|
3433
|
-
if (collection.source !== "project") return `manageCollection: '${defangForPrompt(slug)}' is ${collection.source}-scope and read-only here — only project collections (data/skills/) can be edited.`;
|
|
3434
|
-
if (isPresetSlug(slug)) return `manageCollection: '${defangForPrompt(slug)}' is a preset (mc-*) and re-seeds on restart — copy it under a different slug to customise.`;
|
|
3435
|
-
return null;
|
|
3436
|
-
}
|
|
3437
|
-
/** Turn a CollectionSchemaZ failure into a short, actionable list the
|
|
3438
|
-
* agent can fix, pointing back at the field reference. */
|
|
3439
|
-
function formatSchemaIssues(issues) {
|
|
3440
|
-
const shown = issues.slice(0, 20);
|
|
3441
|
-
const lines = shown.map((issue) => `- ${issue.path.map(String).join(".") || "(root)"}: ${defangForPrompt(issue.message)}`).join("\n");
|
|
3442
|
-
const omitted = issues.length - shown.length;
|
|
3443
|
-
return `manageCollection: schema rejected — fix and retry (call schemaDocs for the field reference):\n${lines}${omitted > 0 ? `\n- …and ${omitted} more issue(s); fix these first and retry.` : ""}`;
|
|
3444
|
-
}
|
|
3445
|
-
/** Write the validated schema to the canonical staging copy, then mirror
|
|
3446
|
-
* it into the active `.claude/skills/` tree discovery reads — an internal
|
|
3447
|
-
* write doesn't fire the skill-bridge hook, so we mirror explicitly. */
|
|
3448
|
-
async function writeAndMirrorSchema(slug, schema, deps) {
|
|
3449
|
-
const base = resolveBase(deps);
|
|
3450
|
-
await writeFileAtomic(path.join(dataSkillDir(base, slug), SCHEMA_FILE), `${JSON.stringify(schema, null, 2)}\n`);
|
|
3451
|
-
mirrorSkillWrite(base, {
|
|
3452
|
-
slug,
|
|
3453
|
-
relSegments: [SCHEMA_FILE]
|
|
3454
|
-
});
|
|
3455
|
-
try {
|
|
3456
|
-
await deps.refreshAfterWrite?.();
|
|
3457
|
-
} catch {}
|
|
3458
|
-
}
|
|
3459
|
-
/** The post-Zod acceptance gates discovery applies before a parsed schema
|
|
3460
|
-
* becomes a live collection. Mirrors discovery's checks (`loadOneCollection`)
|
|
3461
|
-
* so a write can't pass here yet be silently skipped on the next load.
|
|
3462
|
-
* putSchema only runs for project-scope collections, so the feed-`ingest`
|
|
3463
|
-
* gate doesn't apply. Returns a one-line reason, or null when the schema
|
|
3464
|
-
* would be accepted. */
|
|
3465
|
-
function schemaDiscoveryGate(schema, base) {
|
|
3466
|
-
const primaryField = schema.fields[schema.primaryKey];
|
|
3467
|
-
if (!primaryField) return `primaryKey '${schema.primaryKey}' is not one of the declared fields`;
|
|
3468
|
-
if (primaryField.primary !== true) return `the primaryKey field '${schema.primaryKey}' must be flagged \`primary: true\``;
|
|
3469
|
-
if (schema.dataPath !== void 0 && resolveDataDir(schema.dataPath, base) === null) return `dataPath '${schema.dataPath}' escapes the workspace`;
|
|
3470
|
-
if (schema.dataSource !== void 0 && resolveDataDir(schema.dataSource.path, base) === null) return `dataSource.path '${schema.dataSource.path}' escapes the workspace`;
|
|
3471
|
-
return null;
|
|
3472
|
-
}
|
|
3473
|
-
/** Validate a schema against CollectionSchemaZ and, on success, persist it.
|
|
3474
|
-
* Edit-only: a new collection is created by writing SKILL.md + schema.json
|
|
3475
|
-
* under data/skills/<slug>/ (the normal create flow), not through here. */
|
|
3476
|
-
async function handlePutSchema(slug, schemaArg, deps) {
|
|
3477
|
-
if (!schemaArg || typeof schemaArg !== "object" || Array.isArray(schemaArg)) return "manageCollection: `schema` is required for putSchema — the full collection schema object.";
|
|
3478
|
-
const collection = await loadCollection(slug, deps);
|
|
3479
|
-
if (!collection) return `manageCollection: unknown collection '${defangForPrompt(slug)}' — create it by writing SKILL.md + ${SCHEMA_FILE} under data/skills/${defangForPrompt(slug)}/, then edit it here.`;
|
|
3480
|
-
const refusal = schemaEditRefusal(collection, slug);
|
|
3481
|
-
if (refusal) return refusal;
|
|
3482
|
-
const parsed = CollectionSchemaZ.safeParse(schemaArg);
|
|
3483
|
-
if (!parsed.success) return formatSchemaIssues(parsed.error.issues);
|
|
3484
|
-
const gate = schemaDiscoveryGate(parsed.data, resolveBase(deps));
|
|
3485
|
-
if (gate) return `manageCollection: schema rejected — ${gate} (call schemaDocs for the field reference). It passes basic validation but discovery would skip it, hiding the collection.`;
|
|
3486
|
-
await writeAndMirrorSchema(collection.slug, parsed.data, deps);
|
|
3487
|
-
return JSON.stringify({
|
|
3488
|
-
collection: collection.slug,
|
|
3489
|
-
written: true
|
|
3490
|
-
});
|
|
3491
|
-
}
|
|
3492
|
-
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.";
|
|
3493
|
-
/** Validate getItems' optional `ids`/`fields` args, then delegate. */
|
|
3494
|
-
async function dispatchGetItems(collection, args, deps) {
|
|
3495
|
-
const ids = optionalStringArray(args.ids, "ids");
|
|
3496
|
-
if (!ids.ok) return ids.error;
|
|
3497
|
-
const fields = optionalStringArray(args.fields, "fields");
|
|
3498
|
-
if (!fields.ok) return fields.error;
|
|
3499
|
-
return handleGetItems(collection, {
|
|
3500
|
-
slug: collection.slug,
|
|
3501
|
-
ids: ids.value,
|
|
3502
|
-
fields: fields.value
|
|
3503
|
-
}, deps);
|
|
3504
|
-
}
|
|
3505
|
-
async function manageCollectionHandler(deps, args) {
|
|
3506
|
-
const action = typeof args.action === "string" ? args.action : "";
|
|
3507
|
-
if (action === "schemaDocs") return handleSchemaDocs(deps);
|
|
3508
|
-
if (action === "getOntology") return handleGetOntology(deps);
|
|
3509
|
-
const slug = typeof args.slug === "string" ? args.slug.trim() : "";
|
|
3510
|
-
if (!slug) return "manageCollection: `slug` is required (the collection's slug).";
|
|
3511
|
-
if (action === "getSchema") return handleGetSchema(slug, deps);
|
|
3512
|
-
if (action === "putSchema") return handlePutSchema(slug, args.schema, deps);
|
|
3513
|
-
if (action !== "getItems" && action !== "putItems" && action !== "queryItems") return "manageCollection: `action` must be \"getItems\", \"putItems\", \"queryItems\", \"getOntology\", \"schemaDocs\", \"getSchema\", or \"putSchema\".";
|
|
3514
|
-
const collection = await loadCollection(slug, deps);
|
|
3515
|
-
if (!collection) return unknownCollection(slug);
|
|
3516
|
-
if (action === "getItems") return dispatchGetItems(collection, args, deps);
|
|
3517
|
-
if (action === "queryItems") return handleQueryItems(collection, args.query, deps);
|
|
3518
|
-
const parsed = parsePutItems(args, slug);
|
|
3519
|
-
if (typeof parsed === "string") return parsed;
|
|
3520
|
-
return handlePutItems(collection, parsed, deps);
|
|
3521
|
-
}
|
|
3522
|
-
var MANAGE_COLLECTION_DEFINITION = {
|
|
3523
|
-
name: "manageCollection",
|
|
3524
|
-
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.",
|
|
3525
|
-
inputSchema: {
|
|
3526
|
-
type: "object",
|
|
3527
|
-
properties: {
|
|
3528
|
-
action: {
|
|
3529
|
-
type: "string",
|
|
3530
|
-
enum: [
|
|
3531
|
-
"getItems",
|
|
3532
|
-
"putItems",
|
|
3533
|
-
"queryItems",
|
|
3534
|
-
"getOntology",
|
|
3535
|
-
"schemaDocs",
|
|
3536
|
-
"getSchema",
|
|
3537
|
-
"putSchema"
|
|
3538
|
-
],
|
|
3539
|
-
description: "What to do."
|
|
3540
|
-
},
|
|
3541
|
-
slug: {
|
|
3542
|
-
type: "string",
|
|
3543
|
-
description: "The collection's slug (its directory name, e.g. `stock-quotes`). Required for everything except schemaDocs and getOntology."
|
|
3544
|
-
},
|
|
3545
|
-
ids: {
|
|
3546
|
-
type: "array",
|
|
3547
|
-
items: { type: "string" },
|
|
3548
|
-
description: "getItems: only these record ids (primary-key values). Omit for all records."
|
|
3549
|
-
},
|
|
3550
|
-
fields: {
|
|
3551
|
-
type: "array",
|
|
3552
|
-
items: { type: "string" },
|
|
3553
|
-
description: "getItems: only these fields per record (the primary key is always included). Omit for all fields. Use on large collections."
|
|
3554
|
-
},
|
|
3555
|
-
items: {
|
|
3556
|
-
type: "array",
|
|
3557
|
-
items: { type: "object" },
|
|
3558
|
-
description: "putItems: the record objects to store. Each must carry the schema's primaryKey value (it doubles as the filename)."
|
|
3559
|
-
},
|
|
3560
|
-
mode: {
|
|
3561
|
-
type: "string",
|
|
3562
|
-
enum: [
|
|
3563
|
-
"upsert",
|
|
3564
|
-
"create",
|
|
3565
|
-
"merge"
|
|
3566
|
-
],
|
|
3567
|
-
description: "putItems: \"upsert\" (default) replaces existing records WHOLE; \"create\" rejects rows whose id already exists; \"merge\" updates only the fields a row carries, keeping the rest of the existing record (rejects unknown ids). Use \"merge\" when changing a few fields."
|
|
3568
|
-
},
|
|
3569
|
-
schema: {
|
|
3570
|
-
type: "object",
|
|
3571
|
-
description: "putSchema: the full collection schema object (same shape as schema.json — title, icon, dataPath, primaryKey, fields, …). Call getSchema first for the current one, and schemaDocs for the field DSL."
|
|
3572
|
-
},
|
|
3573
|
-
query: {
|
|
3574
|
-
type: "object",
|
|
3575
|
-
description: "queryItems: a structured aggregation query — `{ groupBy?: [\"col\"], aggregates?: { alias: { op: \"count\"|\"sum\"|\"avg\"|\"min\"|\"max\", column? } }, where?: [{ field, op, value }], orderBy?: [{ field, dir? }], limit? }`. At least one of groupBy/aggregates. On a dataSource (CSV) collection it scans the WHOLE file uncapped (unlike getItems); on a file-backed collection it aggregates the ENRICHED records, so computed fields (derived/rollup/toggle) are queryable columns. Use it for counts / sums / averages / group-bys instead of arithmetic over getItems output."
|
|
3576
|
-
}
|
|
3577
|
-
},
|
|
3578
|
-
required: ["action"]
|
|
3579
|
-
}
|
|
3580
|
-
};
|
|
3581
|
-
function makeManageCollectionTool(deps = {}) {
|
|
3582
|
-
return {
|
|
3583
|
-
definition: MANAGE_COLLECTION_DEFINITION,
|
|
3584
|
-
alwaysActive: true,
|
|
3585
|
-
prompt: MANAGE_COLLECTION_PROMPT,
|
|
3586
|
-
handler: (args) => manageCollectionHandler(deps, args)
|
|
3587
|
-
};
|
|
3588
|
-
}
|
|
3589
|
-
//#endregion
|
|
3590
|
-
export { isRegularFile as $, validateCollectionRecords as A, decodeCsvRecordId as B, discoverCollections as C, CollectionSchemaZ as D, toSummary as E, collectionWritable as F, compileJsonlQuery as G, encodeCsvRecordId as H, readOnlyRefusal as I, MAX_QUERY_ROWS as J, CollectionQueryZ as K, storeFor as L, compileRecordZ as M, recordFieldProblem as N, applyMutateAction as O, runQueryOverRows as P, generateItemId as Q, MAX_CSV_ROWS as R, acceptParsedSchema as S, setCollectionChangePublisher as St, toDetail as T, normalizeCsvValue as U, dedupeByRecordId as V, compileCsvQuery as W, buildCollectionActionSeedPrompt as X, buildActionSeedPrompt as Y, deleteItem as Z, errorMessage as _, collectionsRegistriesConfigPath as _t, deleteCollection as a, readSkillTemplate as at, buildWorkspaceOntology as b, log as bt, computeSuccessor as c, writeFileAtomic as ct, isTriggerDue as d, isContainedInWorkspace as dt, listItems as et, maybeSpawnSuccessor as f, itemFilePath as ft, ONE_SECOND_MS as g, safeSlugName as gt, successorId as h, safeRecordId as ht, deleteCustomView as i, readItem as it, validateRecordObject as j, firstMutateParamProblem as k, daysInMonth as l, SCHEMA_FILE$1 as lt, resolveEvery as m, resolveTemplatePath as mt, MAX_UNSELECTIVE_ITEMS as n, readCustomViewHtml as nt, deleteCollectionRefusalMessage as o, resolveCreateItemId as ot, parseCivil as p, resolveDataDir as pt, DEFAULT_QUERY_ROWS as q, makeManageCollectionTool as r, readCustomViewI18n as rt, advanceTriggerDate as s, writeItem as st, MAX_SCHEMA_ISSUES as t, promptPathsFor as tt, formatCivil as u, isContainedInRoot as ut, computeCollectionIcon as v, configureCollectionHost as vt, loadCollection as w, schemaRelations as x, publishCollectionChange as xt, enrichItems as y, getWorkspaceRoot as yt, csvRowToItem as z };
|
|
2509
|
+
export { isPresetSlug as $, generateItemId as A, writeFileAtomic as B, compileJsonlQuery as C, buildActionSeedPrompt as D, MAX_QUERY_ROWS as E, readCustomViewI18n as F, resolveDataDir as G, isContainedInRoot as H, readItem as I, safeSlugName as J, resolveTemplatePath as K, readSkillTemplate as L, listItems as M, promptPathsFor as N, buildCollectionActionSeedPrompt as O, readCustomViewHtml as P, getWorkspaceRoot as Q, resolveCreateItemId as R, compileCsvQuery as S, DEFAULT_QUERY_ROWS as T, isContainedInWorkspace as U, SCHEMA_FILE as V, itemFilePath as W, collectionsRegistriesConfigPath as X, archiveDir as Y, configureCollectionHost as Z, decodeCsvRecordId as _, toSummary as a, normalizeCsvValue as b, collectionWritable as c, checkpointSqliteDatabase as d, log as et, pageFromFullRead as f, csvRowToItem as g, cacheDir as h, toDetail as i, isRegularFile as j, deleteItem as k, readOnlyRefusal as l, MAX_CSV_ROWS as m, discoverCollections as n, setCollectionChangePublisher as nt, CollectionSchemaZ as o, projectItemFields as p, safeRecordId as q, loadCollection as r, skillsStagingDir as rt, resolveMutateSet as s, acceptParsedSchema as t, publishCollectionChange as tt, storeFor as u, dedupeByRecordId as v, CollectionQueryZ as w, queryCsv as x, encodeCsvRecordId as y, writeItem as z };
|
|
3591
2510
|
|
|
3592
|
-
//# sourceMappingURL=
|
|
2511
|
+
//# sourceMappingURL=discovery-QUi_7aNG.js.map
|