@mulmoclaude/core 3.5.0 → 3.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/assets/helps/collection-skills.md +49 -1
  2. package/assets/helps/error-recovery.md +53 -0
  3. package/dist/calendarGrid-CQ8MVSRb.js.map +1 -1
  4. package/dist/calendarGrid-DGILaVxI.cjs.map +1 -1
  5. package/dist/collection/core/schema.d.ts +8 -1
  6. package/dist/collection/core/schemaZ.d.ts +36 -26
  7. package/dist/collection/firestore.cjs +51 -0
  8. package/dist/collection/firestore.cjs.map +1 -0
  9. package/dist/collection/firestore.d.ts +1 -0
  10. package/dist/collection/firestore.js +50 -0
  11. package/dist/collection/firestore.js.map +1 -0
  12. package/dist/collection/registry/server/index.cjs +19 -19
  13. package/dist/collection/registry/server/index.cjs.map +1 -1
  14. package/dist/collection/registry/server/index.js +2 -2
  15. package/dist/collection/server/appManifest.d.ts +53 -0
  16. package/dist/collection/server/delete.d.ts +10 -0
  17. package/dist/collection/server/discoveredCollection.d.ts +10 -0
  18. package/dist/collection/server/discovery.d.ts +1 -0
  19. package/dist/collection/server/firestoreDocs.d.ts +39 -0
  20. package/dist/collection/server/firestoreStore.d.ts +14 -0
  21. package/dist/collection/server/host.d.ts +52 -0
  22. package/dist/collection/server/index.cjs +72 -52
  23. package/dist/collection/server/index.d.ts +8 -1
  24. package/dist/collection/server/index.js +3 -3
  25. package/dist/collection/server/manageTool.d.ts +4 -0
  26. package/dist/collection/server/publish.d.ts +56 -0
  27. package/dist/collection/server/publishChecks.d.ts +29 -0
  28. package/dist/collection/server/publishManifest.d.ts +181 -0
  29. package/dist/collection/server/publishProject.d.ts +86 -0
  30. package/dist/collection/server/validate.d.ts +12 -0
  31. package/dist/collection-watchers/index.cjs +143 -52
  32. package/dist/collection-watchers/index.cjs.map +1 -1
  33. package/dist/collection-watchers/index.js +132 -41
  34. package/dist/collection-watchers/index.js.map +1 -1
  35. package/dist/collection-watchers/reconciler.d.ts +1 -1
  36. package/dist/feeds/server/index.cjs +10 -10
  37. package/dist/feeds/server/index.cjs.map +1 -1
  38. package/dist/feeds/server/index.js +2 -2
  39. package/dist/google/index.cjs +12 -12
  40. package/dist/google/index.cjs.map +1 -1
  41. package/dist/google/index.js +1 -1
  42. package/dist/{server-BiRLLMpW.js → server-B48Jyxcj.js} +1100 -230
  43. package/dist/server-B48Jyxcj.js.map +1 -0
  44. package/dist/{server-5EMj3naj.cjs → server-CWZyg8fn.cjs} +1333 -385
  45. package/dist/server-CWZyg8fn.cjs.map +1 -0
  46. package/dist/{discovery-Ck4AqikY.cjs → store-5_P_NsGa.cjs} +2108 -1755
  47. package/dist/store-5_P_NsGa.cjs.map +1 -0
  48. package/dist/{discovery-DH9wweuj.js → store-_61sO8K8.js} +2333 -2022
  49. package/dist/store-_61sO8K8.js.map +1 -0
  50. package/dist/whisper/index.cjs +1 -1
  51. package/dist/whisper/index.js +1 -1
  52. package/package.json +7 -1
  53. package/dist/discovery-Ck4AqikY.cjs.map +0 -1
  54. package/dist/discovery-DH9wweuj.js.map +0 -1
  55. package/dist/server-5EMj3naj.cjs.map +0 -1
  56. package/dist/server-BiRLLMpW.js.map +0 -1
@@ -1,15 +1,15 @@
1
1
  import { a as isErrorWithCode, l as isUnknownArray, r as hasNumberProp, s as isRecord } from "./dist-D8zokgGo.js";
2
2
  import { n as writeFileAtomic, t as canonicalRoot } from "./root-BMroU_mB.js";
3
- import { A as AGENT_INGEST_KIND, C as sharedCollectionKey, D as isSafeRecordId, I as isReadOnlySchema, L as storageKindFor, M as FEED_SCHEDULES, N as INGEST_KINDS, O as isSafeSlug, R as fieldText, T as SAFE_SLUG_PATTERN, b as localCollectionKeyOf, j as COMPUTED_TYPES, w as SAFE_RECORD_ID_PATTERN, z as fieldTextOrNull } from "./calendarGrid-CQ8MVSRb.js";
3
+ import { A as AGENT_INGEST_KIND, C as sharedCollectionKey, D as isSafeRecordId, I as isReadOnlySchema, L as storageKindFor, M as FEED_SCHEDULES, N as INGEST_KINDS, O as isSafeSlug, R as fieldText, T as SAFE_SLUG_PATTERN, b as localCollectionKeyOf, j as COMPUTED_TYPES, w as SAFE_RECORD_ID_PATTERN, y as isValidCollectionName, z as fieldTextOrNull } from "./calendarGrid-CQ8MVSRb.js";
4
4
  import { n as projectRecordFields } from "./project-B0j_Vf_Q.js";
5
5
  import { n as isSafeCustomViewI18nPath, r as isSafeCustomViewPath, t as isSafeActionTemplatePath } from "./templatePath-k_WNbL_Q.js";
6
- import { realpathSync, watch } from "node:fs";
6
+ import { readFileSync, realpathSync, watch } from "node:fs";
7
7
  import path from "node:path";
8
8
  import { createHash, randomBytes } from "node:crypto";
9
9
  import { lstat, mkdir, open, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
10
+ import { z } from "zod";
10
11
  import { tmpdir } from "node:os";
11
12
  import iconv from "iconv-lite";
12
- import { z } from "zod";
13
13
  //#region src/host/hostSlot.ts
14
14
  function createHostSlot(name) {
15
15
  let current = null;
@@ -103,6 +103,7 @@ function collectionChangeKey(payload, fallbackRoot) {
103
103
  }
104
104
  var hostSlot = createHostSlot("@mulmoclaude/core/collection/server: configureCollectionHost()");
105
105
  var changePublisher = null;
106
+ var firestoreAccessor = null;
106
107
  /** Wire the engine to a host. Call once at server startup, before any
107
108
  * collection storage operation. Re-binding to a *different* host throws —
108
109
  * silently redirecting later filesystem operations to another workspace
@@ -126,6 +127,25 @@ function setCollectionChangePublisher(publish) {
126
127
  function publishCollectionChange(payload) {
127
128
  changePublisher?.(payload);
128
129
  }
130
+ /** Wire the accessor for the host's authenticated Firestore session.
131
+ *
132
+ * Separate from `configureCollectionHost` for the same reason
133
+ * `setCollectionChangePublisher` is: the host binding is set at the top of
134
+ * server startup, but this session doesn't exist until the user connects
135
+ * remote-host (and closes again on disconnect), so it cannot be part of a
136
+ * one-shot binding. Optional — left unwired, only shared collections are
137
+ * affected, and they report "not connected". Pass `null` to detach. */
138
+ function setFirestoreAccessor(accessor) {
139
+ firestoreAccessor = accessor;
140
+ }
141
+ /** The host's live Firestore access, or null when there is no session (or the
142
+ * host never wired one — every non-shared backend leaves it unset).
143
+ * Callers MUST surface null as an actionable "connect remote-host first",
144
+ * never as an empty result: silence would be indistinguishable from a
145
+ * collection that genuinely has no records. */
146
+ function firestoreHandle() {
147
+ return firestoreAccessor?.() ?? null;
148
+ }
129
149
  function requireHost() {
130
150
  return hostSlot.get();
131
151
  }
@@ -190,6 +210,18 @@ function isPresetSlug(slug) {
190
210
  * without a workspace root. */
191
211
  var log = createForwardingLogger(() => hostSlot.peek()?.log ?? null);
192
212
  //#endregion
213
+ //#region src/collection/server/backendAvailability.ts
214
+ /** Thrown by a store when its engine or session cannot serve the request. */
215
+ var BackendUnavailableError = class extends Error {
216
+ constructor(message) {
217
+ super(message);
218
+ this.name = "BackendUnavailableError";
219
+ }
220
+ };
221
+ function isBackendUnavailable(err) {
222
+ return err instanceof BackendUnavailableError;
223
+ }
224
+ //#endregion
193
225
  //#region src/collection/server/paths.ts
194
226
  var SCHEMA_FILE = "schema.json";
195
227
  /** Sanitise a user-supplied slug into a safe directory-name leaf.
@@ -301,1571 +333,636 @@ function resolveTemplatePath(skillDir, templateRelPath) {
301
333
  return resolved;
302
334
  }
303
335
  //#endregion
304
- //#region src/collection/server/io.ts
305
- /** True iff `filePath` exists and is a regular file (NOT a symlink).
306
- * Defends `listItems` / `readItem` against `*.json` symlinks placed
307
- * inside an otherwise-contained data dir — without this, a record
308
- * file could symlink to /etc/passwd and the detail endpoint would
309
- * happily serve it. Returns false on ENOENT and on any other lstat
310
- * failure so the caller's "missing" branch covers those cases too.
311
- * Exported so `ontology.ts`'s record COUNT classifies entries with the
312
- * SAME lstat logic — the two must agree on what a record file is. */
313
- async function isRegularFile(filePath) {
314
- try {
315
- return (await lstat(filePath)).isFile();
316
- } catch {
317
- return false;
318
- }
319
- }
320
- /** Read one JSON record file. Returns null when the file is missing,
321
- * is a symlink (file-disclosure defense), parses to a non-object,
322
- * or has a read/parse error. Caller logs the per-entry skip — this
323
- * helper just classifies. Split out to keep `listItems` under the
324
- * `sonarjs/cognitive-complexity` threshold. */
325
- /** Parse a record file's text into a plain-object `CollectionItem`, or
326
- * null when it isn't a JSON object (array / scalar / null). */
327
- function parseRecordJson(raw) {
328
- const parsed = JSON.parse(raw);
329
- return isRecord(parsed) ? parsed : null;
336
+ //#region src/collection/server/storePage.ts
337
+ /** Project `fields` (+ the primary key, always) out of each record. Thin
338
+ * server-typed alias over the shared isomorphic `projectRecordFields`
339
+ * (../core/project.ts) — kept as the store layer's exported name. */
340
+ function projectItemFields(items, fields, primaryKey) {
341
+ return projectRecordFields(items, fields, primaryKey);
330
342
  }
331
- async function tryReadRecord(filePath) {
332
- if (!await isRegularFile(filePath)) return null;
333
- try {
334
- return parseRecordJson(await readFile(filePath, "utf-8"));
335
- } catch {
336
- return null;
337
- }
343
+ /** Slice + project an already-ordered full read into a `ListPage` — the
344
+ * shared emulation for stores without native paging. Pure, exported for
345
+ * tests. `limit: 0` is a valid "count only" page. */
346
+ function pageFromFullRead(items, opts, primaryKey, truncated) {
347
+ const offset = Math.max(0, opts.offset ?? 0);
348
+ const end = opts.limit === void 0 ? items.length : offset + Math.max(0, opts.limit);
349
+ return {
350
+ items: projectItemFields(items.slice(offset, end), opts.fields, primaryKey),
351
+ total: items.length,
352
+ truncated
353
+ };
338
354
  }
339
- /** Read every record under `dataDir`. Returns [] if the dir doesn't
340
- * exist yet (legitimate first-use state). Malformed JSON files and
341
- * symlinked records are skipped (the latter is a file-disclosure
342
- * defense — see `isRegularFile`). Re-validates the realpath
343
- * containment to defend against a symlinked data dir appearing
344
- * between discovery and use. */
345
- async function listItems(dataDir, opts = {}) {
346
- if (!isContainedInRoot(dataDir, opts.workspaceRoot ?? getWorkspaceRoot())) {
347
- log.warn("collections", "listItems refused: dataDir escapes workspace via symlink", { dataDir });
348
- return [];
349
- }
350
- let entries;
355
+ //#endregion
356
+ //#region src/collection/server/firestoreStore.ts
357
+ /** What every operation throws when there is no live session. Worded as an
358
+ * instruction because it surfaces straight to the user and the agent. */
359
+ var NOT_CONNECTED = "shared collection unavailable: connect remote-host first — these records live in the app's Firestore, not in the workspace, so nothing can be read or written while the session is closed";
360
+ /** What a schema declaring `storage.type: "firestore"` must have had resolved
361
+ * for it before it can be served. Its absence is a programming error here, not
362
+ * a user-facing state: discovery REFUSES such a schema when the repository
363
+ * declares no `aid`, so a collection that reached this store has one. */
364
+ var NO_APP = "shared collection has no app id — discovery should have refused this schema; check that the repository's app.json declares an `aid`";
365
+ /** The records subcollection of one shared collection.
366
+ *
367
+ * Takes a KEY, never loose strings, and the key is the only way to reach this
368
+ * function. `sharedCollectionKey` is where the name rule lives (the charset a
369
+ * Firestore document id, a pubsub channel segment and the completion-bell id
370
+ * must all survive), so building a path cannot be a way around it. */
371
+ function sharedItemsPath(key) {
372
+ return `apps/${key.aid}/collections/${key.cid}/items`;
373
+ }
374
+ /** The collection's identity, from what discovery resolved. Throws on a
375
+ * missing `appId` — see NO_APP. */
376
+ function keyOf(collection) {
377
+ if (collection.appId === void 0) throw new Error(NO_APP);
378
+ return sharedCollectionKey(collection.appId, collection.slug);
379
+ }
380
+ function requireHandle() {
381
+ const handle = firestoreHandle();
382
+ if (handle === null) throw new BackendUnavailableError(NOT_CONNECTED);
383
+ return handle;
384
+ }
385
+ /** Firestore's own refusal, named.
386
+ *
387
+ * `permission-denied` is the failure a shared collection has most often and
388
+ * the one the SDK explains worst ("Missing or insufficient permissions") — it
389
+ * says nothing about WHO was refused, which is the only fact that leads to a
390
+ * fix. Authorization here is the app's member roster, keyed by email, so the
391
+ * signed-in address is what the app's owner needs in order to add it. This is
392
+ * the whole reason `FirestoreHandle` carries `email`.
393
+ *
394
+ * Reported as a `BackendUnavailableError` deliberately, even though it is a
395
+ * refusal rather than an outage: the layers above catch broadly, and without a
396
+ * type to test, `store.read(...).catch(() => null)` reports "record missing"
397
+ * and an ontology count reports 0 — a denial would read as an empty
398
+ * collection, which is the exact confusion this backend refuses to create. */
399
+ function isPermissionDenied(err) {
400
+ return isRecord(err) && err.code === "permission-denied";
401
+ }
402
+ function deniedMessage(key, email) {
403
+ return `permission denied on shared collection '${key.cid}' of app '${key.aid}' — signed in as ${email}. A shared collection is authorized by the app's member roster (by email), so this address needs a role for '${key.cid}' (or '*'); only the app's owner can add it.`;
404
+ }
405
+ /** Run one SDK call, translating a roster denial. Every read and write goes
406
+ * through this — a denial reaching one path and not another would mean the
407
+ * message a user sees depends on which screen they were on. */
408
+ async function guarded(key, email, run) {
351
409
  try {
352
- entries = await readdir(dataDir);
410
+ return await run();
353
411
  } catch (err) {
354
- if (isErrorWithCode(err) && err.code === "ENOENT") return [];
355
- throw err;
356
- }
357
- const results = [];
358
- for (const name of entries) {
359
- if (!name.endsWith(".json")) continue;
360
- if (name.startsWith(".")) continue;
361
- const filePath = path.join(dataDir, name);
362
- const record = await tryReadRecord(filePath);
363
- if (record === null) {
364
- log.warn("collections", "skipping record (missing, symlink, or unreadable)", { path: filePath });
365
- continue;
366
- }
367
- results.push(record);
412
+ if (!isPermissionDenied(err)) throw err;
413
+ throw new BackendUnavailableError(deniedMessage(key, email));
368
414
  }
369
- return results;
370
415
  }
371
- /** Read one record by id. Returns null when the file is missing,
372
- * when the resolved path escapes the workspace via a symlink, or
373
- * when the record file itself is a symlink (file-disclosure
374
- * defense — see `isRegularFile`). */
375
- async function readItem(dataDir, itemId, opts = {}) {
416
+ /** A stored document's fields → a record. A document written by hand (or by an
417
+ * older version) can hold anything, so a non-object is dropped rather than
418
+ * surfaced as a broken record — the same fail-soft the file store applies to
419
+ * an unparseable `.json`. */
420
+ function toItem(data) {
421
+ return isRecord(data) ? data : null;
422
+ }
423
+ /** Record ids are validated with the SAME helper every other backend uses.
424
+ * Firestore would accept ids the file store refuses, but a record should stay
425
+ * portable between backends — and an id that can't round-trip to a filename
426
+ * would break an export back to a file collection. */
427
+ function withSafeId(itemId, onInvalid, run) {
376
428
  const safeId = safeRecordId(itemId);
377
- if (safeId === null) return null;
378
- if (!isContainedInRoot(dataDir, opts.workspaceRoot ?? getWorkspaceRoot())) return null;
379
- const filePath = itemFilePath(dataDir, safeId);
380
- if (!await isRegularFile(filePath)) return null;
381
- try {
382
- return parseRecordJson(await readFile(filePath, "utf-8"));
383
- } catch (err) {
384
- if (isErrorWithCode(err) && err.code === "ENOENT") return null;
385
- throw err;
386
- }
429
+ if (safeId === null) return onInvalid();
430
+ return run(safeId, requireHandle());
431
+ }
432
+ async function firestoreList(key) {
433
+ const { docs, email } = requireHandle();
434
+ return (await guarded(key, email, () => docs.list(sharedItemsPath(key)))).map((entry) => toItem(entry.data)).filter((item) => item !== null);
435
+ }
436
+ /** Paging is emulated over a full ordered read rather than pushed into
437
+ * Firestore: `offset` has no server-side form there (the cursor API needs the
438
+ * preceding document, which a stateless offset/limit call doesn't have), and
439
+ * `total` needs the full count anyway. Hence `nativePaging: false` — the
440
+ * capability is honest about the cost. */
441
+ async function firestorePage(key, primaryKey, opts) {
442
+ const items = await firestoreList(key);
443
+ const offset = Math.max(0, opts.offset ?? 0);
444
+ return {
445
+ items: projectItemFields(opts.limit === void 0 ? items.slice(offset) : items.slice(offset, offset + Math.max(0, opts.limit)), opts.fields, primaryKey),
446
+ total: items.length,
447
+ truncated: false
448
+ };
387
449
  }
388
- /** The symlink-containment refusal every record path shares: one check, one
389
- * warn, one answer. Extracted because this is a security RULE applied at
390
- * three sites (write pre-mkdir, write post-mkdir, delete) — a fix to the
391
- * check must not be able to land at only one of them.
392
- *
393
- * `stage` names the call site so the warn stays as diagnosable as the three
394
- * hand-written copies were.
395
- *
396
- * Scope, stated explicitly because a reviewer asks every time: this catches
397
- * a symlink that EXISTS when we look — `isContainedInRoot` realpaths the
398
- * closest existing ancestor, so a pre-planted escape is refused. It does not
399
- * and cannot close the check-then-use race, where an ancestor is swapped for
400
- * a symlink between this call and the `mkdir` / `open` / `unlink` that
401
- * follows. Closing that needs directory-handle I/O anchored at the workspace
402
- * (`openat` + `O_NOFOLLOW`), which `node:fs` does not expose — it would mean
403
- * a different I/O layer, not a tighter check here.
404
- *
405
- * That race is deliberately outside this app's threat model: the process is
406
- * loopback-bound and bearer-authed, so anyone able to swap directories inside
407
- * the workspace is already the workspace owner — the same trust principal the
408
- * writes belong to. Revisit if collections ever serve a lower-trust caller. */
409
- function escapesWorkspace(dataDir, workspaceRoot, itemId, stage) {
410
- if (isContainedInRoot(dataDir, workspaceRoot)) return false;
411
- log.warn("collections", `${stage} refused: dataDir escapes workspace via symlink`, {
412
- dataDir,
413
- itemId
414
- });
415
- return true;
450
+ async function firestoreRead(key, itemId) {
451
+ return withSafeId(itemId, () => Promise.resolve(null), async (safeId, { docs, email }) => toItem(await guarded(key, email, () => docs.get(sharedItemsPath(key), safeId))));
416
452
  }
417
- /** Write a record. Ensures the directory exists, validates the id,
418
- * re-checks symlink containment after mkdir, and writes atomically.
419
- *
420
- * Create path (`refuseOverwrite: true`) uses an O_EXCL `wx` open
421
- * rather than `stat` + `writeFileAtomic` to close a check-then-write
422
- * race: two concurrent POSTs would otherwise both pass the existence
423
- * check and one would silently overwrite the other. The trade-off
424
- * is that the create path is not crash-atomic (a partial file could
425
- * remain if the process dies mid-write); acceptable here because
426
- * records are small JSON blobs and the next read either parses or
427
- * is skipped via the "malformed JSON" branch in `listItems`.
453
+ /** Publish the "records changed" ping for a shared collection.
428
454
  *
429
- * Update path (`refuseOverwrite: false`) uses `writeFileAtomic` so
430
- * PUT remains crash-atomic. No race there — the URL pins the id. */
431
- async function writeItem(dataDir, itemId, item, opts = {}) {
432
- const safeId = safeRecordId(itemId);
433
- if (safeId === null) return {
455
+ * `sharedCollectionChangePayload` NEVER stamps a root, and that matters beyond
456
+ * tidiness: this payload is relayed to the browser and on into an
457
+ * LLM-generated custom-view iframe, so a filesystem path on it would be a
458
+ * disclosure. The type makes it unreachable rather than trusting the caller. */
459
+ function publishShared(key, ids, operation) {
460
+ publishCollectionChange(sharedCollectionChangePayload({
461
+ slug: key.cid,
462
+ ids,
463
+ op: operation
464
+ }, key.aid));
465
+ }
466
+ async function firestoreWrite(key, itemId, item, opts) {
467
+ return withSafeId(itemId, () => Promise.resolve({
434
468
  kind: "invalid-id",
435
469
  itemId
436
- };
437
- const workspaceRoot = opts.workspaceRoot ?? getWorkspaceRoot();
438
- if (escapesWorkspace(dataDir, workspaceRoot, safeId, "writeItem (pre-mkdir)")) return {
439
- kind: "path-escape",
440
- itemId: safeId
441
- };
442
- await mkdir(dataDir, { recursive: true });
443
- if (escapesWorkspace(dataDir, workspaceRoot, safeId, "writeItem (post-mkdir)")) return {
444
- kind: "path-escape",
445
- itemId: safeId
446
- };
447
- const filePath = itemFilePath(dataDir, safeId);
448
- const payload = `${JSON.stringify(item, null, 2)}\n`;
449
- if (opts.refuseOverwrite) {
450
- let handle;
451
- try {
452
- handle = await open(filePath, "wx");
453
- } catch (err) {
454
- if (isErrorWithCode(err) && err.code === "EEXIST") return {
470
+ }), async (safeId, { docs, email }) => {
471
+ const collectionPath = sharedItemsPath(key);
472
+ if (opts.refuseOverwrite) {
473
+ if (!await guarded(key, email, () => docs.create(collectionPath, safeId, item))) return {
455
474
  kind: "conflict",
456
475
  itemId: safeId
457
476
  };
458
- throw err;
459
- }
460
- try {
461
- await handle.writeFile(payload);
462
- } finally {
463
- await handle.close();
464
- }
465
- } else await writeFileAtomic(filePath, payload);
466
- if (opts.slug) publishCollectionChange(collectionChangePayload({
467
- slug: opts.slug,
468
- ids: [safeId],
469
- op: "upsert"
470
- }, opts.workspaceRoot));
471
- return {
472
- kind: "ok",
473
- itemId: safeId,
474
- item
475
- };
477
+ } else await guarded(key, email, () => docs.set(collectionPath, safeId, item));
478
+ if (opts.slug) publishShared(key, [safeId], "upsert");
479
+ return {
480
+ kind: "ok",
481
+ itemId: safeId,
482
+ item
483
+ };
484
+ });
476
485
  }
477
- async function deleteItem(dataDir, itemId, opts = {}) {
478
- const safeId = safeRecordId(itemId);
479
- if (safeId === null) return {
486
+ async function firestoreDelete(key, itemId, opts) {
487
+ return withSafeId(itemId, () => Promise.resolve({
480
488
  kind: "invalid-id",
481
489
  itemId
482
- };
483
- if (escapesWorkspace(dataDir, opts.workspaceRoot ?? getWorkspaceRoot(), safeId, "deleteItem")) return {
484
- kind: "path-escape",
485
- itemId: safeId
486
- };
487
- const filePath = itemFilePath(dataDir, safeId);
488
- try {
489
- await unlink(filePath);
490
- if (opts.slug) publishCollectionChange(collectionChangePayload({
491
- slug: opts.slug,
492
- ids: [safeId],
493
- op: "delete"
494
- }, opts.workspaceRoot));
490
+ }), async (safeId, { docs, email }) => {
491
+ if (!await guarded(key, email, () => docs.delete(sharedItemsPath(key), safeId))) return {
492
+ kind: "not-found",
493
+ itemId: safeId
494
+ };
495
+ if (opts.slug) publishShared(key, [safeId], "delete");
495
496
  return {
496
497
  kind: "ok",
497
498
  itemId: safeId
498
499
  };
500
+ });
501
+ }
502
+ /** The store factory registered for `storage.type === "firestore"`.
503
+ * Synchronous and connection-agnostic by contract — see the header. */
504
+ function firestoreStoreFor(collection, opts) {
505
+ const { primaryKey } = collection.schema;
506
+ const ioOpts = {
507
+ ...opts,
508
+ slug: opts.slug ?? collection.slug
509
+ };
510
+ return {
511
+ capabilities: {
512
+ writable: true,
513
+ nativeQuery: false,
514
+ nativePaging: false
515
+ },
516
+ list: async () => firestoreList(keyOf(collection)),
517
+ page: async (pageOpts = {}) => firestorePage(keyOf(collection), primaryKey, pageOpts),
518
+ read: async (itemId) => firestoreRead(keyOf(collection), itemId),
519
+ write: async (itemId, item, writeOpts = {}) => firestoreWrite(keyOf(collection), itemId, item, {
520
+ ...ioOpts,
521
+ refuseOverwrite: writeOpts.refuseOverwrite
522
+ }),
523
+ delete: async (itemId) => firestoreDelete(keyOf(collection), itemId, ioOpts)
524
+ };
525
+ }
526
+ //#endregion
527
+ //#region src/collection/server/appManifest.ts
528
+ /** The app declaration's filename, at the repository root. */
529
+ var APP_MANIFEST_FILE = "app.json";
530
+ /** Read `<root>/app.json` and return its `aid`.
531
+ *
532
+ * SYNCHRONOUS on purpose. The caller is `acceptParsedSchema`, which is sync
533
+ * and is shared by discovery and `manageCollection`'s `putSchema` precisely so
534
+ * that a schema which would be skipped on the next discovery cannot be written
535
+ * as if it were fine. Making this async would split that gate in two, and the
536
+ * half that lost the check is the half the author sees. The file is a few
537
+ * hundred bytes and is read once per firestore collection per discovery pass.
538
+ *
539
+ * Never cached. `app.json` is edited by hand and by the agent, and a cache
540
+ * here would mean the app a collection points at is whatever it was when the
541
+ * server started. */
542
+ function loadAppManifest(root) {
543
+ let raw;
544
+ try {
545
+ raw = readFileSync(path.join(root, APP_MANIFEST_FILE), "utf-8");
499
546
  } catch (err) {
500
547
  if (isErrorWithCode(err) && err.code === "ENOENT") return {
501
- kind: "not-found",
502
- itemId: safeId
548
+ ok: false,
549
+ kind: "missing"
550
+ };
551
+ return {
552
+ ok: false,
553
+ kind: "unreadable",
554
+ detail: String(err)
503
555
  };
504
- throw err;
505
556
  }
557
+ return parseAppManifest(raw);
506
558
  }
507
- /** Generate a short random hex id. Used by POST when the form doesn't
508
- * carry a primary-key value (UI shortcut — Claude normally derives a
509
- * semantic id from the record's name). */
510
- function generateItemId() {
511
- return randomBytes(4).toString("hex");
512
- }
513
- /** The item id a CREATE should use for `schema`, or null when the
514
- * caller should generate one. A singleton collection pins every
515
- * create to its fixed `schema.singleton` id, so the "at most one
516
- * record" contract is enforced server-side (a second create targets
517
- * the same file and hits `writeItem`'s refuseOverwrite conflict) —
518
- * not only in the UI. Otherwise the record's own primaryKey value
519
- * wins, falling back to a generated id (null = "generate"). */
520
- function resolveCreateItemId(schema, record) {
521
- if (schema.singleton) return schema.singleton;
522
- const primaryRaw = record[schema.primaryKey];
523
- return typeof primaryRaw === "string" && primaryRaw.length > 0 ? primaryRaw : null;
524
- }
525
- //#endregion
526
- //#region src/collection/server/backendAvailability.ts
527
- /** Thrown by a store when its engine or session cannot serve the request. */
528
- var BackendUnavailableError = class extends Error {
529
- constructor(message) {
530
- super(message);
531
- this.name = "BackendUnavailableError";
532
- }
533
- };
534
- function isBackendUnavailable(err) {
535
- return err instanceof BackendUnavailableError;
536
- }
537
- //#endregion
538
- //#region src/collection/core/queryZ.ts
539
- /** Result-column aliases double as SQL identifiers and JSON keys — keep
540
- * them to a conservative identifier charset so neither side needs
541
- * escaping gymnastics. */
542
- var SAFE_ALIAS_PATTERN = /^[A-Za-z_]\w{0,63}$/;
543
- /** Hard ceiling on returned rows; `limit` clamps below it. A group-by on
544
- * a near-unique column would otherwise return one row per source row —
545
- * the exact materialization the aggregate path exists to avoid. */
546
- var MAX_QUERY_ROWS = 1e4;
547
- /** Default row cap when the query declares no `limit`. */
548
- var DEFAULT_QUERY_ROWS = 1e3;
549
- /** One aggregate column: `count` (rows; `column` optional to count
550
- * non-null cells) or `sum`/`avg`/`min`/`max` over a named CSV column. */
551
- var QueryAggregateZ = z.object({
552
- op: z.enum([
553
- "count",
554
- "sum",
555
- "avg",
556
- "min",
557
- "max"
558
- ]),
559
- column: z.string().min(1).optional()
560
- }).refine((aggregate) => aggregate.op === "count" || aggregate.column !== void 0, {
561
- message: "`column` is required for every aggregate op except `count`",
562
- path: ["column"]
563
- });
564
- /** One filter condition. Same op vocabulary as the schema-level `where`
565
- * (`core/where.ts`) so authors learn one set; values may be typed
566
- * (number / boolean) since CSV columns are. `in` requires an array
567
- * value, every other op a scalar. */
568
- var QueryWhereZ = z.object({
569
- field: z.string().min(1),
570
- op: z.enum([
571
- "eq",
572
- "ne",
573
- "in",
574
- "gt",
575
- "gte",
576
- "lt",
577
- "lte",
578
- "contains"
579
- ]),
580
- value: z.union([
581
- z.string(),
582
- z.number(),
583
- z.boolean(),
584
- z.array(z.union([
585
- z.string(),
586
- z.number(),
587
- z.boolean()
588
- ])).min(1).max(100)
589
- ])
590
- }).refine((cond) => cond.op === "in" === Array.isArray(cond.value), {
591
- message: "`in` requires an array value (the allowed set); every other op requires a scalar value",
592
- path: ["value"]
593
- });
594
- var QueryOrderZ = z.object({
595
- /** A `groupBy` column or an aggregate alias — membership enforced by
596
- * the whole-query refine below. */
597
- field: z.string().min(1),
598
- dir: z.enum(["asc", "desc"]).optional()
599
- });
600
- /** The whole query. At least one of `groupBy` / `aggregates` must be
601
- * present: bare `groupBy` is a DISTINCT listing, bare `aggregates` a
602
- * whole-file scalar row, together a grouped aggregation. */
603
- var CollectionQueryZ = z.object({
604
- groupBy: z.array(z.string().min(1)).max(8).refine((columns) => new Set(columns.map((column) => column.toLowerCase())).size === columns.length, { message: "`groupBy` columns must be unique (case-insensitively — SQL identifiers ignore case)" }).optional(),
605
- aggregates: z.record(z.string().regex(SAFE_ALIAS_PATTERN, "aggregate aliases must be simple identifiers (letters/digits/underscore)"), QueryAggregateZ).optional(),
606
- where: z.array(QueryWhereZ).max(16).optional(),
607
- orderBy: z.array(QueryOrderZ).max(4).optional(),
608
- limit: z.number().int().min(1).max(MAX_QUERY_ROWS).optional()
609
- }).refine((query) => (query.groupBy?.length ?? 0) > 0 || Object.keys(query.aggregates ?? {}).length > 0, {
610
- message: "declare at least one of `groupBy` (columns to bucket by) or `aggregates` (values to compute)",
611
- path: ["groupBy"]
612
- }).refine((query) => Object.keys(query.aggregates ?? {}).length <= 32, {
613
- message: `\`aggregates\` supports at most 32 entries`,
614
- path: ["aggregates"]
615
- }).refine((query) => {
616
- const groupLower = new Set((query.groupBy ?? []).map((column) => column.toLowerCase()));
617
- const seen = /* @__PURE__ */ new Set();
618
- return Object.keys(query.aggregates ?? {}).every((alias) => {
619
- const lower = alias.toLowerCase();
620
- if (groupLower.has(lower) || seen.has(lower)) return false;
621
- seen.add(lower);
622
- return true;
623
- });
624
- }, {
625
- message: "aggregate aliases must be unique and must not collide with `groupBy` column names (case-insensitively — SQL identifiers ignore case)",
626
- path: ["aggregates"]
627
- }).refine((query) => {
628
- const sortable = /* @__PURE__ */ new Set([...query.groupBy ?? [], ...Object.keys(query.aggregates ?? {})]);
629
- return (query.orderBy ?? []).every((order) => sortable.has(order.field));
630
- }, {
631
- message: "every `orderBy.field` must be a `groupBy` column or an aggregate alias",
632
- path: ["orderBy"]
633
- });
634
- //#endregion
635
- //#region src/collection/server/csvQuery.ts
636
- /** Double-quote a SQL identifier (CSV column name / result alias). */
637
- function quoteIdent(name) {
638
- return `"${name.replaceAll("\"", "\"\"")}"`;
639
- }
640
- /** Single-quote a SQL string literal (a `types={...}` struct key). */
641
- function quoteLiteral(value) {
642
- return `'${value.replaceAll("'", "''")}'`;
643
- }
644
- /** The `read_csv` argument list shared by every CSV query: the (prepared)
645
- * path plus a `types` pin forcing the key column to VARCHAR — without it
646
- * DuckDB's sniffer turns `001` into BIGINT 1, so leading zeros vanish
647
- * and distinct keys collapse. */
648
- function readCsvArgs(primaryKey) {
649
- return `?, types={${quoteLiteral(primaryKey)}: 'VARCHAR'}`;
650
- }
651
- /** One aggregate's SQL expression. `sum`/`avg` TRY_CAST to DOUBLE so a
652
- * column the sniffer kept as VARCHAR (mixed values) aggregates over its
653
- * numeric cells instead of erroring; non-numeric cells become NULL and
654
- * are skipped — standard BI tolerance. `min`/`max` stay native (they are
655
- * meaningful on strings and dates too). */
656
- function aggregateExpr(aggregate) {
657
- const { op, column } = aggregate;
658
- if (op === "count") return column === void 0 ? "count(*)" : `count(${quoteIdent(column)})`;
659
- if (op === "sum" || op === "avg") return `${op}(TRY_CAST(${quoteIdent(column ?? "")} AS DOUBLE))`;
660
- return `${op}(${quoteIdent(column ?? "")})`;
661
- }
662
- /** One where condition → SQL fragment + its bound parameters. String
663
- * equality compares against `CAST(col AS VARCHAR)` so a sniffer-typed
664
- * column still matches its textual value; numeric/boolean values compare
665
- * natively (DuckDB coerces the column side). */
666
- function whereFragment(cond) {
667
- const column = quoteIdent(cond.field);
668
- const asText = `CAST(${column} AS VARCHAR)`;
669
- if (cond.op === "in") {
670
- const values = arrayValue(cond);
559
+ /** The parse half, exported so it can be tested without a filesystem.
560
+ *
561
+ * `aid` is validated with `isValidCollectionName` — the SAME predicate the
562
+ * `CollectionKey` constructors apply — rather than a rule of its own. An `aid`
563
+ * is re-encoded downstream as a Firestore document id, a pubsub channel
564
+ * segment and a cache key, each with a different character that would break
565
+ * it; one rule, stated once, is what keeps those layers from disagreeing.
566
+ * Rejecting here rather than at `sharedCollectionKey` only changes WHERE the
567
+ * author is told: a reason on the collection they wrote, instead of a throw
568
+ * from inside a store call. */
569
+ function parseAppManifest(raw) {
570
+ let parsed;
571
+ try {
572
+ parsed = JSON.parse(raw);
573
+ } catch (err) {
671
574
  return {
672
- sql: `${values.every((value) => typeof value === "string") ? asText : column} IN (${values.map(() => "?").join(", ")})`,
673
- params: values
575
+ ok: false,
576
+ kind: "malformed",
577
+ detail: `not valid JSON (${String(err)})`
674
578
  };
675
579
  }
676
- if (cond.op === "contains") return {
677
- sql: `contains(${asText}, ?)`,
678
- params: [String(scalarValue(cond))]
580
+ if (!isRecord(parsed)) return {
581
+ ok: false,
582
+ kind: "malformed",
583
+ detail: "is not a JSON object"
679
584
  };
680
- const operator = {
681
- eq: "=",
682
- ne: "<>",
683
- gt: ">",
684
- gte: ">=",
685
- lt: "<",
686
- lte: "<="
687
- }[cond.op];
688
- return {
689
- sql: `${typeof cond.value === "string" && (cond.op === "eq" || cond.op === "ne") ? asText : column} ${operator} ?`,
690
- params: [scalarValue(cond)]
585
+ const { aid } = parsed;
586
+ if (typeof aid !== "string" || aid.length === 0) return {
587
+ ok: false,
588
+ kind: "malformed",
589
+ detail: "declares no `aid` string"
590
+ };
591
+ if (!isValidCollectionName(aid)) return {
592
+ ok: false,
593
+ kind: "malformed",
594
+ detail: `\`aid\` '${aid}' is not a valid app id`
691
595
  };
692
- }
693
- /** Mirror of `scalarValue` for the one op that takes a set: a scalar under
694
- * `in` also means the query skipped `CollectionQueryZ`. Left unchecked it
695
- * failed as `values.every is not a function`, naming neither the field nor
696
- * the op. */
697
- function arrayValue(cond) {
698
- if (!Array.isArray(cond.value)) throw new Error(`where condition on '${cond.field}' uses op 'in', which requires an array value, not a scalar`);
699
- return cond.value;
700
- }
701
- /** `CollectionQueryZ` refines "`in` ⇔ array value", so an array reaching a
702
- * scalar op means the query was compiled without being validated first —
703
- * binding it would send an array to a single `?`. */
704
- function scalarValue(cond) {
705
- if (Array.isArray(cond.value)) throw new Error(`where condition on '${cond.field}' uses op '${cond.op}', which requires a scalar value, not an array`);
706
- return cond.value;
707
- }
708
- /** Compile a validated query against `fromSql` (a table-function call
709
- * whose FIRST placeholder is the source path — the executor binds it).
710
- * Returns the SQL and the where-value parameters that follow the path.
711
- * Callers MUST have run `CollectionQueryZ` first; this function trusts
712
- * the shape (aliases already charset-checked, orderBy membership already
713
- * enforced). */
714
- function compileQuery(query, fromSql) {
715
- const groupBy = query.groupBy ?? [];
716
- const aggregates = Object.entries(query.aggregates ?? {});
717
- const selectList = [...groupBy.map(quoteIdent), ...aggregates.map(([alias, aggregate]) => `${aggregateExpr(aggregate)} AS ${quoteIdent(alias)}`)];
718
- const where = (query.where ?? []).map(whereFragment);
719
- const clauses = [`SELECT ${selectList.join(", ")}`, `FROM ${fromSql}`];
720
- if (where.length > 0) clauses.push(`WHERE ${where.map((fragment) => fragment.sql).join(" AND ")}`);
721
- if (groupBy.length > 0) clauses.push(`GROUP BY ${groupBy.map(quoteIdent).join(", ")}`);
722
- const orderBy = (query.orderBy ?? []).map((order) => quoteIdent(order.field) + (order.dir === "desc" ? " DESC" : " ASC"));
723
- if (orderBy.length > 0) clauses.push(`ORDER BY ${orderBy.join(", ")}`);
724
- clauses.push(`LIMIT ${query.limit ?? 1e3}`);
725
596
  return {
726
- sql: clauses.join(" "),
727
- params: where.flatMap((fragment) => fragment.params)
597
+ ok: true,
598
+ manifest: { aid }
728
599
  };
729
600
  }
730
- /** Compile against a CSV file (the dataSource store's engine). */
731
- function compileCsvQuery(query, primaryKey) {
732
- return compileQuery(query, `read_csv(${readCsvArgs(primaryKey)})`);
601
+ /** The failure as the one line an author can act on. Kept next to the failure
602
+ * type so a new variant cannot be added without wording it. */
603
+ function appManifestReason(failure, root) {
604
+ const manifestPath = path.join(root, APP_MANIFEST_FILE);
605
+ if (failure.kind === "missing") return `a shared collection needs an app: create ${manifestPath} declaring an \`aid\``;
606
+ if (failure.kind === "unreadable") return `cannot read ${manifestPath}: ${failure.detail}`;
607
+ return `${manifestPath} ${failure.detail}`;
733
608
  }
734
- /** Compile against a JSONL file of ENRICHED records — the file-backed
735
- * collections' engine (see `jsonlQuery.ts`). No VARCHAR key pin needed:
736
- * enriched record ids are already strings. `sample_size=-1` makes the
737
- * schema inference scan EVERY line — with the default sample, a sparse
738
- * optional/derived field first appearing past the sample would not be
739
- * inferred as a column and the query would binder-error on it (Codex P2
740
- * on #2165). The full scan costs nothing extra here: aggregation reads
741
- * the whole file anyway. */
742
- function compileJsonlQuery(query) {
743
- return compileQuery(query, `read_json(?, format='newline_delimited', sample_size=-1)`);
609
+ /** The param name a `set` value references, or null when the value is a
610
+ * literal (non-strings can never be references). A bare/empty prefix
611
+ * (`"$params."`) returns the empty string — the schema refine rejects
612
+ * it as an undeclared param, never silently treats it as a literal. */
613
+ function paramRefName(value) {
614
+ if (typeof value !== "string" || !value.startsWith("$params.")) return null;
615
+ return value.slice(8);
744
616
  }
745
- //#endregion
746
- //#region src/collection/server/csvStore.ts
747
- /** `list()` row cap. Over-cap files are truncated with a warn — the v1
748
- * contract is "browse + per-record views", not full-table analytics. */
749
- var MAX_CSV_ROWS = 5e3;
750
- /** Record ids minted from non-safe key values: `id0x` + utf-8 hex. Raw key
751
- * values that themselves match this pattern are ALSO encoded, so the
752
- * encoded namespace never collides with a raw value (injective mapping). */
753
- var ENCODED_ID_PATTERN = /^id0x([0-9a-f]+)$/;
754
- /** A CSV key value → the record id it's addressed by. Safe values pass
755
- * through untouched; everything else (and anything shaped like an encoded
756
- * id) becomes `id0x<hex>`. Pure + exported for unit tests. */
757
- function encodeCsvRecordId(rawKey) {
758
- if (safeRecordId(rawKey) === rawKey && !ENCODED_ID_PATTERN.test(rawKey)) return rawKey;
759
- return `id0x${Buffer.from(rawKey, "utf-8").toString("hex")}`;
617
+ /** Resolve a mutate action's `set` map against the submitted params:
618
+ * literals pass through, `$params.<name>` reads the param value. An
619
+ * ABSENT referenced param omits the key entirely (merge semantics —
620
+ * the stored value survives), mirroring how the record form omits
621
+ * empty optionals rather than writing empty strings. */
622
+ function resolveMutateSet(set, params) {
623
+ const resolved = {};
624
+ for (const [key, value] of Object.entries(set)) {
625
+ const ref = paramRefName(value);
626
+ if (ref === null) {
627
+ resolved[key] = value;
628
+ continue;
629
+ }
630
+ const paramValue = params[ref];
631
+ if (paramValue !== void 0 && paramValue !== null && paramValue !== "") resolved[key] = paramValue;
632
+ }
633
+ return resolved;
760
634
  }
761
- /** A record id → the CSV key value to look up. Inverse of
762
- * `encodeCsvRecordId` for encoded ids; anything else is already the raw
763
- * value. Pure + exported for unit tests. */
764
- function decodeCsvRecordId(itemId) {
765
- const hex = ENCODED_ID_PATTERN.exec(itemId)?.[1];
766
- if (hex === void 0) return itemId;
767
- return Buffer.from(hex, "hex").toString("utf-8");
635
+ //#endregion
636
+ //#region src/collection/core/schemaRules.ts
637
+ var declaredField = (fields, name) => Object.hasOwn(fields, name) ? fields[name] : void 0;
638
+ var isDateLike = (type) => type === "date" || type === "datetime";
639
+ var isTimeStringField = (type) => type === "string" || type === "text";
640
+ var CODE_FIELD_TYPES = /* @__PURE__ */ new Set([
641
+ "string",
642
+ "text",
643
+ "enum"
644
+ ]);
645
+ var namesStoredField = (fields, name, primaryKey) => {
646
+ const target = declaredField(fields, name);
647
+ return target !== void 0 && !COMPUTED_TYPES.has(target.type) && name !== primaryKey;
648
+ };
649
+ var hasUniqueIds = (entries) => entries === void 0 || new Set(entries.map((entry) => entry.id)).size === entries.length;
650
+ /** Exactly one storage declaration: native records need `dataPath`, an external
651
+ * data file needs `dataSource`, an alternative backend needs `storage`. Zero
652
+ * (nowhere to read) and several (ambiguous which wins) are equally
653
+ * meaningless — fail loudly at load instead of picking silently. */
654
+ function declaresExactlyOneStore(schema) {
655
+ return [
656
+ schema.dataPath,
657
+ schema.dataSource,
658
+ schema.storage
659
+ ].filter((declared) => declared !== void 0).length === 1;
768
660
  }
769
- /** Normalize one DuckDB JS value into a JSON-safe record value: BigInt →
770
- * number (string beyond the safe range), DATE/TIMESTAMP → ISO string
771
- * (date-only when the clock is exactly UTC midnight, matching the `date`
772
- * field contract), exotic DuckDB values → their string form. Pure +
773
- * exported for unit tests. */
774
- /** `JSON.stringify` restricted to what a CSV cell can survive. Returns the
775
- * serialised value, or `String(value)` when serialisation is impossible —
776
- * losing the content of one cell is bad, failing the entire query is worse. */
777
- function safeJsonCell(value) {
778
- try {
779
- return JSON.stringify(value, (_key, entry) => typeof entry === "bigint" ? entry.toString() : entry) ?? String(value);
780
- } catch {
781
- return String(value);
782
- }
661
+ /** A `dataSource` collection is read-only by definition, so schema-level write
662
+ * machinery can never fire: `singleton` pins CREATES, `ingest` REFILLS
663
+ * records, `spawn` WRITES successor records. Rejecting them at validation
664
+ * kills whole classes of writes before any runtime guard. */
665
+ function dataSourceDeclaresNoWriteMachinery(schema) {
666
+ if (schema.dataSource === void 0) return true;
667
+ return schema.singleton === void 0 && schema.ingest === void 0 && schema.spawn === void 0 && schema.googleCalendar === void 0;
783
668
  }
784
- function normalizeCsvValue(value) {
785
- if (typeof value === "bigint") return value <= BigInt(Number.MAX_SAFE_INTEGER) && value >= BigInt(-Number.MAX_SAFE_INTEGER) ? Number(value) : value.toString();
786
- if (value instanceof Date) {
787
- const iso = value.toISOString();
788
- return iso.endsWith("T00:00:00.000Z") ? iso.slice(0, 10) : iso;
789
- }
790
- if (value !== null && typeof value === "object") return safeJsonCell(value);
791
- return value;
669
+ /** Same rule for declarative host writes: a mutate action writes the record
670
+ * it's invoked on, which a read-only collection has no business doing. */
671
+ function dataSourceDeclaresNoMutateAction(schema) {
672
+ if (schema.dataSource !== void 0) return [...schema.actions ?? [], ...schema.collectionActions ?? []].every((action) => action.kind !== "mutate");
673
+ return true;
792
674
  }
793
- /** One raw DuckDB row → a CollectionItem, or null when the key cell is
794
- * missing/empty (the row can't be addressed). The primaryKey field is
795
- * OVERWRITTEN with the (possibly encoded) record id so `item[primaryKey]`
796
- * and the record's address never drift — same invariant the file store's
797
- * write path enforces. Pure + exported for unit tests. */
798
- function csvRowToItem(row, primaryKey) {
799
- const normalized = Object.fromEntries(Object.entries(row).map(([key, value]) => [key, normalizeCsvValue(value)]));
800
- const rawKey = normalized[primaryKey];
801
- const keyText = fieldTextOrNull(rawKey);
802
- if (keyText === null || keyText === "") return null;
803
- return {
804
- ...normalized,
805
- [primaryKey]: encodeCsvRecordId(keyText)
806
- };
675
+ /** Action ids must be unique so the dispatch route resolves unambiguously. */
676
+ function actionIdsAreUnique(schema) {
677
+ return hasUniqueIds(schema.actions);
807
678
  }
808
- /** Dedupe by record id, LAST row wins (matches `csvRead`'s last-match
809
- * pick). Returns the surviving items in first-seen order. Pure +
810
- * exported for unit tests. */
811
- function dedupeByRecordId(items, primaryKey) {
812
- const byId = /* @__PURE__ */ new Map();
813
- for (const item of items) byId.set(String(item[primaryKey]), item);
814
- return {
815
- items: [...byId.values()],
816
- duplicates: items.length - byId.size
817
- };
679
+ /** Collection-level action ids must likewise be unique. */
680
+ function collectionActionIdsAreUnique(schema) {
681
+ return hasUniqueIds(schema.collectionActions);
818
682
  }
819
- /** True when a thrown DuckDB error is the `types` pin naming a column the
820
- * CSV doesn't have — the schema/file-mismatch case the caller downgrades
821
- * to "empty collection + warn" instead of a 500. */
822
- function isMissingKeyColumnError(err) {
823
- return String(err).includes("do not exist in the CSV");
683
+ /** A mutate action's `set` writes real STORED fields: a typo'd key would write
684
+ * a stray value forever, a computed/projected field is never persisted, and
685
+ * the primaryKey is the filename (renaming is not a mutation). */
686
+ function mutateSetKeysNameStoredFields(schema) {
687
+ return (schema.actions ?? []).every((action) => action.kind !== "mutate" || Object.keys(action.set).every((key) => namesStoredField(schema.fields, key, schema.primaryKey)));
824
688
  }
825
- /** Bytes sniffed for UTF-8 validity. The trailing 3 bytes of the sample
826
- * are dropped so a multibyte char split at the boundary can't produce a
827
- * false negative on a valid file. */
828
- var SNIFF_BYTES = 1048576;
829
- function isValidUtf8(buf) {
830
- try {
831
- new TextDecoder("utf-8", { fatal: true }).decode(buf);
832
- return true;
833
- } catch {
834
- return false;
835
- }
689
+ /** Every `$params.<name>` reference in `set` must name a declared param — an
690
+ * undeclared one would silently no-op the assignment. */
691
+ function mutateParamRefsAreDeclared(schema) {
692
+ return (schema.actions ?? []).every((action) => action.kind !== "mutate" || Object.values(action.set).every((value) => {
693
+ const ref = paramRefName(value);
694
+ return ref === null || (action.params ?? {})[ref] !== void 0;
695
+ }));
836
696
  }
837
- /** Detect the (best-effort) encoding of a non-UTF-8 buffer. BOMs decide
838
- * UTF-16; otherwise cp932 (the Shift_JIS superset — Excel-exported
839
- * Japanese CSVs are the primary non-UTF-8 case this feature serves). */
840
- function fallbackEncoding(buf) {
841
- if (buf.length >= 2 && buf[0] === 255 && buf[1] === 254) return "utf-16le";
842
- if (buf.length >= 2 && buf[0] === 254 && buf[1] === 255) return "utf-16be";
843
- return "cp932";
697
+ /** A collection-level action has no record to write. */
698
+ function collectionActionsAreNotMutate(schema) {
699
+ return (schema.collectionActions ?? []).every((action) => action.kind !== "mutate");
844
700
  }
845
- function cacheDir() {
846
- return path.join(tmpdir(), "mulmoclaude-csv-utf8");
701
+ /** The singleton value becomes a record id (and thus a `<id>.json` filename),
702
+ * so it must satisfy the SAME record-id rule the write path enforces —
703
+ * otherwise the create form would lock the primary key to a value the POST
704
+ * route then rejects, making the collection impossible to initialize. */
705
+ function singletonIsAValidRecordId(schema) {
706
+ return schema.singleton === void 0 || isSafeRecordId(schema.singleton);
847
707
  }
848
- /** Read only the first `bytes` of a file — the encoding sniff must not
849
- * pull a multi-hundred-MB CSV into memory on the (common) UTF-8 path. */
850
- async function readHead(absPath, bytes) {
851
- const handle = await open(absPath, "r");
852
- try {
853
- const { size } = await handle.stat();
854
- const buf = Buffer.alloc(Math.min(bytes, size));
855
- await handle.read(buf, 0, buf.length, 0);
856
- return buf;
857
- } finally {
858
- await handle.close();
708
+ function collectCurrencyFieldRefs(fields) {
709
+ const refs = [];
710
+ for (const field of Object.values(fields)) {
711
+ if (typeof field.currencyField === "string" && field.currencyField.length > 0) refs.push(field.currencyField);
712
+ for (const sub of Object.values(field.of ?? {})) if (typeof sub.currencyField === "string" && sub.currencyField.length > 0) refs.push(sub.currencyField);
859
713
  }
714
+ return refs;
860
715
  }
861
- /** Decode the whole file into a UTF-8 cache copy and return its path.
862
- * Cache key = (path, mtime, size), so a replaced CSV re-decodes and an
863
- * unchanged one never does. */
864
- async function pathExists(target) {
865
- try {
866
- await stat(target);
867
- return true;
868
- } catch {
869
- return false;
870
- }
716
+ /** A `currencyField` pointer must name a real top-level field that holds a code
717
+ * string — a typo (`curreny`) would otherwise pass the per-field check, then
718
+ * silently fall back to the literal / USD at render and mislabel amounts. */
719
+ function currencyFieldRefsNameCodeFields(schema) {
720
+ return collectCurrencyFieldRefs(schema.fields).every((name) => CODE_FIELD_TYPES.has(declaredField(schema.fields, name)?.type ?? ""));
871
721
  }
872
- /** Best-effort removal of older decode-cache entries for the same source
873
- * path — a frequently-replaced large CSV would otherwise accumulate one
874
- * full copy per (mtime, size) forever. Runs AFTER the current copy is
875
- * published; a concurrent reader holding an old fd is unaffected
876
- * (unlink-while-open is safe on POSIX). */
877
- async function evictSupersededCache(key, keepBasename) {
878
- try {
879
- const entries = await readdir(cacheDir());
880
- await Promise.all(entries.filter((name) => name.startsWith(`${key}-`) && name !== keepBasename).map((name) => unlink(path.join(cacheDir(), name)).catch(() => void 0)));
881
- } catch {}
722
+ /** The pair must be declared together — one without the other is meaningless:
723
+ * the host would either never fire (no done values to compare against) or
724
+ * never clear (no field to read).
725
+ *
726
+ * EXCEPTION: when `completionField` names a `flag` field, done ⇔ the flag's
727
+ * `where` matches, so `completionDoneValues` carries no information and MUST
728
+ * be omitted (declaring it would invite a contradictory second source of
729
+ * truth). */
730
+ function completionPairIsCoherent(schema) {
731
+ if (schema.completionField !== void 0 && declaredField(schema.fields, schema.completionField)?.type === "flag") return schema.completionDoneValues === void 0;
732
+ return schema.completionField === void 0 === (schema.completionDoneValues === void 0);
882
733
  }
883
- /** Decode the whole file into a UTF-8 cache copy and return its path.
884
- * Cache key = (path, mtime, size), so a replaced CSV re-decodes and an
885
- * unchanged one never does; superseded copies are evicted. The cache
886
- * lives in the SHARED OS tmpdir, so the dir is 0700 and files 0600 —
887
- * decoded rows must not be readable by other local users. */
888
- async function decodeToCache(absPath, info) {
889
- const key = createHash("sha256").update(absPath).digest("hex").slice(0, 16);
890
- const cached = path.join(cacheDir(), `${key}-${Math.trunc(info.mtimeMs)}-${info.size}.csv`);
891
- if (!await pathExists(cached)) {
892
- const whole = await readFile(absPath);
893
- const encoding = fallbackEncoding(whole);
894
- const text = iconv.decode(whole, encoding);
895
- await mkdir(cacheDir(), {
896
- recursive: true,
897
- mode: 448
898
- });
899
- const tmp = `${cached}.${randomBytes(4).toString("hex")}.tmp`;
900
- await writeFile(tmp, text, {
901
- encoding: "utf-8",
902
- mode: 384
903
- });
904
- await rename(tmp, cached);
905
- log.info("collections", "decoded non-UTF-8 dataSource file to cache", {
906
- path: absPath,
907
- encoding
908
- });
909
- await evictSupersededCache(key, path.basename(cached));
910
- }
911
- return cached;
734
+ /** `completionField` must name a real top-level field — a typo would silently
735
+ * disable the notification mechanism otherwise. */
736
+ function completionFieldIsDeclared(schema) {
737
+ return schema.completionField === void 0 || declaredField(schema.fields, schema.completionField) !== void 0;
912
738
  }
913
- /** Re-validate the dataSource file at READ time, mirroring the JSON
914
- * store's per-read defenses: realpath containment (a symlink swapped in
915
- * after discovery must not walk out of the workspace) and an lstat
916
- * regular-file check (a symlink leaf is refused outright, even one
917
- * pointing inside the workspace — same rule as `isRegularFile` on
918
- * record files). Returns the stat info, or null for "no readable file"
919
- * (ENOENT / refused), which callers render as an empty collection. */
920
- async function safeCsvStat(absPath, workspaceRoot) {
921
- if (!isContainedInRoot(absPath, workspaceRoot)) {
922
- log.warn("collections", "dataSource read refused: path escapes workspace", { path: absPath });
923
- return null;
924
- }
925
- let info;
926
- try {
927
- info = await lstat(absPath);
928
- } catch (err) {
929
- if (isErrorWithCode(err) && err.code === "ENOENT") return null;
930
- throw err;
931
- }
932
- if (!info.isFile()) {
933
- log.warn("collections", "dataSource read refused: not a regular file (symlink?)", { path: absPath });
934
- return null;
935
- }
936
- return info;
739
+ /** A flag named by `completionField` is evaluated against the RAW record — the
740
+ * reconciler (and spawn's fallback) read items straight off disk, BEFORE any
741
+ * `deriveAll` enrichment — so its `where` may only reference STORED fields. A
742
+ * condition over a computed sibling would see an absent key: `ne` matches
743
+ * vacuously, every other op reads false, and the bell would clear wrongly /
744
+ * never. General (non-completion) flags keep the full vocabulary — the UI
745
+ * evaluates them post-enrichment. */
746
+ function completionFlagReadsOnlyStoredFields(schema) {
747
+ const spec = schema.completionField === void 0 ? void 0 : declaredField(schema.fields, schema.completionField);
748
+ if (spec?.type !== "flag") return true;
749
+ return spec.where.every((cond) => [cond.field, ...cond.valueFrom ? [cond.valueFrom.field] : []].every((name) => {
750
+ const target = declaredField(schema.fields, name);
751
+ return target !== void 0 && !COMPUTED_TYPES.has(target.type);
752
+ }));
937
753
  }
938
- /** Return a path DuckDB can read as UTF-8: the original file when it
939
- * already is UTF-8 (the cheap, common case — only the head is sniffed),
940
- * else a decoded cache copy (see `decodeToCache`). Returns null when
941
- * there is no readable file (missing, symlink, or containment-refused —
942
- * see `safeCsvStat`), which callers render as an empty collection. */
943
- async function ensureUtf8CsvPath(absPath, workspaceRoot) {
944
- const info = await safeCsvStat(absPath, workspaceRoot);
945
- if (info === null) return null;
946
- const head = await readHead(absPath, SNIFF_BYTES);
947
- const sample = head.length === SNIFF_BYTES ? head.subarray(0, 1048573) : head;
948
- if (!(head.length >= 2 && (head[0] === 255 && head[1] === 254 || head[0] === 254 && head[1] === 255)) && isValidUtf8(sample)) return absPath;
949
- return decodeToCache(absPath, info);
754
+ /** `displayField`, like `completionField`, must name a real top-level field —
755
+ * a typo would silently fall back to the primaryKey forever. */
756
+ function displayFieldIsDeclared(schema) {
757
+ return schema.displayField === void 0 || declaredField(schema.fields, schema.displayField) !== void 0;
950
758
  }
951
- var instancePromise = null;
952
- /** Lazily create one shared in-memory DuckDB instance. The dynamic import
953
- * keeps the native module OUT of core's load path — a platform where the
954
- * prebuilt binding is missing degrades to a per-query error on dataSource
955
- * collections only, never a broken core. A failed init is retried on the
956
- * next call (the promise is reset). */
957
- async function duckDbInstance() {
958
- if (instancePromise === null) instancePromise = import("@duckdb/node-api").then((mod) => mod.DuckDBInstance.create(":memory:"));
959
- try {
960
- return await instancePromise;
961
- } catch (err) {
962
- instancePromise = null;
963
- throw new BackendUnavailableError(`DuckDB is unavailable on this host (@duckdb/node-api failed to load: ${String(err)}) — dataSource collections cannot be read`);
964
- }
759
+ /** A field's `when.field` gates its visibility against a sibling's value, so it
760
+ * must name a real top-level field — a typo would silently keep the field
761
+ * hidden forever (the gate never matches). */
762
+ function fieldVisibilityGatesNameDeclaredFields(schema) {
763
+ return Object.values(schema.fields).every((field) => field.when === void 0 || declaredField(schema.fields, field.when.field) !== void 0);
965
764
  }
966
- async function queryCsv(sql, params) {
967
- const connection = await (await duckDbInstance()).connect();
968
- try {
969
- return (await connection.runAndReadAll(sql, params)).getRowObjectsJS();
970
- } finally {
971
- connection.disconnectSync();
972
- }
765
+ /** A flag's `where` reads sibling fields (both `cond.field` and a same-record
766
+ * `valueFrom.field`), so each must name a real top-level field — a typo would
767
+ * silently pin the flag false forever (`ne`: true forever). */
768
+ function flagConditionsNameDeclaredFields(schema) {
769
+ return Object.values(schema.fields).every((field) => field.type !== "flag" || field.where.every((cond) => declaredField(schema.fields, cond.field) !== void 0 && (cond.valueFrom === void 0 || declaredField(schema.fields, cond.valueFrom.field) !== void 0)));
973
770
  }
974
- async function csvList(absPath, primaryKey, workspaceRoot) {
975
- const utf8Path = await ensureUtf8CsvPath(absPath, workspaceRoot ?? getWorkspaceRoot());
976
- if (utf8Path === null) return {
977
- items: [],
978
- truncated: false
979
- };
980
- let rows;
981
- try {
982
- rows = await queryCsv(`SELECT * FROM read_csv(${readCsvArgs(primaryKey)}) LIMIT 5001`, [utf8Path]);
983
- } catch (err) {
984
- if (!isMissingKeyColumnError(err)) throw err;
985
- log.warn("collections", "dataSource CSV has no primaryKey column — every row is skipped", {
986
- path: absPath,
987
- primaryKey
988
- });
989
- return {
990
- items: [],
991
- truncated: false
992
- };
993
- }
994
- const truncated = rows.length > MAX_CSV_ROWS;
995
- if (truncated) {
996
- log.warn("collections", "dataSource CSV truncated to row cap", {
997
- path: absPath,
998
- cap: MAX_CSV_ROWS
999
- });
1000
- rows.length = MAX_CSV_ROWS;
1001
- }
1002
- const items = rows.map((row) => csvRowToItem(row, primaryKey)).filter((item) => item !== null);
1003
- const skipped = rows.length - items.length;
1004
- if (skipped > 0) log.warn("collections", "dataSource CSV rows skipped (empty key cell)", {
1005
- path: absPath,
1006
- skipped
1007
- });
1008
- const deduped = dedupeByRecordId(items, primaryKey);
1009
- if (deduped.duplicates > 0) log.warn("collections", "dataSource CSV has duplicate key values (last row wins)", {
1010
- path: absPath,
1011
- duplicates: deduped.duplicates
771
+ /** An `embed`'s `idField` resolves the target record id from a sibling's value,
772
+ * so it must name a real top-level field — and one whose stored value is a
773
+ * plain id string. Only `ref` / `string` qualify: the editor writes the picked
774
+ * id into that field, so a non-persisted or composite type would either not
775
+ * round-trip on save or hold no usable id. */
776
+ function embedIdFieldsNameIdBearingFields(schema) {
777
+ return Object.values(schema.fields).every((field) => {
778
+ if (field.type !== "embed" || field.idField === void 0) return true;
779
+ const target = declaredField(schema.fields, field.idField);
780
+ return target !== void 0 && (target.type === "ref" || target.type === "string");
1012
781
  });
1013
- return {
1014
- items: deduped.items,
1015
- truncated
1016
- };
1017
- }
1018
- /** The scan-order ordinal column the last-match read adds. Underscore
1019
- * prefix keeps it out of any plausible CSV header namespace; it is
1020
- * stripped from the returned record either way. */
1021
- var ROW_ORDINAL = "__mc_row";
1022
- /** One record by id. The comparison value rides as a prepared-statement
1023
- * parameter, and the LAST matching row is selected IN DuckDB (scan-order
1024
- * ordinal + LIMIT 1) — a CSV with thousands of duplicate keys must not
1025
- * materialize them all for one detail read. Consistent with csvList's
1026
- * last-wins dedupe. */
1027
- async function csvRead(absPath, primaryKey, itemId, workspaceRoot) {
1028
- const utf8Path = await ensureUtf8CsvPath(absPath, workspaceRoot ?? getWorkspaceRoot());
1029
- if (utf8Path === null) return null;
1030
- const rawKey = decodeCsvRecordId(itemId);
1031
- const last = (await queryCsv(`SELECT * FROM (SELECT *, row_number() OVER () AS ${quoteIdent(ROW_ORDINAL)} FROM read_csv(${readCsvArgs(primaryKey)})) WHERE CAST(${quoteIdent(primaryKey)} AS VARCHAR) = ? ORDER BY ${quoteIdent(ROW_ORDINAL)} DESC LIMIT 1`, [utf8Path, rawKey])).at(0);
1032
- if (last === void 0) return null;
1033
- const { [ROW_ORDINAL]: __ordinal, ...record } = last;
1034
- return csvRowToItem(record, primaryKey);
1035
782
  }
1036
- /** Run a validated aggregation query (the structured DSL — see
1037
- * `core/queryZ.ts`) over the WHOLE file: no row cap on the scan (a
1038
- * capped aggregate would be a wrong number), only the result-row LIMIT
1039
- * the compiler emits. Values are normalized like list/read rows so a
1040
- * chart consumer gets plain JSON scalars. */
1041
- async function csvRunQuery(absPath, primaryKey, query, workspaceRoot) {
1042
- const utf8Path = await ensureUtf8CsvPath(absPath, workspaceRoot ?? getWorkspaceRoot());
1043
- if (utf8Path === null) return [];
1044
- const { sql, params } = compileCsvQuery(query, primaryKey);
1045
- return (await queryCsv(sql, [utf8Path, ...params])).map((row) => Object.fromEntries(Object.entries(row).map(([key, value]) => [key, normalizeCsvValue(value)])));
783
+ /** The sync writes each mapped value into a declared field, and puts the Google
784
+ * event id in the primary field — so a map key that names no field (or names
785
+ * the primary) would silently drop data or fight the id. */
786
+ function googleCalendarMapNamesStoredFields(schema) {
787
+ if (schema.googleCalendar === void 0) return true;
788
+ return Object.keys(schema.googleCalendar.map).every((key) => namesStoredField(schema.fields, key, schema.primaryKey));
1046
789
  }
1047
- //#endregion
1048
- //#region src/collection/server/watchFs.ts
1049
- /** An atomic file replace (editor save, `mv` over the target) surfaces as
1050
- * 2-3 events. Collapse them so one user action reports one change. */
1051
- var REPLACE_DEBOUNCE_MS = 300;
1052
- /** The path to hand `watch()`, with Windows 8.3 short names resolved away.
1053
- *
1054
- * ReadDirectoryChangesW reports filenames against the LONG path, but a watch
1055
- * opened on a short path (`C:\Users\RUNNER~1\…` — what `os.tmpdir()` returns
1056
- * on GitHub's Windows runners) keeps the short form. libuv's
1057
- * `assert(!_wcsnicmp(filename, dir, dirlen))` in `src/win/fs-event.c` then
1058
- * aborts the PROCESS on the first event — a native assert, so neither
1059
- * `watcher.on("error")` nor a try/catch can contain it.
1060
- *
1061
- * POSIX is deliberately left alone: `realpath` there also collapses symlinks
1062
- * (`/var` → `/private/var` on macOS), which we neither need nor want to
1063
- * change. A failure falls back to the original path — worst case we are no
1064
- * worse off than before. */
1065
- function watchablePath(dir) {
1066
- if (process.platform !== "win32") return dir;
1067
- try {
1068
- return realpathSync.native(dir);
1069
- } catch {
1070
- return dir;
790
+ /** A `toggle` field projects an `enum` field: its `field` must name a real
791
+ * top-level enum, and `onValue` / `offValue` must be members of that enum's
792
+ * `values` — otherwise toggling would write a value outside the closed set
793
+ * (and never appear "checked"). */
794
+ function togglesProjectValidEnums(schema) {
795
+ const { fields } = schema;
796
+ for (const spec of Object.values(fields)) {
797
+ if (spec.type !== "toggle") continue;
798
+ const target = declaredField(fields, spec.field);
799
+ if (!target || target.type !== "enum") return false;
800
+ const allowed = new Set(target.values);
801
+ if (!allowed.has(spec.onValue) || !allowed.has(spec.offValue)) return false;
1071
802
  }
803
+ return true;
1072
804
  }
1073
- /** Watch `dir`, reporting each accepted filename. `accept` decides what is
1074
- * noise; a null filename always passes (the platform didn't tell us which
1075
- * file, so the caller must assume the worst). */
1076
- async function watchDirectory(dir, accept, onHit) {
1077
- try {
1078
- await mkdir(dir, { recursive: true });
1079
- const watcher = watch(watchablePath(dir), { persistent: false }, (_eventType, rawFilename) => {
1080
- const filename = rawFilename === null ? null : String(rawFilename);
1081
- if (filename !== null && !accept(filename)) return;
1082
- onHit(filename);
1083
- });
1084
- watcher.on("error", (err) => {
1085
- log.warn("collections", "fs watch error", {
1086
- dir,
1087
- error: String(err)
1088
- });
1089
- });
1090
- return { close: () => watcher.close() };
1091
- } catch (err) {
1092
- log.warn("collections", "fs watch start failed", {
1093
- dir,
1094
- error: String(err)
1095
- });
1096
- return null;
1097
- }
805
+ /** `triggerField` requires the completion pair: the time gate only suppresses
806
+ * the *completion* bell until the date, and the bell still clears via
807
+ * `completionDoneValues`. Without completion there is no bell to gate. */
808
+ function triggerFieldRequiresCompletion(schema) {
809
+ return schema.triggerField === void 0 || schema.completionField !== void 0;
1098
810
  }
1099
- /** Watch the single file `absPath` by watching its PARENT directory, so an
1100
- * atomic replace can't strand the watch on a dead inode. `alsoAccept`
1101
- * widens the filter beyond the exact basename (sqlite's `-wal`/`-journal`
1102
- * sidecars). Reports are debounced: one replace, one call. */
1103
- async function watchSingleFile(absPath, alsoAccept, onChange) {
1104
- const dir = path.dirname(absPath);
1105
- const base = path.basename(absPath);
1106
- let timer = null;
1107
- const fire = () => {
1108
- if (timer) clearTimeout(timer);
1109
- timer = setTimeout(() => {
1110
- timer = null;
1111
- onChange();
1112
- }, REPLACE_DEBOUNCE_MS);
1113
- timer.unref?.();
1114
- };
1115
- const handle = await watchDirectory(dir, (filename) => filename === base || alsoAccept(base, filename), fire);
1116
- if (!handle) return null;
1117
- return { close: () => {
1118
- if (timer) clearTimeout(timer);
1119
- timer = null;
1120
- handle.close();
1121
- } };
811
+ /** `triggerField` must name a real `date` field — the gate parses its value as
812
+ * `YYYY-MM-DD`; any other type can't be compared to the clock. */
813
+ function triggerFieldIsADateField(schema) {
814
+ return schema.triggerField === void 0 || declaredField(schema.fields, schema.triggerField)?.type === "date";
1122
815
  }
1123
- /** An `FsWatchHandle` as a bare unsubscribe — `null` straight through, so an
1124
- * unarmed watch stays distinguishable from an armed one. Lives here rather
1125
- * than beside the store contract so both `store.ts` and the backends it
1126
- * registers can reach it without importing each other. */
1127
- function closerFor(handle) {
1128
- return handle === null ? null : () => handle.close();
1129
- }
1130
- //#endregion
1131
- //#region src/collection/server/storePage.ts
1132
- /** Project `fields` (+ the primary key, always) out of each record. Thin
1133
- * server-typed alias over the shared isomorphic `projectRecordFields`
1134
- * (../core/project.ts) — kept as the store layer's exported name. */
1135
- function projectItemFields(items, fields, primaryKey) {
1136
- return projectRecordFields(items, fields, primaryKey);
816
+ /** `triggerLeadDays` only means something relative to a trigger date. */
817
+ function triggerLeadDaysRequiresTriggerField(schema) {
818
+ return schema.triggerLeadDays === void 0 || schema.triggerField !== void 0;
1137
819
  }
1138
- /** Slice + project an already-ordered full read into a `ListPage` — the
1139
- * shared emulation for stores without native paging. Pure, exported for
1140
- * tests. `limit: 0` is a valid "count only" page. */
1141
- function pageFromFullRead(items, opts, primaryKey, truncated) {
1142
- const offset = Math.max(0, opts.offset ?? 0);
1143
- const end = opts.limit === void 0 ? items.length : offset + Math.max(0, opts.limit);
1144
- return {
1145
- items: projectItemFields(items.slice(offset, end), opts.fields, primaryKey),
1146
- total: items.length,
1147
- truncated
1148
- };
820
+ /** `spawn` advances `triggerField` to compute the successor's trigger date, so
821
+ * the schema must declare one. */
822
+ function spawnRequiresTriggerField(schema) {
823
+ return schema.spawn === void 0 || schema.triggerField !== void 0;
1149
824
  }
1150
- //#endregion
1151
- //#region src/collection/server/sqliteStore.ts
1152
- /** A constructor's parameter and return types are not observable at runtime,
1153
- * so the check stops at "DatabaseSync is constructible" — the only member of
1154
- * the module this store ever touches. */
1155
- function isSqliteModule(mod) {
1156
- return isRecord(mod) && typeof mod.DatabaseSync === "function";
825
+ /** `spawn.when.field` must name a real top-level field — a typo would silently
826
+ * never match. */
827
+ function spawnWhenFieldIsDeclared(schema) {
828
+ return schema.spawn?.when === void 0 || declaredField(schema.fields, schema.spawn.when.field) !== void 0;
1157
829
  }
1158
- var sqliteModule = null;
1159
- /** Drops the memo first so a later call can retry (e.g. tests stubbing the
1160
- * runtime), then reports why the backend is unusable. */
1161
- function sqliteUnavailable(reason) {
1162
- sqliteModule = null;
1163
- throw new BackendUnavailableError(`sqlite storage needs the node:sqlite module (Node.js >= 22.5) — this runtime cannot load it: ${reason}`);
830
+ /** Every `spawn.carry` entry must name a real top-level field — a typo would
831
+ * silently never copy. */
832
+ function spawnCarryEntriesAreDeclared(schema) {
833
+ return (schema.spawn?.carry ?? []).every((name) => declaredField(schema.fields, name) !== void 0);
1164
834
  }
1165
- /** Lazy-load node:sqlite once. A runtime without it (Node < 22.5) throws a
1166
- * clearly-worded error the caller surfaces — never a bare MODULE_NOT_FOUND. */
1167
- function loadSqlite() {
1168
- sqliteModule ??= import("node:sqlite").then((mod) => isSqliteModule(mod) ? mod : sqliteUnavailable("the module exposes no DatabaseSync constructor"), (err) => sqliteUnavailable(String(err)));
1169
- return sqliteModule;
835
+ /** A successor must NOT be born already matching its own spawn predicate — it
836
+ * would re-spawn on its first reconcile, fanning out into an unbounded chain
837
+ * of records. The predicate field/values are `spawn.when` when given, else the
838
+ * completion-done pair. The successor's value for that field is `set[field]`
839
+ * if set, else the carried source value (which matched, by definition, when
840
+ * the spawn fired) if carried, else absent (safe). */
841
+ function spawnSuccessorStartsInert(schema) {
842
+ const { spawn } = schema;
843
+ if (!spawn) return true;
844
+ const field = spawn.when?.field ?? schema.completionField;
845
+ const values = spawn.when?.in ?? schema.completionDoneValues;
846
+ if (!field || !values) return true;
847
+ if (spawn.set && Object.prototype.hasOwnProperty.call(spawn.set, field)) return !values.includes(String(spawn.set[field]));
848
+ return !(spawn.carry ?? []).includes(field);
1170
849
  }
1171
- /** The db file's on-disk state. A symlink or non-regular file is refused
1172
- * (file-disclosure defense, same rule as io.ts record files); ENOENT is
1173
- * just "no records yet". Any OTHER lstat failure (EACCES, EIO, …) is
1174
- * rethrown so reads surface a real filesystem problem instead of
1175
- * silently reporting an empty collection. */
1176
- async function dbFileState(absPath) {
1177
- try {
1178
- return (await lstat(absPath)).isFile() ? "file" : "refused";
1179
- } catch (err) {
1180
- if (isErrorWithCode(err) && err.code === "ENOENT") return "missing";
1181
- throw err;
1182
- }
850
+ /** `spawnSuccessorStartsInert` cannot see through a flag's `where` (the
851
+ * predicate would need full record evaluation against `set`/`carry`). So a
852
+ * schema whose completion is flag-form may only spawn with an explicit
853
+ * `spawn.when` — which that check CAN evaluate. */
854
+ function flagCompletionSpawnDeclaresWhen(schema) {
855
+ return schema.spawn === void 0 || schema.spawn.when !== void 0 || declaredField(schema.fields, schema.completionField ?? "")?.type !== "flag";
1183
856
  }
1184
- var CREATE_TABLE = "CREATE TABLE IF NOT EXISTS records (id TEXT PRIMARY KEY, record TEXT NOT NULL)";
1185
- /** Open the database for one operation, classifying the two unavailable
1186
- * states so callers can map them honestly (`refused` ⇒ path-escape,
1187
- * `missing` ⇒ empty / not-found — conflating them would misreport a
1188
- * containment escape as "item not found"). The containment pre-check runs
1189
- * BEFORE mkdir even when the file is missing — `isContainedInRoot`
1190
- * resolves through the closest existing ancestor, so a symlinked-away
1191
- * parent can never make the recursive mkdir create directories outside
1192
- * the workspace (same pre/post belt-and-suspenders as io.ts writes). */
1193
- async function openDb(absPath, workspaceRoot, mode) {
1194
- const state = await dbFileState(absPath);
1195
- if (state === "refused") {
1196
- log.warn("collections", "sqlite database refused: not a regular file", { path: absPath });
1197
- return { kind: "refused" };
1198
- }
1199
- if (!isContainedInRoot(path.dirname(absPath), workspaceRoot)) {
1200
- log.warn("collections", "sqlite refused: database dir escapes workspace via symlink", { path: absPath });
1201
- return { kind: "refused" };
1202
- }
1203
- if (mode === "read" && state === "missing") return { kind: "missing" };
1204
- if (mode === "write") {
1205
- await mkdir(path.dirname(absPath), { recursive: true });
1206
- if (!isContainedInRoot(path.dirname(absPath), workspaceRoot)) {
1207
- log.warn("collections", "sqlite write refused: database dir escapes workspace via symlink (post-mkdir)", { path: absPath });
1208
- return { kind: "refused" };
1209
- }
1210
- }
1211
- const { DatabaseSync } = await loadSqlite();
1212
- const database = new DatabaseSync(absPath);
1213
- database.exec("PRAGMA busy_timeout = 5000");
1214
- database.exec(CREATE_TABLE);
1215
- return {
1216
- kind: "ok",
1217
- database
1218
- };
857
+ function fieldDrivenSpawnEvery(schema) {
858
+ const every = schema.spawn?.every;
859
+ if (!every || !("fromField" in every)) return null;
860
+ return every;
1219
861
  }
1220
- /** Run `operation` against the database and always close it; unavailable
1221
- * states resolve through `onUnavailable` so each caller maps `missing`
1222
- * vs `refused` to its own result kind. */
1223
- async function withDb(absPath, workspaceRoot, mode, onUnavailable, operation) {
1224
- const handle = await openDb(absPath, workspaceRoot, mode);
1225
- if (handle.kind !== "ok") return onUnavailable(handle.kind);
1226
- try {
1227
- return await operation(handle.database);
1228
- } finally {
1229
- handle.database.close();
1230
- }
862
+ /** §4.1 — `fromField` must name a real top-level `enum` field. The `map` keys
863
+ * are only meaningful against a closed value set, and the field renders as a
864
+ * form `<select>`; a non-enum target has no finite values to validate. */
865
+ function fieldDrivenFromFieldIsEnum(schema) {
866
+ const driven = fieldDrivenSpawnEvery(schema);
867
+ if (!driven) return true;
868
+ return declaredField(schema.fields, driven.fromField)?.type === "enum";
1231
869
  }
1232
- var SQLITE_CONSTRAINT_PRIMARYKEY = 1555;
1233
- var SQLITE_CONSTRAINT_UNIQUE = 2067;
1234
- /** node:sqlite throws ERR_SQLITE_ERROR with the SQLite extended result
1235
- * code on `errcode`. Checked structurally (message text kept only as a
1236
- * fallback for runtimes that don't expose `errcode`). */
1237
- function isUniqueConstraintError(err) {
1238
- if (hasNumberProp(err, "errcode")) return err.errcode === SQLITE_CONSTRAINT_PRIMARYKEY || err.errcode === SQLITE_CONSTRAINT_UNIQUE;
1239
- return String(err).includes("UNIQUE constraint");
870
+ /** §4.2 — `map` keys must EXACTLY cover the enum's `values` (no missing keys —
871
+ * a record could pick an unmapped frequency and silently stall; no extra keys
872
+ * — a stale map outliving an enum edit). */
873
+ function fieldDrivenMapCoversValues(schema) {
874
+ const driven = fieldDrivenSpawnEvery(schema);
875
+ if (!driven) return true;
876
+ const target = declaredField(schema.fields, driven.fromField);
877
+ if (target?.type !== "enum") return true;
878
+ const values = new Set(target.values);
879
+ const keys = Object.keys(driven.map);
880
+ return keys.length === values.size && keys.every((key) => values.has(key));
1240
881
  }
1241
- function parseRow(raw) {
1242
- if (typeof raw !== "string") return null;
1243
- try {
1244
- const parsed = JSON.parse(raw);
1245
- return isRecord(parsed) ? parsed : null;
1246
- } catch {
1247
- return null;
882
+ /** §4.5 — `fromField` must reach the successor (via `carry` or `set`);
883
+ * otherwise the successor loses its frequency and the NEXT spawn along the
884
+ * chain can't resolve an interval, silently halting the recurrence.
885
+ *
886
+ * `set` writes a FIXED value, so it must itself be a key of `map` (else the
887
+ * successor is born with an unresolvable driver and `resolveEvery` skips it —
888
+ * the exact silent-halt §4.5 exists to prevent). `carry` copies the source's
889
+ * own value, which — for a record that matched the spawn — is one of the
890
+ * enum's values, all of which `map` covers by §4.2; so a carried driver is
891
+ * always resolvable and needs no value check here. */
892
+ function fieldDrivenFromFieldCarried(schema) {
893
+ const driven = fieldDrivenSpawnEvery(schema);
894
+ if (!driven) return true;
895
+ const { carry, set } = schema.spawn ?? {};
896
+ if (set && Object.prototype.hasOwnProperty.call(set, driven.fromField)) {
897
+ const raw = set[driven.fromField];
898
+ if (raw === void 0 || raw === null || raw === "") return false;
899
+ const key = fieldTextOrNull(raw);
900
+ return key !== null && Object.prototype.hasOwnProperty.call(driven.map, key);
1248
901
  }
902
+ return (carry ?? []).includes(driven.fromField);
1249
903
  }
1250
- /** One column of a result row. node:sqlite types rows as `unknown`, so a
1251
- * value that is not a row object yields no column at all. */
1252
- function readColumn(row, column) {
1253
- return isRecord(row) ? row[column] : void 0;
904
+ /** `calendarField` must name a real `date`/`datetime` field — the calendar view
905
+ * parses its value to place records on the month grid (a `datetime` anchor
906
+ * also carries the clock for the day view). */
907
+ function calendarFieldIsDateLike(schema) {
908
+ return schema.calendarField === void 0 || isDateLike(declaredField(schema.fields, schema.calendarField)?.type);
1254
909
  }
1255
- function rowsToItems(rows) {
1256
- return rows.map((row) => parseRow(readColumn(row, "record"))).filter((item) => item !== null);
910
+ /** `calendarEndField` marks the end of a multi-day span, so it only means
911
+ * something alongside a start anchor. */
912
+ function calendarEndFieldRequiresCalendarField(schema) {
913
+ return schema.calendarEndField === void 0 || schema.calendarField !== void 0;
1257
914
  }
1258
- /** node:sqlite hands back an integer column as `number`, or as `bigint` once
1259
- * it leaves the safe-integer range — COUNT(*) can be either. */
1260
- function countRecords(database) {
1261
- const count = readColumn(database.prepare("SELECT COUNT(*) AS n FROM records").get(), "n");
1262
- if (typeof count === "number") return count;
1263
- if (typeof count === "bigint") return Number(count);
1264
- throw new Error(`sqlite COUNT(*) returned no numeric row count (got ${typeof count})`);
915
+ /** `calendarEndField` must also name a real `date`/`datetime` field — same parse. */
916
+ function calendarEndFieldIsDateLike(schema) {
917
+ return schema.calendarEndField === void 0 || isDateLike(declaredField(schema.fields, schema.calendarEndField)?.type);
1265
918
  }
1266
- async function sqliteList(absPath, workspaceRoot) {
1267
- return withDb(absPath, workspaceRoot, "read", () => [], (database) => rowsToItems(database.prepare("SELECT record FROM records ORDER BY id").all()));
919
+ /** `calendarTimeField` places records on the day view, so it only means
920
+ * something alongside a start anchor. */
921
+ function calendarTimeFieldRequiresCalendarField(schema) {
922
+ return schema.calendarTimeField === void 0 || schema.calendarField !== void 0;
1268
923
  }
1269
- async function sqlitePage(absPath, primaryKey, opts, workspaceRoot) {
1270
- const emptyPage = {
1271
- items: [],
1272
- total: 0,
1273
- truncated: false
1274
- };
1275
- return withDb(absPath, workspaceRoot, "read", () => emptyPage, (database) => {
1276
- const total = countRecords(database);
1277
- const offset = Math.max(0, opts.offset ?? 0);
1278
- const limit = opts.limit === void 0 ? -1 : Math.max(0, opts.limit);
1279
- return {
1280
- items: projectItemFields(rowsToItems(database.prepare("SELECT record FROM records ORDER BY id LIMIT ? OFFSET ?").all(limit, offset)), opts.fields, primaryKey),
1281
- total,
1282
- truncated: false
1283
- };
1284
- });
924
+ /** `calendarTimeField` must name a real top-level field (a free-form time
925
+ * string the day view parses). */
926
+ function calendarTimeFieldIsDeclared(schema) {
927
+ return schema.calendarTimeField === void 0 || declaredField(schema.fields, schema.calendarTimeField) !== void 0;
1285
928
  }
1286
- async function sqliteRead(absPath, itemId, workspaceRoot) {
1287
- const safeId = safeRecordId(itemId);
1288
- if (safeId === null) return null;
1289
- return withDb(absPath, workspaceRoot, "read", () => null, (database) => {
1290
- return parseRow(readColumn(database.prepare("SELECT record FROM records WHERE id = ?").get(safeId), "record"));
1291
- });
929
+ /** …and that field must be string-backed — the day view parses its value as a
930
+ * time string, so a number/enum/date column can't drive it. */
931
+ function calendarTimeFieldIsStringBacked(schema) {
932
+ return schema.calendarTimeField === void 0 || isTimeStringField(declaredField(schema.fields, schema.calendarTimeField)?.type);
1292
933
  }
1293
- async function sqliteWrite(absPath, itemId, item, opts) {
1294
- const safeId = safeRecordId(itemId);
1295
- if (safeId === null) return {
1296
- kind: "invalid-id",
1297
- itemId
1298
- };
1299
- const outcome = await withDb(absPath, opts.workspaceRoot, "write", () => ({
1300
- kind: "path-escape",
1301
- itemId: safeId
1302
- }), (database) => {
1303
- const payload = JSON.stringify(item);
1304
- if (opts.refuseOverwrite) try {
1305
- database.prepare("INSERT INTO records (id, record) VALUES (?, ?)").run(safeId, payload);
1306
- } catch (err) {
1307
- if (isUniqueConstraintError(err)) return {
1308
- kind: "conflict",
1309
- itemId: safeId
1310
- };
1311
- throw err;
1312
- }
1313
- else database.prepare("INSERT INTO records (id, record) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET record = excluded.record").run(safeId, payload);
1314
- return {
1315
- kind: "ok",
1316
- itemId: safeId,
1317
- item
1318
- };
1319
- });
1320
- if (outcome.kind === "ok" && opts.slug) publishCollectionChange(collectionChangePayload({
1321
- slug: opts.slug,
1322
- ids: [safeId],
1323
- op: "upsert"
1324
- }, opts.publishRoot));
1325
- return outcome;
1326
- }
1327
- async function sqliteDelete(absPath, itemId, opts) {
1328
- const safeId = safeRecordId(itemId);
1329
- if (safeId === null) return {
1330
- kind: "invalid-id",
1331
- itemId
1332
- };
1333
- const outcome = await withDb(absPath, opts.workspaceRoot, "read", (reason) => reason === "refused" ? {
1334
- kind: "path-escape",
1335
- itemId: safeId
1336
- } : {
1337
- kind: "not-found",
1338
- itemId: safeId
1339
- }, (database) => {
1340
- const { changes } = database.prepare("DELETE FROM records WHERE id = ?").run(safeId);
1341
- return Number(changes) === 0 ? {
1342
- kind: "not-found",
1343
- itemId: safeId
1344
- } : {
1345
- kind: "ok",
1346
- itemId: safeId
1347
- };
1348
- });
1349
- if (outcome.kind === "ok" && opts.slug) publishCollectionChange(collectionChangePayload({
1350
- slug: opts.slug,
1351
- ids: [safeId],
1352
- op: "delete"
1353
- }, opts.publishRoot));
1354
- return outcome;
1355
- }
1356
- /** Best-effort full WAL checkpoint so the MAIN db file alone is a
1357
- * complete snapshot (committed pages in `<db>-wal` are folded in and the
1358
- * WAL truncated). Used by `deleteCollection` before archiving. Returns
1359
- * false on any failure (runtime without node:sqlite, locked db, missing
1360
- * file) — the caller then archives the sidecar files alongside the db so
1361
- * no committed data is lost either way. */
1362
- async function checkpointSqliteDatabase(absPath) {
1363
- try {
1364
- const { DatabaseSync } = await loadSqlite();
1365
- const database = new DatabaseSync(absPath);
1366
- try {
1367
- database.exec("PRAGMA wal_checkpoint(TRUNCATE)");
1368
- } finally {
1369
- database.close();
1370
- }
1371
- return true;
1372
- } catch {
1373
- return false;
1374
- }
1375
- }
1376
- /** A `storage: sqlite` store over `collection.storageFile`. A schema whose
1377
- * `storageFile` failed to resolve yields a read-only EMPTY store rather
1378
- * than a writable one — same fail-closed rule as the CSV store. */
1379
- function sqliteStoreFor(collection, opts) {
1380
- const file = collection.storageFile;
1381
- const key = collection.schema.primaryKey;
1382
- const slug = opts.slug ?? collection.slug;
1383
- const root = () => opts.workspaceRoot ?? getWorkspaceRoot();
1384
- const publishRoot = opts.workspaceRoot;
1385
- if (file === void 0) return {
1386
- capabilities: {
1387
- writable: false,
1388
- nativeQuery: false,
1389
- nativePaging: false
1390
- },
1391
- list: () => Promise.resolve([]),
1392
- page: () => Promise.resolve({
1393
- items: [],
1394
- total: 0,
1395
- truncated: false
1396
- }),
1397
- read: () => Promise.resolve(null)
1398
- };
1399
- return {
1400
- capabilities: {
1401
- writable: true,
1402
- nativeQuery: false,
1403
- nativePaging: true
1404
- },
1405
- list: () => sqliteList(file, root()),
1406
- page: (pageOpts = {}) => sqlitePage(file, key, pageOpts, root()),
1407
- read: (itemId) => sqliteRead(file, itemId, root()),
1408
- write: (itemId, item, writeOpts = {}) => sqliteWrite(file, itemId, item, {
1409
- workspaceRoot: root(),
1410
- publishRoot,
1411
- slug,
1412
- refuseOverwrite: writeOpts.refuseOverwrite
1413
- }),
1414
- delete: (itemId) => sqliteDelete(file, itemId, {
1415
- workspaceRoot: root(),
1416
- publishRoot,
1417
- slug
1418
- }),
1419
- watch: async (onChange) => closerFor(await watchSingleFile(file, (base, name) => name.startsWith(base), () => onChange({ kind: "collection" })))
1420
- };
1421
- }
1422
- //#endregion
1423
- //#region src/collection/server/store.ts
1424
- /** The file store's stable order: lexicographic by record id (codepoint
1425
- * compare — locale-independent). `listItems` returns readdir order, which
1426
- * is filesystem-dependent; paging needs determinism. */
1427
- function sortByRecordId(items, primaryKey) {
1428
- return [...items].sort((left, right) => {
1429
- const leftId = fieldText(left[primaryKey]);
1430
- const rightId = fieldText(right[primaryKey]);
1431
- if (leftId < rightId) return -1;
1432
- return leftId > rightId ? 1 : 0;
1433
- });
1434
- }
1435
- /** True when the collection accepts UI/tool writes. A `dataSource`
1436
- * collection is read-only: updates happen by editing/replacing the
1437
- * data file itself. Every write entry point checks this BEFORE calling
1438
- * `writeItem`/`deleteItem` — server-enforced, not just UI-hidden. */
1439
- function collectionWritable(collection) {
1440
- return !isReadOnlySchema(collection.schema);
1441
- }
1442
- /** The one-line refusal write paths surface (HTTP 405 / MCP error text). */
1443
- function readOnlyRefusal(slug) {
1444
- return `collection '${slug}' is read-only (backed by an external dataSource) — update the data file itself instead`;
1445
- }
1446
- /** A `dataSource` store over `file` (CSV row order; DuckDB-native query).
1447
- * A schema whose `dataSourceFile` failed to resolve yields a read-only
1448
- * EMPTY store rather than falling back to the (writable) file store — a
1449
- * half-loaded read-only collection must never become writable. */
1450
- function csvStoreFor(collection, opts) {
1451
- const file = collection.dataSourceFile;
1452
- const key = collection.schema.primaryKey;
1453
- const listAll = () => file === void 0 ? Promise.resolve({
1454
- items: [],
1455
- truncated: false
1456
- }) : csvList(file, key, opts.workspaceRoot);
1457
- return {
1458
- capabilities: {
1459
- writable: false,
1460
- nativeQuery: true,
1461
- nativePaging: false
1462
- },
1463
- list: () => listAll().then((result) => result.items),
1464
- page: (pageOpts = {}) => listAll().then((result) => pageFromFullRead(result.items, pageOpts, key, result.truncated)),
1465
- read: (itemId) => file === void 0 ? Promise.resolve(null) : csvRead(file, key, itemId, opts.workspaceRoot),
1466
- query: (query) => file === void 0 ? Promise.resolve([]) : csvRunQuery(file, key, query, opts.workspaceRoot),
1467
- ...file === void 0 ? {} : { watch: async (onChange) => closerFor(await watchSingleFile(file, () => false, () => onChange({ kind: "collection" }))) }
1468
- };
934
+ /** `kanbanField` must name a real `enum` field — the board groups records into
935
+ * one column per declared enum value; any other type has no closed set of
936
+ * columns to group by. */
937
+ function kanbanFieldIsAnEnum(schema) {
938
+ return schema.kanbanField === void 0 || declaredField(schema.fields, schema.kanbanField)?.type === "enum";
1469
939
  }
1470
- /** The classic file store over `<dataDir>/<itemId>.json` records. */
1471
- function fileStoreFor(collection, opts) {
1472
- const key = collection.schema.primaryKey;
1473
- const ioOpts = {
1474
- ...opts,
1475
- slug: opts.slug ?? collection.slug
1476
- };
1477
- return {
1478
- capabilities: {
1479
- writable: true,
1480
- nativeQuery: false,
1481
- nativePaging: false
1482
- },
1483
- list: () => listItems(collection.dataDir, opts),
1484
- page: async (pageOpts = {}) => pageFromFullRead(sortByRecordId(await listItems(collection.dataDir, opts), key), pageOpts, key, false),
1485
- read: (itemId) => readItem(collection.dataDir, itemId, opts),
1486
- write: (itemId, item, writeOpts = {}) => writeItem(collection.dataDir, itemId, item, {
1487
- ...ioOpts,
1488
- refuseOverwrite: writeOpts.refuseOverwrite
1489
- }),
1490
- delete: (itemId) => deleteItem(collection.dataDir, itemId, ioOpts),
1491
- watch: async (onChange) => closerFor(await watchDirectory(collection.dataDir, (name) => name.endsWith(".json") && !name.startsWith("."), (filename) => onChange(filename === null ? { kind: "collection" } : {
1492
- kind: "item",
1493
- itemId: filename.slice(0, -5)
1494
- })))
1495
- };
940
+ /** `notifyWhen` narrows the completion bell, so it only means something with
941
+ * completion tracking. */
942
+ function notifyWhenRequiresCompletion(schema) {
943
+ return schema.notifyWhen === void 0 || schema.completionField !== void 0;
1496
944
  }
1497
- var storeFactories = /* @__PURE__ */ new Map([
1498
- ["file", fileStoreFor],
1499
- ["csv", csvStoreFor],
1500
- ["sqlite", sqliteStoreFor]
1501
- ]);
1502
- /** Pick the store implementation for a discovered collection via the
1503
- * factory registry. An unknown kind cannot normally reach here (the
1504
- * schema's `StorageZ` union gates it), so the throw is a loud invariant
1505
- * breach, not a user-facing path. */
1506
- function storeFor(collection, opts = {}) {
1507
- const kind = storageKindFor(collection.schema);
1508
- const factory = storeFactories.get(kind);
1509
- if (!factory) throw new Error(`no store factory registered for storage kind '${kind}'`);
1510
- return factory(collection, opts);
945
+ /** `notifyWhen.field` must name a real top-level field. */
946
+ function notifyWhenFieldIsDeclared(schema) {
947
+ return schema.notifyWhen === void 0 || declaredField(schema.fields, schema.notifyWhen.field) !== void 0;
1511
948
  }
1512
- /** The param name a `set` value references, or null when the value is a
1513
- * literal (non-strings can never be references). A bare/empty prefix
1514
- * (`"$params."`) returns the empty string — the schema refine rejects
1515
- * it as an undeclared param, never silently treats it as a literal. */
1516
- function paramRefName(value) {
1517
- if (typeof value !== "string" || !value.startsWith("$params.")) return null;
1518
- return value.slice(8);
949
+ /** Every custom view `id` must be a valid slug — it doubles as the view-mode
950
+ * selector key (`custom:<id>`) and the capability-token clamp key, both of
951
+ * which expect a path-safe token. */
952
+ function viewIdsAreSlugs(schema) {
953
+ return schema.views === void 0 || schema.views.every((view) => isSafeSlug(view.id));
1519
954
  }
1520
- /** Resolve a mutate action's `set` map against the submitted params:
1521
- * literals pass through, `$params.<name>` reads the param value. An
1522
- * ABSENT referenced param omits the key entirely (merge semantics —
1523
- * the stored value survives), mirroring how the record form omits
1524
- * empty optionals rather than writing empty strings. */
1525
- function resolveMutateSet(set, params) {
1526
- const resolved = {};
1527
- for (const [key, value] of Object.entries(set)) {
1528
- const ref = paramRefName(value);
1529
- if (ref === null) {
1530
- resolved[key] = value;
1531
- continue;
1532
- }
1533
- const paramValue = params[ref];
1534
- if (paramValue !== void 0 && paramValue !== null && paramValue !== "") resolved[key] = paramValue;
1535
- }
1536
- return resolved;
955
+ /** Custom view ids must be unique so the selector + token clamp resolve
956
+ * unambiguously. */
957
+ function viewIdsAreUnique(schema) {
958
+ return hasUniqueIds(schema.views);
1537
959
  }
1538
960
  //#endregion
1539
- //#region src/collection/core/schemaRules.ts
1540
- var declaredField = (fields, name) => Object.hasOwn(fields, name) ? fields[name] : void 0;
1541
- var isDateLike = (type) => type === "date" || type === "datetime";
1542
- var isTimeStringField = (type) => type === "string" || type === "text";
1543
- var CODE_FIELD_TYPES = /* @__PURE__ */ new Set([
1544
- "string",
1545
- "text",
1546
- "enum"
1547
- ]);
1548
- var namesStoredField = (fields, name, primaryKey) => {
1549
- const target = declaredField(fields, name);
1550
- return target !== void 0 && !COMPUTED_TYPES.has(target.type) && name !== primaryKey;
1551
- };
1552
- var hasUniqueIds = (entries) => entries === void 0 || new Set(entries.map((entry) => entry.id)).size === entries.length;
1553
- /** Exactly one storage declaration: native records need `dataPath`, an external
1554
- * data file needs `dataSource`, an alternative backend needs `storage`. Zero
1555
- * (nowhere to read) and several (ambiguous which wins) are equally
1556
- * meaningless — fail loudly at load instead of picking silently. */
1557
- function declaresExactlyOneStore(schema) {
1558
- return [
1559
- schema.dataPath,
1560
- schema.dataSource,
1561
- schema.storage
1562
- ].filter((declared) => declared !== void 0).length === 1;
1563
- }
1564
- /** A `dataSource` collection is read-only by definition, so schema-level write
1565
- * machinery can never fire: `singleton` pins CREATES, `ingest` REFILLS
1566
- * records, `spawn` WRITES successor records. Rejecting them at validation
1567
- * kills whole classes of writes before any runtime guard. */
1568
- function dataSourceDeclaresNoWriteMachinery(schema) {
1569
- if (schema.dataSource === void 0) return true;
1570
- return schema.singleton === void 0 && schema.ingest === void 0 && schema.spawn === void 0 && schema.googleCalendar === void 0;
1571
- }
1572
- /** Same rule for declarative host writes: a mutate action writes the record
1573
- * it's invoked on, which a read-only collection has no business doing. */
1574
- function dataSourceDeclaresNoMutateAction(schema) {
1575
- if (schema.dataSource !== void 0) return [...schema.actions ?? [], ...schema.collectionActions ?? []].every((action) => action.kind !== "mutate");
1576
- return true;
1577
- }
1578
- /** Action ids must be unique so the dispatch route resolves unambiguously. */
1579
- function actionIdsAreUnique(schema) {
1580
- return hasUniqueIds(schema.actions);
1581
- }
1582
- /** Collection-level action ids must likewise be unique. */
1583
- function collectionActionIdsAreUnique(schema) {
1584
- return hasUniqueIds(schema.collectionActions);
1585
- }
1586
- /** A mutate action's `set` writes real STORED fields: a typo'd key would write
1587
- * a stray value forever, a computed/projected field is never persisted, and
1588
- * the primaryKey is the filename (renaming is not a mutation). */
1589
- function mutateSetKeysNameStoredFields(schema) {
1590
- return (schema.actions ?? []).every((action) => action.kind !== "mutate" || Object.keys(action.set).every((key) => namesStoredField(schema.fields, key, schema.primaryKey)));
1591
- }
1592
- /** Every `$params.<name>` reference in `set` must name a declared param — an
1593
- * undeclared one would silently no-op the assignment. */
1594
- function mutateParamRefsAreDeclared(schema) {
1595
- return (schema.actions ?? []).every((action) => action.kind !== "mutate" || Object.values(action.set).every((value) => {
1596
- const ref = paramRefName(value);
1597
- return ref === null || (action.params ?? {})[ref] !== void 0;
1598
- }));
1599
- }
1600
- /** A collection-level action has no record to write. */
1601
- function collectionActionsAreNotMutate(schema) {
1602
- return (schema.collectionActions ?? []).every((action) => action.kind !== "mutate");
1603
- }
1604
- /** The singleton value becomes a record id (and thus a `<id>.json` filename),
1605
- * so it must satisfy the SAME record-id rule the write path enforces —
1606
- * otherwise the create form would lock the primary key to a value the POST
1607
- * route then rejects, making the collection impossible to initialize. */
1608
- function singletonIsAValidRecordId(schema) {
1609
- return schema.singleton === void 0 || isSafeRecordId(schema.singleton);
1610
- }
1611
- function collectCurrencyFieldRefs(fields) {
1612
- const refs = [];
1613
- for (const field of Object.values(fields)) {
1614
- if (typeof field.currencyField === "string" && field.currencyField.length > 0) refs.push(field.currencyField);
1615
- for (const sub of Object.values(field.of ?? {})) if (typeof sub.currencyField === "string" && sub.currencyField.length > 0) refs.push(sub.currencyField);
1616
- }
1617
- return refs;
1618
- }
1619
- /** A `currencyField` pointer must name a real top-level field that holds a code
1620
- * string — a typo (`curreny`) would otherwise pass the per-field check, then
1621
- * silently fall back to the literal / USD at render and mislabel amounts. */
1622
- function currencyFieldRefsNameCodeFields(schema) {
1623
- return collectCurrencyFieldRefs(schema.fields).every((name) => CODE_FIELD_TYPES.has(declaredField(schema.fields, name)?.type ?? ""));
1624
- }
1625
- /** The pair must be declared together — one without the other is meaningless:
1626
- * the host would either never fire (no done values to compare against) or
1627
- * never clear (no field to read).
1628
- *
1629
- * EXCEPTION: when `completionField` names a `flag` field, done ⇔ the flag's
1630
- * `where` matches, so `completionDoneValues` carries no information and MUST
1631
- * be omitted (declaring it would invite a contradictory second source of
1632
- * truth). */
1633
- function completionPairIsCoherent(schema) {
1634
- if (schema.completionField !== void 0 && declaredField(schema.fields, schema.completionField)?.type === "flag") return schema.completionDoneValues === void 0;
1635
- return schema.completionField === void 0 === (schema.completionDoneValues === void 0);
1636
- }
1637
- /** `completionField` must name a real top-level field — a typo would silently
1638
- * disable the notification mechanism otherwise. */
1639
- function completionFieldIsDeclared(schema) {
1640
- return schema.completionField === void 0 || declaredField(schema.fields, schema.completionField) !== void 0;
1641
- }
1642
- /** A flag named by `completionField` is evaluated against the RAW record — the
1643
- * reconciler (and spawn's fallback) read items straight off disk, BEFORE any
1644
- * `deriveAll` enrichment — so its `where` may only reference STORED fields. A
1645
- * condition over a computed sibling would see an absent key: `ne` matches
1646
- * vacuously, every other op reads false, and the bell would clear wrongly /
1647
- * never. General (non-completion) flags keep the full vocabulary — the UI
1648
- * evaluates them post-enrichment. */
1649
- function completionFlagReadsOnlyStoredFields(schema) {
1650
- const spec = schema.completionField === void 0 ? void 0 : declaredField(schema.fields, schema.completionField);
1651
- if (spec?.type !== "flag") return true;
1652
- return spec.where.every((cond) => [cond.field, ...cond.valueFrom ? [cond.valueFrom.field] : []].every((name) => {
1653
- const target = declaredField(schema.fields, name);
1654
- return target !== void 0 && !COMPUTED_TYPES.has(target.type);
1655
- }));
1656
- }
1657
- /** `displayField`, like `completionField`, must name a real top-level field —
1658
- * a typo would silently fall back to the primaryKey forever. */
1659
- function displayFieldIsDeclared(schema) {
1660
- return schema.displayField === void 0 || declaredField(schema.fields, schema.displayField) !== void 0;
1661
- }
1662
- /** A field's `when.field` gates its visibility against a sibling's value, so it
1663
- * must name a real top-level field — a typo would silently keep the field
1664
- * hidden forever (the gate never matches). */
1665
- function fieldVisibilityGatesNameDeclaredFields(schema) {
1666
- return Object.values(schema.fields).every((field) => field.when === void 0 || declaredField(schema.fields, field.when.field) !== void 0);
1667
- }
1668
- /** A flag's `where` reads sibling fields (both `cond.field` and a same-record
1669
- * `valueFrom.field`), so each must name a real top-level field — a typo would
1670
- * silently pin the flag false forever (`ne`: true forever). */
1671
- function flagConditionsNameDeclaredFields(schema) {
1672
- return Object.values(schema.fields).every((field) => field.type !== "flag" || field.where.every((cond) => declaredField(schema.fields, cond.field) !== void 0 && (cond.valueFrom === void 0 || declaredField(schema.fields, cond.valueFrom.field) !== void 0)));
1673
- }
1674
- /** An `embed`'s `idField` resolves the target record id from a sibling's value,
1675
- * so it must name a real top-level field — and one whose stored value is a
1676
- * plain id string. Only `ref` / `string` qualify: the editor writes the picked
1677
- * id into that field, so a non-persisted or composite type would either not
1678
- * round-trip on save or hold no usable id. */
1679
- function embedIdFieldsNameIdBearingFields(schema) {
1680
- return Object.values(schema.fields).every((field) => {
1681
- if (field.type !== "embed" || field.idField === void 0) return true;
1682
- const target = declaredField(schema.fields, field.idField);
1683
- return target !== void 0 && (target.type === "ref" || target.type === "string");
1684
- });
1685
- }
1686
- /** The sync writes each mapped value into a declared field, and puts the Google
1687
- * event id in the primary field — so a map key that names no field (or names
1688
- * the primary) would silently drop data or fight the id. */
1689
- function googleCalendarMapNamesStoredFields(schema) {
1690
- if (schema.googleCalendar === void 0) return true;
1691
- return Object.keys(schema.googleCalendar.map).every((key) => namesStoredField(schema.fields, key, schema.primaryKey));
1692
- }
1693
- /** A `toggle` field projects an `enum` field: its `field` must name a real
1694
- * top-level enum, and `onValue` / `offValue` must be members of that enum's
1695
- * `values` — otherwise toggling would write a value outside the closed set
1696
- * (and never appear "checked"). */
1697
- function togglesProjectValidEnums(schema) {
1698
- const { fields } = schema;
1699
- for (const spec of Object.values(fields)) {
1700
- if (spec.type !== "toggle") continue;
1701
- const target = declaredField(fields, spec.field);
1702
- if (!target || target.type !== "enum") return false;
1703
- const allowed = new Set(target.values);
1704
- if (!allowed.has(spec.onValue) || !allowed.has(spec.offValue)) return false;
1705
- }
1706
- return true;
1707
- }
1708
- /** `triggerField` requires the completion pair: the time gate only suppresses
1709
- * the *completion* bell until the date, and the bell still clears via
1710
- * `completionDoneValues`. Without completion there is no bell to gate. */
1711
- function triggerFieldRequiresCompletion(schema) {
1712
- return schema.triggerField === void 0 || schema.completionField !== void 0;
1713
- }
1714
- /** `triggerField` must name a real `date` field — the gate parses its value as
1715
- * `YYYY-MM-DD`; any other type can't be compared to the clock. */
1716
- function triggerFieldIsADateField(schema) {
1717
- return schema.triggerField === void 0 || declaredField(schema.fields, schema.triggerField)?.type === "date";
1718
- }
1719
- /** `triggerLeadDays` only means something relative to a trigger date. */
1720
- function triggerLeadDaysRequiresTriggerField(schema) {
1721
- return schema.triggerLeadDays === void 0 || schema.triggerField !== void 0;
1722
- }
1723
- /** `spawn` advances `triggerField` to compute the successor's trigger date, so
1724
- * the schema must declare one. */
1725
- function spawnRequiresTriggerField(schema) {
1726
- return schema.spawn === void 0 || schema.triggerField !== void 0;
1727
- }
1728
- /** `spawn.when.field` must name a real top-level field — a typo would silently
1729
- * never match. */
1730
- function spawnWhenFieldIsDeclared(schema) {
1731
- return schema.spawn?.when === void 0 || declaredField(schema.fields, schema.spawn.when.field) !== void 0;
1732
- }
1733
- /** Every `spawn.carry` entry must name a real top-level field — a typo would
1734
- * silently never copy. */
1735
- function spawnCarryEntriesAreDeclared(schema) {
1736
- return (schema.spawn?.carry ?? []).every((name) => declaredField(schema.fields, name) !== void 0);
1737
- }
1738
- /** A successor must NOT be born already matching its own spawn predicate — it
1739
- * would re-spawn on its first reconcile, fanning out into an unbounded chain
1740
- * of records. The predicate field/values are `spawn.when` when given, else the
1741
- * completion-done pair. The successor's value for that field is `set[field]`
1742
- * if set, else the carried source value (which matched, by definition, when
1743
- * the spawn fired) if carried, else absent (safe). */
1744
- function spawnSuccessorStartsInert(schema) {
1745
- const { spawn } = schema;
1746
- if (!spawn) return true;
1747
- const field = spawn.when?.field ?? schema.completionField;
1748
- const values = spawn.when?.in ?? schema.completionDoneValues;
1749
- if (!field || !values) return true;
1750
- if (spawn.set && Object.prototype.hasOwnProperty.call(spawn.set, field)) return !values.includes(String(spawn.set[field]));
1751
- return !(spawn.carry ?? []).includes(field);
1752
- }
1753
- /** `spawnSuccessorStartsInert` cannot see through a flag's `where` (the
1754
- * predicate would need full record evaluation against `set`/`carry`). So a
1755
- * schema whose completion is flag-form may only spawn with an explicit
1756
- * `spawn.when` — which that check CAN evaluate. */
1757
- function flagCompletionSpawnDeclaresWhen(schema) {
1758
- return schema.spawn === void 0 || schema.spawn.when !== void 0 || declaredField(schema.fields, schema.completionField ?? "")?.type !== "flag";
1759
- }
1760
- function fieldDrivenSpawnEvery(schema) {
1761
- const every = schema.spawn?.every;
1762
- if (!every || !("fromField" in every)) return null;
1763
- return every;
1764
- }
1765
- /** §4.1 — `fromField` must name a real top-level `enum` field. The `map` keys
1766
- * are only meaningful against a closed value set, and the field renders as a
1767
- * form `<select>`; a non-enum target has no finite values to validate. */
1768
- function fieldDrivenFromFieldIsEnum(schema) {
1769
- const driven = fieldDrivenSpawnEvery(schema);
1770
- if (!driven) return true;
1771
- return declaredField(schema.fields, driven.fromField)?.type === "enum";
1772
- }
1773
- /** §4.2 — `map` keys must EXACTLY cover the enum's `values` (no missing keys —
1774
- * a record could pick an unmapped frequency and silently stall; no extra keys
1775
- * — a stale map outliving an enum edit). */
1776
- function fieldDrivenMapCoversValues(schema) {
1777
- const driven = fieldDrivenSpawnEvery(schema);
1778
- if (!driven) return true;
1779
- const target = declaredField(schema.fields, driven.fromField);
1780
- if (target?.type !== "enum") return true;
1781
- const values = new Set(target.values);
1782
- const keys = Object.keys(driven.map);
1783
- return keys.length === values.size && keys.every((key) => values.has(key));
1784
- }
1785
- /** §4.5 — `fromField` must reach the successor (via `carry` or `set`);
1786
- * otherwise the successor loses its frequency and the NEXT spawn along the
1787
- * chain can't resolve an interval, silently halting the recurrence.
1788
- *
1789
- * `set` writes a FIXED value, so it must itself be a key of `map` (else the
1790
- * successor is born with an unresolvable driver and `resolveEvery` skips it —
1791
- * the exact silent-halt §4.5 exists to prevent). `carry` copies the source's
1792
- * own value, which — for a record that matched the spawn — is one of the
1793
- * enum's values, all of which `map` covers by §4.2; so a carried driver is
1794
- * always resolvable and needs no value check here. */
1795
- function fieldDrivenFromFieldCarried(schema) {
1796
- const driven = fieldDrivenSpawnEvery(schema);
1797
- if (!driven) return true;
1798
- const { carry, set } = schema.spawn ?? {};
1799
- if (set && Object.prototype.hasOwnProperty.call(set, driven.fromField)) {
1800
- const raw = set[driven.fromField];
1801
- if (raw === void 0 || raw === null || raw === "") return false;
1802
- const key = fieldTextOrNull(raw);
1803
- return key !== null && Object.prototype.hasOwnProperty.call(driven.map, key);
1804
- }
1805
- return (carry ?? []).includes(driven.fromField);
1806
- }
1807
- /** `calendarField` must name a real `date`/`datetime` field — the calendar view
1808
- * parses its value to place records on the month grid (a `datetime` anchor
1809
- * also carries the clock for the day view). */
1810
- function calendarFieldIsDateLike(schema) {
1811
- return schema.calendarField === void 0 || isDateLike(declaredField(schema.fields, schema.calendarField)?.type);
1812
- }
1813
- /** `calendarEndField` marks the end of a multi-day span, so it only means
1814
- * something alongside a start anchor. */
1815
- function calendarEndFieldRequiresCalendarField(schema) {
1816
- return schema.calendarEndField === void 0 || schema.calendarField !== void 0;
1817
- }
1818
- /** `calendarEndField` must also name a real `date`/`datetime` field — same parse. */
1819
- function calendarEndFieldIsDateLike(schema) {
1820
- return schema.calendarEndField === void 0 || isDateLike(declaredField(schema.fields, schema.calendarEndField)?.type);
1821
- }
1822
- /** `calendarTimeField` places records on the day view, so it only means
1823
- * something alongside a start anchor. */
1824
- function calendarTimeFieldRequiresCalendarField(schema) {
1825
- return schema.calendarTimeField === void 0 || schema.calendarField !== void 0;
1826
- }
1827
- /** `calendarTimeField` must name a real top-level field (a free-form time
1828
- * string the day view parses). */
1829
- function calendarTimeFieldIsDeclared(schema) {
1830
- return schema.calendarTimeField === void 0 || declaredField(schema.fields, schema.calendarTimeField) !== void 0;
1831
- }
1832
- /** …and that field must be string-backed — the day view parses its value as a
1833
- * time string, so a number/enum/date column can't drive it. */
1834
- function calendarTimeFieldIsStringBacked(schema) {
1835
- return schema.calendarTimeField === void 0 || isTimeStringField(declaredField(schema.fields, schema.calendarTimeField)?.type);
1836
- }
1837
- /** `kanbanField` must name a real `enum` field — the board groups records into
1838
- * one column per declared enum value; any other type has no closed set of
1839
- * columns to group by. */
1840
- function kanbanFieldIsAnEnum(schema) {
1841
- return schema.kanbanField === void 0 || declaredField(schema.fields, schema.kanbanField)?.type === "enum";
1842
- }
1843
- /** `notifyWhen` narrows the completion bell, so it only means something with
1844
- * completion tracking. */
1845
- function notifyWhenRequiresCompletion(schema) {
1846
- return schema.notifyWhen === void 0 || schema.completionField !== void 0;
1847
- }
1848
- /** `notifyWhen.field` must name a real top-level field. */
1849
- function notifyWhenFieldIsDeclared(schema) {
1850
- return schema.notifyWhen === void 0 || declaredField(schema.fields, schema.notifyWhen.field) !== void 0;
1851
- }
1852
- /** Every custom view `id` must be a valid slug — it doubles as the view-mode
1853
- * selector key (`custom:<id>`) and the capability-token clamp key, both of
1854
- * which expect a path-safe token. */
1855
- function viewIdsAreSlugs(schema) {
1856
- return schema.views === void 0 || schema.views.every((view) => isSafeSlug(view.id));
1857
- }
1858
- /** Custom view ids must be unique so the selector + token clamp resolve
1859
- * unambiguously. */
1860
- function viewIdsAreUnique(schema) {
1861
- return hasUniqueIds(schema.views);
1862
- }
1863
- //#endregion
1864
- //#region src/collection/core/schemaZ.ts
1865
- /** Optional visibility predicate shared by actions and fields: the target
1866
- * shows only when the open record's `field` (stringified) is one of `in`.
1867
- * Domain-free — `field` is any non-empty key, `in` a non-empty array of
1868
- * non-empty values; the host never interprets the meaning.
961
+ //#region src/collection/core/schemaZ.ts
962
+ /** Optional visibility predicate shared by actions and fields: the target
963
+ * shows only when the open record's `field` (stringified) is one of `in`.
964
+ * Domain-free — `field` is any non-empty key, `in` a non-empty array of
965
+ * non-empty values; the host never interprets the meaning.
1869
966
  *
1870
967
  * `trim().min(1)` rather than bare `min(1)` so a whitespace-only string
1871
968
  * (" ") fails validation — otherwise the cell formatter / dropdown would
@@ -2231,594 +1328,1808 @@ var EveryLiteralZ = z.object({
2231
1328
  "month",
2232
1329
  "year"
2233
1330
  ]),
2234
- interval: z.number().int().min(1),
2235
- dayOfMonth: z.union([z.number().int().min(1).max(31), z.literal("last")]).optional()
2236
- }).strict();
2237
- /** Field-driven recurrence: pick the interval per-record by an `enum`
2238
- * field's value — one collection can mix daily / weekly / monthly
2239
- * obligations in a single list. `map` keys are validated to exactly cover
2240
- * that field's `values` by a `CollectionSchemaZ` refine (which can see the
2241
- * sibling `fields`); here each map value just has to be a well-formed
2242
- * literal `every`. `.strict()` mirrors the literal arm so a both-keys
2243
- * object fails this arm too. */
2244
- var EveryFieldDrivenZ = z.object({
2245
- fromField: z.string().trim().min(1),
2246
- map: z.record(z.string(), EveryLiteralZ)
2247
- }).strict();
2248
- /** Either a single literal interval (applied to every record) or the
2249
- * field-driven map. Two `.strict()` arms mean "both keys" and "neither
2250
- * key" both fail validation, with no extra refine. */
2251
- var EveryZ = z.union([EveryLiteralZ, EveryFieldDrivenZ]);
2252
- /** Host-driven recurrence: when a record satisfies `when` (default:
2253
- * "`completionField` value ∈ `completionDoneValues`"), the host creates the
2254
- * next record with a forward-advanced `triggerField` date. `carry` copies
2255
- * record fields verbatim onto the successor; `set` forces fixed values
2256
- * (typically resetting the status field to its pending value). The
2257
- * successor's id and contents are a pure function of (source record, this
2258
- * rule); creation is create-if-absent, so the mechanism stays convergent. */
2259
- var SpawnZ = z.object({
2260
- when: WhenZ.optional(),
2261
- every: EveryZ,
2262
- carry: z.array(z.string().trim().min(1)).optional(),
2263
- set: z.record(z.string(), z.unknown()).optional()
2264
- });
2265
- /** Declarative retrieval config for a Feed (a collection that refills itself
2266
- * from the internet). `http-json` needs `itemsAt` (a path to the items
2267
- * array) only when the response body isn't itself the array; rss/atom yield
2268
- * items natively and ignore it — so no kind-specific requirement here. */
2269
- var DeclarativeIngestZ = z.object({
2270
- kind: z.enum(INGEST_KINDS),
2271
- url: z.url(),
2272
- schedule: z.enum(FEED_SCHEDULES),
2273
- atHour: z.number().int().min(0).max(23).optional(),
2274
- itemsAt: z.string().trim().min(1).optional(),
2275
- map: z.record(z.string().trim().min(1), z.string().trim().min(1)),
2276
- idFrom: z.string().trim().min(1).optional(),
2277
- maxItems: z.number().int().min(0).optional()
2278
- });
2279
- /** Agent-performed retrieval. Valid on any collection (the primary consumer
2280
- * is skill-backed collections — feeds keep their declarative kinds). No
2281
- * `url`/`map`: the worker owns retrieval and record shape, seeded by
2282
- * `template` + a summary of every record, run in `role`. `template` is
2283
- * validated the SAME way an action's template is (safe path under
2284
- * `templates/`), so the skill-bridge mirrors it identically. */
2285
- var AgentIngestZ = z.object({
2286
- kind: z.literal(AGENT_INGEST_KIND),
2287
- schedule: z.enum(FEED_SCHEDULES),
2288
- atHour: z.number().int().min(0).max(23).optional(),
2289
- role: z.string().trim().min(1),
2290
- 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)")
2291
- });
2292
- /** Marks a collection as the destination of the LLM-free Google Calendar
2293
- * sync (#2095). `map` is collectionField → Google event field, so the user's
2294
- * collection keeps whatever field names it already uses. */
2295
- var GoogleCalendarSyncZ = z.object({
2296
- /** Calendar to pull from; defaults to the user's primary. */
2297
- calendarId: z.string().trim().min(1).optional(),
2298
- map: z.record(z.string().trim().min(1), z.enum([
2299
- "summary",
2300
- "start",
2301
- "end",
2302
- "htmlLink",
2303
- "colorId",
2304
- "status",
2305
- "description",
2306
- "location"
2307
- ])).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" }),
2308
- /** Run the push on the sync schedule too, immediately before each pull, so
2309
- * local edits reach Google without anyone pressing a button (#2620).
2310
- *
2311
- * Opt-in and absent by default: a push writes to a calendar other people may
2312
- * read, so turning it on is the user's decision, not a default. */
2313
- autoPush: z.boolean().optional()
2314
- });
2315
- /** `ingest` is a discriminated union on `kind`: the three declarative
2316
- * retrievers fetch-and-map; `agent` dispatches a hidden worker. Optional on
2317
- * every schema — skill-backed collections usually omit it; only feeds
2318
- * discovered from `<workspace>/feeds/` are REQUIRED to carry it (gated by
2319
- * `acceptParsedSchema`). */
2320
- var IngestZ = z.discriminatedUnion("kind", [DeclarativeIngestZ, AgentIngestZ]);
2321
- var DynamicIconSourceZ = z.object({
2322
- collection: z.string().trim().min(1),
2323
- from: z.enum([
2324
- "latest",
2325
- "first",
2326
- "when"
2327
- ]).optional(),
2328
- orderBy: z.string().trim().min(1).optional(),
2329
- where: WhereZ.optional()
2330
- });
2331
- var DynamicIconRuleZ = z.object({
2332
- where: WhereZ,
2333
- icon: z.string().trim().min(1)
2334
- });
2335
- var DynamicIconSpecZ = z.object({
2336
- source: DynamicIconSourceZ,
2337
- rules: z.array(DynamicIconRuleZ),
2338
- fallback: z.string().trim().min(1).optional()
1331
+ interval: z.number().int().min(1),
1332
+ dayOfMonth: z.union([z.number().int().min(1).max(31), z.literal("last")]).optional()
1333
+ }).strict();
1334
+ /** Field-driven recurrence: pick the interval per-record by an `enum`
1335
+ * field's value — one collection can mix daily / weekly / monthly
1336
+ * obligations in a single list. `map` keys are validated to exactly cover
1337
+ * that field's `values` by a `CollectionSchemaZ` refine (which can see the
1338
+ * sibling `fields`); here each map value just has to be a well-formed
1339
+ * literal `every`. `.strict()` mirrors the literal arm so a both-keys
1340
+ * object fails this arm too. */
1341
+ var EveryFieldDrivenZ = z.object({
1342
+ fromField: z.string().trim().min(1),
1343
+ map: z.record(z.string(), EveryLiteralZ)
1344
+ }).strict();
1345
+ /** Either a single literal interval (applied to every record) or the
1346
+ * field-driven map. Two `.strict()` arms mean "both keys" and "neither
1347
+ * key" both fail validation, with no extra refine. */
1348
+ var EveryZ = z.union([EveryLiteralZ, EveryFieldDrivenZ]);
1349
+ /** Host-driven recurrence: when a record satisfies `when` (default:
1350
+ * "`completionField` value ∈ `completionDoneValues`"), the host creates the
1351
+ * next record with a forward-advanced `triggerField` date. `carry` copies
1352
+ * record fields verbatim onto the successor; `set` forces fixed values
1353
+ * (typically resetting the status field to its pending value). The
1354
+ * successor's id and contents are a pure function of (source record, this
1355
+ * rule); creation is create-if-absent, so the mechanism stays convergent. */
1356
+ var SpawnZ = z.object({
1357
+ when: WhenZ.optional(),
1358
+ every: EveryZ,
1359
+ carry: z.array(z.string().trim().min(1)).optional(),
1360
+ set: z.record(z.string(), z.unknown()).optional()
1361
+ });
1362
+ /** Declarative retrieval config for a Feed (a collection that refills itself
1363
+ * from the internet). `http-json` needs `itemsAt` (a path to the items
1364
+ * array) only when the response body isn't itself the array; rss/atom yield
1365
+ * items natively and ignore it — so no kind-specific requirement here. */
1366
+ var DeclarativeIngestZ = z.object({
1367
+ kind: z.enum(INGEST_KINDS),
1368
+ url: z.url(),
1369
+ schedule: z.enum(FEED_SCHEDULES),
1370
+ atHour: z.number().int().min(0).max(23).optional(),
1371
+ itemsAt: z.string().trim().min(1).optional(),
1372
+ map: z.record(z.string().trim().min(1), z.string().trim().min(1)),
1373
+ idFrom: z.string().trim().min(1).optional(),
1374
+ maxItems: z.number().int().min(0).optional()
1375
+ });
1376
+ /** Agent-performed retrieval. Valid on any collection (the primary consumer
1377
+ * is skill-backed collections — feeds keep their declarative kinds). No
1378
+ * `url`/`map`: the worker owns retrieval and record shape, seeded by
1379
+ * `template` + a summary of every record, run in `role`. `template` is
1380
+ * validated the SAME way an action's template is (safe path under
1381
+ * `templates/`), so the skill-bridge mirrors it identically. */
1382
+ var AgentIngestZ = z.object({
1383
+ kind: z.literal(AGENT_INGEST_KIND),
1384
+ schedule: z.enum(FEED_SCHEDULES),
1385
+ atHour: z.number().int().min(0).max(23).optional(),
1386
+ role: z.string().trim().min(1),
1387
+ 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)")
1388
+ });
1389
+ /** Marks a collection as the destination of the LLM-free Google Calendar
1390
+ * sync (#2095). `map` is collectionField → Google event field, so the user's
1391
+ * collection keeps whatever field names it already uses. */
1392
+ var GoogleCalendarSyncZ = z.object({
1393
+ /** Calendar to pull from; defaults to the user's primary. */
1394
+ calendarId: z.string().trim().min(1).optional(),
1395
+ map: z.record(z.string().trim().min(1), z.enum([
1396
+ "summary",
1397
+ "start",
1398
+ "end",
1399
+ "htmlLink",
1400
+ "colorId",
1401
+ "status",
1402
+ "description",
1403
+ "location"
1404
+ ])).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" }),
1405
+ /** Run the push on the sync schedule too, immediately before each pull, so
1406
+ * local edits reach Google without anyone pressing a button (#2620).
1407
+ *
1408
+ * Opt-in and absent by default: a push writes to a calendar other people may
1409
+ * read, so turning it on is the user's decision, not a default. */
1410
+ autoPush: z.boolean().optional()
1411
+ });
1412
+ /** `ingest` is a discriminated union on `kind`: the three declarative
1413
+ * retrievers fetch-and-map; `agent` dispatches a hidden worker. Optional on
1414
+ * every schema — skill-backed collections usually omit it; only feeds
1415
+ * discovered from `<workspace>/feeds/` are REQUIRED to carry it (gated by
1416
+ * `acceptParsedSchema`). */
1417
+ var IngestZ = z.discriminatedUnion("kind", [DeclarativeIngestZ, AgentIngestZ]);
1418
+ var DynamicIconSourceZ = z.object({
1419
+ collection: z.string().trim().min(1),
1420
+ from: z.enum([
1421
+ "latest",
1422
+ "first",
1423
+ "when"
1424
+ ]).optional(),
1425
+ orderBy: z.string().trim().min(1).optional(),
1426
+ where: WhereZ.optional()
1427
+ });
1428
+ var DynamicIconRuleZ = z.object({
1429
+ where: WhereZ,
1430
+ icon: z.string().trim().min(1)
1431
+ });
1432
+ var DynamicIconSpecZ = z.object({
1433
+ source: DynamicIconSourceZ,
1434
+ rules: z.array(DynamicIconRuleZ),
1435
+ fallback: z.string().trim().min(1).optional()
1436
+ });
1437
+ /** External-data collection: the records ARE the rows of a user-supplied
1438
+ * data file (v1: CSV), queried through DuckDB — never copied into
1439
+ * `<dataDir>/<id>.json` files. Declaring `dataSource` makes the collection
1440
+ * **read-only** in every UI/tool write path; updates happen by replacing /
1441
+ * editing the file itself (file-watch republishes the views). `path` is
1442
+ * workspace-relative and containment-checked exactly like `dataPath`. The
1443
+ * row-id column is the schema's existing `primaryKey` — there is
1444
+ * deliberately no second key concept here.
1445
+ * See plans/done/feat-collection-csv-duckdb-source.md. */
1446
+ var DataSourceZ = z.object({
1447
+ type: z.literal("csv"),
1448
+ path: z.string().min(1)
1449
+ });
1450
+ /** Alternative WRITABLE storage backend for a collection's records —
1451
+ * unlike `dataSource` (external read-only file), a `storage` collection
1452
+ * behaves like a normal writable collection; only where the rows live
1453
+ * changes. The store factory registry (`server/store.ts`) picks the
1454
+ * implementation by `type` (plans/done/refactor-storage-virtualization.md).
1455
+ *
1456
+ * A discriminated union rather than one shape with optional keys, because
1457
+ * only the sqlite variant is a workspace FILE: its `path` is
1458
+ * workspace-relative and containment-checked exactly like `dataPath`, while
1459
+ * the firestore variant has no path to check — its records are not on this
1460
+ * machine at all. Optional keys would let each arm accept the other's, and
1461
+ * the compiler would stop being the thing that tells you which. */
1462
+ var StorageZ = z.discriminatedUnion("type", [z.object({
1463
+ type: z.literal("sqlite"),
1464
+ path: z.string().min(1)
1465
+ }), z.object({ type: z.literal("firestore") }).strict()]);
1466
+ var BareCollectionSchemaZ = z.object({
1467
+ title: z.string().min(1),
1468
+ icon: z.string().min(1),
1469
+ dataPath: z.string().min(1).optional(),
1470
+ dataSource: DataSourceZ.optional(),
1471
+ storage: StorageZ.optional(),
1472
+ primaryKey: z.string().min(1),
1473
+ singleton: z.string().trim().min(1).optional(),
1474
+ fields: z.record(z.string(), FieldSpecZ),
1475
+ actions: z.array(ActionSpecZ).optional(),
1476
+ collectionActions: z.array(ActionSpecZ).optional(),
1477
+ completionField: z.string().trim().min(1).optional(),
1478
+ completionDoneValues: z.array(z.string().trim().min(1)).min(1).optional(),
1479
+ displayField: z.string().trim().min(1).optional(),
1480
+ triggerField: z.string().trim().min(1).optional(),
1481
+ triggerLeadDays: z.number().int().min(0).optional(),
1482
+ spawn: SpawnZ.optional(),
1483
+ calendarField: z.string().trim().min(1).optional(),
1484
+ calendarEndField: z.string().trim().min(1).optional(),
1485
+ calendarTimeField: z.string().trim().min(1).optional(),
1486
+ kanbanField: z.string().trim().min(1).optional(),
1487
+ views: z.array(CustomViewZ).optional(),
1488
+ notifyWhen: WhenZ.optional(),
1489
+ ingest: IngestZ.optional(),
1490
+ googleCalendar: GoogleCalendarSyncZ.optional(),
1491
+ dynamicIcon: DynamicIconSpecZ.optional()
1492
+ }).refine(declaresExactlyOneStore, {
1493
+ message: "declare exactly one of `dataPath` (native JSON records), `dataSource` (external read-only data file), or `storage` (alternative writable backend)",
1494
+ path: ["dataPath"]
1495
+ }).refine(dataSourceDeclaresNoWriteMachinery, {
1496
+ message: "a `dataSource` collection is read-only — it cannot declare `singleton`, `ingest`, `spawn`, or `googleCalendar` (all of them write records)",
1497
+ path: ["dataSource"]
1498
+ }).refine(googleCalendarMapNamesStoredFields, {
1499
+ message: "a `googleCalendar` map key must name a declared, non-computed field, and never the primaryKey (that always holds the Google event id)",
1500
+ path: ["googleCalendar"]
1501
+ }).refine(dataSourceDeclaresNoMutateAction, {
1502
+ message: "a `dataSource` collection is read-only — its actions cannot use `kind: \"mutate\"` (a host write); use `chat`/`agent` actions instead",
1503
+ path: ["dataSource"]
1504
+ }).refine(singletonIsAValidRecordId, {
1505
+ message: "schema `singleton` must be a valid item id (alphanumeric / hyphen / underscore / interior dot, no `..` or path separators)",
1506
+ path: ["singleton"]
1507
+ }).refine(actionIdsAreUnique, {
1508
+ message: "schema `actions` must have unique `id`s",
1509
+ path: ["actions"]
1510
+ }).refine(collectionActionIdsAreUnique, {
1511
+ message: "schema `collectionActions` must have unique `id`s",
1512
+ path: ["collectionActions"]
1513
+ }).refine(mutateSetKeysNameStoredFields, {
1514
+ message: "a mutate action's `set` keys must name declared, non-computed fields (and never the primaryKey)",
1515
+ path: ["actions"]
1516
+ }).refine(mutateParamRefsAreDeclared, {
1517
+ message: "a mutate action's `$params.<name>` references must name keys declared in its `params`",
1518
+ path: ["actions"]
1519
+ }).refine(collectionActionsAreNotMutate, {
1520
+ message: "`collectionActions` cannot contain `kind: \"mutate\"` — a collection-level action has no record to write",
1521
+ path: ["collectionActions"]
1522
+ }).refine(currencyFieldRefsNameCodeFields, {
1523
+ message: "a money field's `currencyField` must name a top-level `string`, `text`, or `enum` field that holds the currency code",
1524
+ path: ["fields"]
1525
+ }).refine(completionPairIsCoherent, {
1526
+ message: "schema `completionField` and `completionDoneValues` must be declared together (both set, or both omitted) — unless `completionField` names a `flag` field, in which case `completionDoneValues` must be omitted (done ⇔ the flag matches)",
1527
+ path: ["completionField"]
1528
+ }).refine(completionFieldIsDeclared, {
1529
+ message: "schema `completionField` must name a top-level field declared in `fields`",
1530
+ path: ["completionField"]
1531
+ }).refine(displayFieldIsDeclared, {
1532
+ message: "schema `displayField` must name a top-level field declared in `fields`",
1533
+ path: ["displayField"]
1534
+ }).refine(fieldVisibilityGatesNameDeclaredFields, {
1535
+ message: "a field's `when.field` must name a top-level field declared in `fields`",
1536
+ path: ["fields"]
1537
+ }).refine(flagConditionsNameDeclaredFields, {
1538
+ message: "a flag field's `where` conditions must name top-level fields declared in `fields` (both `field` and a same-record `valueFrom.field`)",
1539
+ path: ["fields"]
1540
+ }).refine(completionFlagReadsOnlyStoredFields, {
1541
+ message: "a `flag` named by `completionField` may only reference STORED fields in its `where` — completion is evaluated against the raw record (before deriveAll), where computed values (derived/rollup/toggle/flag/embed/backlinks) are absent",
1542
+ path: ["completionField"]
1543
+ }).refine(flagCompletionSpawnDeclaresWhen, {
1544
+ message: "a schema whose `completionField` names a `flag` field must declare an explicit `spawn.when` (the spawn-inert check cannot statically evaluate a flag's `where`)",
1545
+ path: ["spawn"]
1546
+ }).refine(embedIdFieldsNameIdBearingFields, {
1547
+ message: "an embed field's `idField` must name a top-level `ref` or `string` field declared in `fields`",
1548
+ path: ["fields"]
1549
+ }).refine(triggerFieldRequiresCompletion, {
1550
+ message: "schema `triggerField` requires `completionField` / `completionDoneValues` (the gated bell still clears via the done value)",
1551
+ path: ["triggerField"]
1552
+ }).refine(triggerFieldIsADateField, {
1553
+ message: "schema `triggerField` must name a top-level `date` field declared in `fields`",
1554
+ path: ["triggerField"]
1555
+ }).refine(triggerLeadDaysRequiresTriggerField, {
1556
+ message: "schema `triggerLeadDays` requires `triggerField` (it shifts when that field's bell fires)",
1557
+ path: ["triggerLeadDays"]
1558
+ }).refine(spawnRequiresTriggerField, {
1559
+ message: "schema `spawn` requires `triggerField` (the successor's trigger date is `triggerField` advanced by `spawn.every`)",
1560
+ path: ["spawn"]
1561
+ }).refine(spawnWhenFieldIsDeclared, {
1562
+ message: "schema `spawn.when.field` must name a top-level field declared in `fields`",
1563
+ path: ["spawn"]
1564
+ }).refine(spawnCarryEntriesAreDeclared, {
1565
+ message: "every `spawn.carry` entry must name a top-level field declared in `fields`",
1566
+ path: ["spawn"]
1567
+ }).refine(spawnSuccessorStartsInert, {
1568
+ message: "`spawn` must leave the successor in a non-matching state (e.g. `set` the status to a pending value); seeding the predicate field to a matching value via `set`/`carry` would respawn forever",
1569
+ path: ["spawn"]
1570
+ }).refine(fieldDrivenFromFieldIsEnum, {
1571
+ message: "`spawn.every.fromField` must name a top-level `enum` field declared in `fields`",
1572
+ path: ["spawn"]
1573
+ }).refine(fieldDrivenMapCoversValues, {
1574
+ message: "`spawn.every.map` keys must exactly cover the `values` of the `enum` named by `fromField` (no missing or extra keys)",
1575
+ path: ["spawn"]
1576
+ }).refine(fieldDrivenFromFieldCarried, {
1577
+ message: "`spawn.every.fromField` must appear in `spawn.carry`, or be written by `spawn.set` to a value present in `spawn.every.map`, so the successor keeps a resolvable recurrence interval",
1578
+ path: ["spawn"]
1579
+ }).refine(calendarFieldIsDateLike, {
1580
+ message: "schema `calendarField` must name a top-level `date` or `datetime` field declared in `fields`",
1581
+ path: ["calendarField"]
1582
+ }).refine(calendarEndFieldRequiresCalendarField, {
1583
+ message: "schema `calendarEndField` requires `calendarField` (it marks the end of the span that starts at `calendarField`)",
1584
+ path: ["calendarEndField"]
1585
+ }).refine(calendarEndFieldIsDateLike, {
1586
+ message: "schema `calendarEndField` must name a top-level `date` or `datetime` field declared in `fields`",
1587
+ path: ["calendarEndField"]
1588
+ }).refine(calendarTimeFieldRequiresCalendarField, {
1589
+ message: "schema `calendarTimeField` requires `calendarField` (it supplies the time-of-day for the calendar's day view)",
1590
+ path: ["calendarTimeField"]
1591
+ }).refine(calendarTimeFieldIsDeclared, {
1592
+ message: "schema `calendarTimeField` must name a top-level field declared in `fields`",
1593
+ path: ["calendarTimeField"]
1594
+ }).refine(calendarTimeFieldIsStringBacked, {
1595
+ message: "schema `calendarTimeField` must name a top-level `string` or `text` field declared in `fields`",
1596
+ path: ["calendarTimeField"]
1597
+ }).refine(kanbanFieldIsAnEnum, {
1598
+ message: "schema `kanbanField` must name a top-level `enum` field declared in `fields`",
1599
+ path: ["kanbanField"]
1600
+ }).refine(togglesProjectValidEnums, {
1601
+ message: "a `toggle` field's `field` must name a top-level `enum` field, and its `onValue`/`offValue` must be values of that enum",
1602
+ path: ["fields"]
1603
+ }).refine(notifyWhenRequiresCompletion, {
1604
+ message: "schema `notifyWhen` requires `completionField` (it narrows that bell)",
1605
+ path: ["notifyWhen"]
1606
+ }).refine(notifyWhenFieldIsDeclared, {
1607
+ message: "schema `notifyWhen.field` must name a top-level field declared in `fields`",
1608
+ path: ["notifyWhen"]
1609
+ }).refine(viewIdsAreSlugs, {
1610
+ message: "every `views[].id` must be a valid slug (alphanumeric / hyphen / underscore, no path separators)",
1611
+ path: ["views"]
1612
+ }).refine(viewIdsAreUnique, {
1613
+ message: "schema `views` must have unique `id`s",
1614
+ path: ["views"]
1615
+ });
1616
+ var PROTOTYPE_KEYS = [
1617
+ "__proto__",
1618
+ "constructor",
1619
+ "prototype"
1620
+ ];
1621
+ /** The first own prototype-sensitive key of `value`, or null. */
1622
+ function ownPrototypeKey(value) {
1623
+ if (value === null || typeof value !== "object") return null;
1624
+ for (const key of PROTOTYPE_KEYS) if (Object.hasOwn(value, key)) return key;
1625
+ return null;
1626
+ }
1627
+ /** Own enumerable entries of an object (arrays keyed by index), none for
1628
+ * anything else — the raw input is unvalidated, so `fields` may be junk. */
1629
+ function ownEntries(value) {
1630
+ if (isUnknownArray(value)) return value.map((entry, index) => [String(index), entry]);
1631
+ return isRecord(value) ? Object.entries(value) : [];
1632
+ }
1633
+ /** The name-defining sub-record a raw field spec (`of`) or action (`params`)
1634
+ * carries, or undefined when the holder isn't an object at all. */
1635
+ function nameDefiningSubRecord(holder, key) {
1636
+ return isRecord(holder) ? holder[key] : void 0;
1637
+ }
1638
+ /** Dotted path of the first prototype-sensitive `params` name across both
1639
+ * action lists, or null. */
1640
+ function prototypeActionParamPath(input) {
1641
+ for (const [listName, list] of [["actions", input.actions], ["collectionActions", input.collectionActions]]) for (const action of isUnknownArray(list) ? list : []) {
1642
+ const badParam = ownPrototypeKey(nameDefiningSubRecord(action, "params"));
1643
+ if (badParam !== null) return `${listName}.params.${badParam}`;
1644
+ }
1645
+ return null;
1646
+ }
1647
+ /** Dotted path of the first prototype-sensitive field name in the raw
1648
+ * schema input — top-level `fields`, each table field's `of`, and each
1649
+ * action's `params` (the three records that DEFINE names) — or null. */
1650
+ function prototypeFieldKeyPath(input) {
1651
+ if (!isRecord(input)) return null;
1652
+ const bad = ownPrototypeKey(input.fields);
1653
+ if (bad !== null) return `fields.${bad}`;
1654
+ for (const [key, spec] of ownEntries(input.fields)) {
1655
+ const badSub = ownPrototypeKey(nameDefiningSubRecord(spec, "of"));
1656
+ if (badSub !== null) return `fields.${key}.of.${badSub}`;
1657
+ }
1658
+ return prototypeActionParamPath(input);
1659
+ }
1660
+ var CollectionSchemaZ = z.preprocess((input, ctx) => {
1661
+ const bad = prototypeFieldKeyPath(input);
1662
+ if (bad !== null) {
1663
+ ctx.addIssue({
1664
+ code: "custom",
1665
+ message: `'${bad}': field names must not be prototype-sensitive keys (\`__proto__\`, \`constructor\`, \`prototype\`)`
1666
+ });
1667
+ return z.NEVER;
1668
+ }
1669
+ return input;
1670
+ }, BareCollectionSchemaZ);
1671
+ //#endregion
1672
+ //#region src/collection/server/discovery.ts
1673
+ function applyFeedSchemaDefaults(parsed, slug) {
1674
+ if (!isRecord(parsed)) return parsed;
1675
+ const icon = typeof parsed.icon === "string" && parsed.icon.trim().length > 0 ? parsed.icon : "dynamic_feed";
1676
+ return {
1677
+ ...parsed,
1678
+ icon,
1679
+ dataPath: `data/feeds/${slug}`
1680
+ };
1681
+ }
1682
+ /** The conventional per-slug records dir a `dataSource` / `storage` collection
1683
+ * gets as its `dataDir` (records never live there, but archive/delete paths
1684
+ * stay well-defined — same shape the registry's R3 normalization uses).
1685
+ *
1686
+ * INVARIANT — this is NOT a default `dataPath`, and must not be used as one.
1687
+ * It applies only to the two backends whose records are not per-file JSON. A
1688
+ * normal collection declares its own location and exactly one of `dataPath` /
1689
+ * `dataSource` / `storage`; a schema with none of the three is REJECTED, not
1690
+ * quietly pointed here. Handing a per-file collection this path would silently
1691
+ * relocate its records away from the folder the user (and its SKILL.md) sees. */
1692
+ function conventionalDataPath(slug) {
1693
+ return `data/collections/${slug}/items`;
1694
+ }
1695
+ /** The declared field named by `primaryKey`, or `undefined` when the schema
1696
+ * declares no such field. Own-property guarded: a `primaryKey` of `toString`
1697
+ * / `constructor` / `__proto__` must miss here, not read an Object.prototype
1698
+ * member and slip past the "is it a declared field?" gate into the wrong
1699
+ * "add `primary: true`" advice. Shared with manageCollection's putSchema
1700
+ * gate so both report the SAME reason. */
1701
+ function resolvePrimaryField(fields, primaryKey) {
1702
+ return Object.hasOwn(fields, primaryKey) ? fields[primaryKey] : void 0;
1703
+ }
1704
+ /** The acceptance gates discovery applies AFTER `CollectionSchemaZ` parses,
1705
+ * before a schema becomes a live collection:
1706
+ *
1707
+ * - the `primaryKey` must be a declared field flagged `primary: true` —
1708
+ * without the flag CollectionView renders the field editable, and a
1709
+ * rename is silently pinned back to the URL itemId on save, so the user's
1710
+ * edit is dropped with no error;
1711
+ * - a `feed` schema must declare an `ingest` block (else it's a dead,
1712
+ * non-refreshable card);
1713
+ * - `dataPath` — or a `dataSource`'s `path` — must resolve INSIDE the
1714
+ * workspace (same realpath containment for both).
1715
+ *
1716
+ * Exported so `manageCollection`'s `putSchema` can run the SAME gates before
1717
+ * it reports success — a schema that passes `CollectionSchemaZ` but fails one
1718
+ * of these would otherwise write cleanly yet be skipped on the next discovery,
1719
+ * hiding the collection (the exact failure that tool exists to prevent). */
1720
+ function acceptParsedSchema(schema, opts) {
1721
+ const primaryField = resolvePrimaryField(schema.fields, schema.primaryKey);
1722
+ if (!primaryField) return {
1723
+ ok: false,
1724
+ reason: `primaryKey '${schema.primaryKey}' is not one of the declared fields`
1725
+ };
1726
+ if (primaryField.primary !== true) return {
1727
+ ok: false,
1728
+ reason: `the primaryKey field '${schema.primaryKey}' must be flagged \`primary: true\``
1729
+ };
1730
+ if (opts.source === "feed" && !schema.ingest) return {
1731
+ ok: false,
1732
+ reason: "a feed schema must declare an `ingest` block"
1733
+ };
1734
+ if (schema.dataSource !== void 0) {
1735
+ const dataSourceFile = resolveDataDir(schema.dataSource.path, opts.workspaceRoot);
1736
+ if (dataSourceFile === null) return {
1737
+ ok: false,
1738
+ reason: `dataSource.path '${schema.dataSource.path}' escapes the workspace`
1739
+ };
1740
+ const dataDir = resolveDataDir(conventionalDataPath(opts.slug), opts.workspaceRoot);
1741
+ if (dataDir === null) return {
1742
+ ok: false,
1743
+ reason: `slug '${opts.slug}' yields no workspace-contained data dir`
1744
+ };
1745
+ return {
1746
+ ok: true,
1747
+ dataDir,
1748
+ dataSourceFile
1749
+ };
1750
+ }
1751
+ if (schema.storage !== void 0) return acceptStorageSchema(schema.storage, opts);
1752
+ const dataDir = resolveDataDir(schema.dataPath ?? "", opts.workspaceRoot);
1753
+ if (dataDir === null) return {
1754
+ ok: false,
1755
+ reason: `dataPath '${schema.dataPath}' escapes the workspace`
1756
+ };
1757
+ return {
1758
+ ok: true,
1759
+ dataDir
1760
+ };
1761
+ }
1762
+ /** The `storage` arm of the acceptance gate. Every storage backend gets the
1763
+ * conventional phantom dataDir; what differs is what else has to resolve
1764
+ * before the collection can exist at all.
1765
+ *
1766
+ * A FILE-backed backend (sqlite) resolves and containment-checks a
1767
+ * `storageFile`. A SHARED one (firestore) has no path on this machine — it
1768
+ * resolves an IDENTITY instead: the `aid` from the repository's `app.json`,
1769
+ * which together with the slug as `cid` names `apps/{aid}/collections/{cid}`.
1770
+ *
1771
+ * Resolving it HERE, once, is the point. The store then receives a settled
1772
+ * `(aid, cid)` and never reads `app.json` itself — otherwise the questions of
1773
+ * caching, staleness and what to do when the file is missing would be decided
1774
+ * inside a read path, where the only cheap answer is to return nothing, and
1775
+ * "this collection is misconfigured" would reach the user as "this collection
1776
+ * is empty". A missing or malformed `app.json` is a CONFIGURATION error, so it
1777
+ * is reported the same way an escaping `storage.path` is: the schema is
1778
+ * refused, with a reason naming the file to create. */
1779
+ function acceptStorageSchema(storage, opts) {
1780
+ const dataDir = resolveDataDir(conventionalDataPath(opts.slug), opts.workspaceRoot);
1781
+ if (dataDir === null) return {
1782
+ ok: false,
1783
+ reason: `slug '${opts.slug}' yields no workspace-contained data dir`
1784
+ };
1785
+ if (storage.type === "sqlite") {
1786
+ const storageFile = resolveDataDir(storage.path, opts.workspaceRoot);
1787
+ if (storageFile === null) return {
1788
+ ok: false,
1789
+ reason: `storage.path '${storage.path}' escapes the workspace`
1790
+ };
1791
+ return {
1792
+ ok: true,
1793
+ dataDir,
1794
+ storageFile
1795
+ };
1796
+ }
1797
+ const manifest = loadAppManifest(opts.workspaceRoot);
1798
+ if (!manifest.ok) return {
1799
+ ok: false,
1800
+ reason: appManifestReason(manifest, opts.workspaceRoot)
1801
+ };
1802
+ return {
1803
+ ok: true,
1804
+ dataDir,
1805
+ appId: manifest.manifest.aid
1806
+ };
1807
+ }
1808
+ async function loadOneCollection(skillsRoot, slug, source, workspaceRoot) {
1809
+ const safeName = safeSlugName(slug);
1810
+ if (safeName === null) return null;
1811
+ const schemaPath = path.join(skillsRoot, safeName, SCHEMA_FILE);
1812
+ let raw;
1813
+ try {
1814
+ if (!(await stat(schemaPath)).isFile()) return null;
1815
+ raw = await readFile(schemaPath, "utf-8");
1816
+ } catch (err) {
1817
+ if (!isErrorWithCode(err) || err.code !== "ENOENT") log.warn("collections", "failed to read schema.json, skipping", {
1818
+ slug: safeName,
1819
+ path: schemaPath,
1820
+ error: String(err)
1821
+ });
1822
+ return null;
1823
+ }
1824
+ let parsedJson;
1825
+ try {
1826
+ parsedJson = JSON.parse(raw);
1827
+ } catch (err) {
1828
+ log.warn("collections", "schema.json is not valid JSON, skipping", {
1829
+ slug: safeName,
1830
+ error: String(err)
1831
+ });
1832
+ return null;
1833
+ }
1834
+ const candidate = source === "feed" ? applyFeedSchemaDefaults(parsedJson, safeName) : parsedJson;
1835
+ const parsed = CollectionSchemaZ.safeParse(candidate);
1836
+ if (!parsed.success) {
1837
+ log.warn("collections", "schema.json failed validation, skipping", {
1838
+ slug: safeName,
1839
+ issues: parsed.error.issues
1840
+ });
1841
+ return null;
1842
+ }
1843
+ const schema = parsed.data;
1844
+ const acceptance = acceptParsedSchema(schema, {
1845
+ source,
1846
+ workspaceRoot,
1847
+ slug: safeName
1848
+ });
1849
+ if (!acceptance.ok) {
1850
+ log.warn("collections", "schema.json rejected after validation, skipping", {
1851
+ slug: safeName,
1852
+ reason: acceptance.reason
1853
+ });
1854
+ return null;
1855
+ }
1856
+ return {
1857
+ slug: safeName,
1858
+ source,
1859
+ schema,
1860
+ dataDir: acceptance.dataDir,
1861
+ ...acceptance.dataSourceFile !== void 0 ? { dataSourceFile: acceptance.dataSourceFile } : {},
1862
+ ...acceptance.storageFile !== void 0 ? { storageFile: acceptance.storageFile } : {},
1863
+ ...acceptance.appId !== void 0 ? { appId: acceptance.appId } : {},
1864
+ skillDir: path.join(skillsRoot, safeName)
1865
+ };
1866
+ }
1867
+ async function collectFromDir(skillsRoot, source, workspaceRoot) {
1868
+ let entries;
1869
+ try {
1870
+ entries = await readdir(skillsRoot);
1871
+ } catch (err) {
1872
+ if (isErrorWithCode(err) && err.code === "ENOENT") return [];
1873
+ log.warn("collections", "failed to list skills dir, returning empty", {
1874
+ root: skillsRoot,
1875
+ error: String(err)
1876
+ });
1877
+ return [];
1878
+ }
1879
+ const results = [];
1880
+ for (const name of entries) {
1881
+ if (name.startsWith(".")) continue;
1882
+ const safeName = safeSlugName(name);
1883
+ if (safeName === null) continue;
1884
+ const dirPath = path.join(skillsRoot, safeName);
1885
+ let dirStat;
1886
+ try {
1887
+ dirStat = await stat(dirPath);
1888
+ } catch {
1889
+ continue;
1890
+ }
1891
+ if (!dirStat.isDirectory()) continue;
1892
+ const collection = await loadOneCollection(skillsRoot, safeName, source, workspaceRoot);
1893
+ if (collection) results.push(collection);
1894
+ }
1895
+ return results;
1896
+ }
1897
+ /** The user-scope dir this call should scan, or `null` for none. The single
1898
+ * place the "explicit override beats the host binding, and either may say
1899
+ * none" rule is spelled — `??` cannot express it, because `undefined` there
1900
+ * means "ask the host" and would silently re-enable a scope the caller
1901
+ * passed `null` to switch off. */
1902
+ function resolveUserDir(opts, workspaceRoot) {
1903
+ return opts.userSkillsDir !== void 0 ? opts.userSkillsDir : userSkillsDir(workspaceRoot);
1904
+ }
1905
+ /** Discover every schema-driven collection available to this
1906
+ * workspace. Project-scope collections override user-scope on slug
1907
+ * collision. The `workspaceRoot` override also flows into each
1908
+ * collection's dataDir resolution so a tmpdir-scoped test gets
1909
+ * dataDirs under the same tmpdir (Codex P1 review on PR #1489 —
1910
+ * previously dataDir was always rooted at the live workspacePath
1911
+ * regardless of override). */
1912
+ async function discoverCollections(opts = {}) {
1913
+ const workspaceRoot = opts.workspaceRoot ?? getWorkspaceRoot();
1914
+ const userDir = resolveUserDir(opts, workspaceRoot);
1915
+ const projectDir = projectSkillsDir(workspaceRoot);
1916
+ const feedCollections = await collectFromDir(feedsRoot(workspaceRoot), "feed", workspaceRoot);
1917
+ const userCollections = userDir === null ? [] : await collectFromDir(userDir, "user", workspaceRoot);
1918
+ const projectCollections = await collectFromDir(projectDir, "project", workspaceRoot);
1919
+ const merged = /* @__PURE__ */ new Map();
1920
+ for (const entry of feedCollections) merged.set(entry.slug, entry);
1921
+ for (const entry of userCollections) merged.set(entry.slug, entry);
1922
+ for (const entry of projectCollections) merged.set(entry.slug, entry);
1923
+ return [...merged.values()].sort((left, right) => left.slug.localeCompare(right.slug));
1924
+ }
1925
+ /** Load one collection by slug. Returns null if the slug is invalid,
1926
+ * no matching skill exists, or the schema is malformed. */
1927
+ async function loadCollection(slug, opts = {}) {
1928
+ const safeName = safeSlugName(slug);
1929
+ if (safeName === null) return null;
1930
+ const workspaceRoot = opts.workspaceRoot ?? getWorkspaceRoot();
1931
+ const userDir = resolveUserDir(opts, workspaceRoot);
1932
+ const projectCollection = await loadOneCollection(projectSkillsDir(workspaceRoot), safeName, "project", workspaceRoot);
1933
+ if (projectCollection) return projectCollection;
1934
+ const userCollection = userDir === null ? null : await loadOneCollection(userDir, safeName, "user", workspaceRoot);
1935
+ if (userCollection) return userCollection;
1936
+ return loadOneCollection(feedsRoot(workspaceRoot), safeName, "feed", workspaceRoot);
1937
+ }
1938
+ function toSummary(collection) {
1939
+ return {
1940
+ slug: collection.slug,
1941
+ title: collection.schema.title,
1942
+ icon: collection.schema.icon,
1943
+ source: collection.source,
1944
+ ...collection.schema.dataSource !== void 0 ? { readonly: true } : {},
1945
+ ...collection.appId !== void 0 ? { appId: collection.appId } : {}
1946
+ };
1947
+ }
1948
+ function toDetail(collection) {
1949
+ return {
1950
+ ...toSummary(collection),
1951
+ schema: collection.schema
1952
+ };
1953
+ }
1954
+ //#endregion
1955
+ //#region src/collection/server/io.ts
1956
+ /** True iff `filePath` exists and is a regular file (NOT a symlink).
1957
+ * Defends `listItems` / `readItem` against `*.json` symlinks placed
1958
+ * inside an otherwise-contained data dir — without this, a record
1959
+ * file could symlink to /etc/passwd and the detail endpoint would
1960
+ * happily serve it. Returns false on ENOENT and on any other lstat
1961
+ * failure so the caller's "missing" branch covers those cases too.
1962
+ * Exported so `ontology.ts`'s record COUNT classifies entries with the
1963
+ * SAME lstat logic — the two must agree on what a record file is. */
1964
+ async function isRegularFile(filePath) {
1965
+ try {
1966
+ return (await lstat(filePath)).isFile();
1967
+ } catch {
1968
+ return false;
1969
+ }
1970
+ }
1971
+ /** Read one JSON record file. Returns null when the file is missing,
1972
+ * is a symlink (file-disclosure defense), parses to a non-object,
1973
+ * or has a read/parse error. Caller logs the per-entry skip — this
1974
+ * helper just classifies. Split out to keep `listItems` under the
1975
+ * `sonarjs/cognitive-complexity` threshold. */
1976
+ /** Parse a record file's text into a plain-object `CollectionItem`, or
1977
+ * null when it isn't a JSON object (array / scalar / null). */
1978
+ function parseRecordJson(raw) {
1979
+ const parsed = JSON.parse(raw);
1980
+ return isRecord(parsed) ? parsed : null;
1981
+ }
1982
+ async function tryReadRecord(filePath) {
1983
+ if (!await isRegularFile(filePath)) return null;
1984
+ try {
1985
+ return parseRecordJson(await readFile(filePath, "utf-8"));
1986
+ } catch {
1987
+ return null;
1988
+ }
1989
+ }
1990
+ /** Read every record under `dataDir`. Returns [] if the dir doesn't
1991
+ * exist yet (legitimate first-use state). Malformed JSON files and
1992
+ * symlinked records are skipped (the latter is a file-disclosure
1993
+ * defense — see `isRegularFile`). Re-validates the realpath
1994
+ * containment to defend against a symlinked data dir appearing
1995
+ * between discovery and use. */
1996
+ async function listItems(dataDir, opts = {}) {
1997
+ if (!isContainedInRoot(dataDir, opts.workspaceRoot ?? getWorkspaceRoot())) {
1998
+ log.warn("collections", "listItems refused: dataDir escapes workspace via symlink", { dataDir });
1999
+ return [];
2000
+ }
2001
+ let entries;
2002
+ try {
2003
+ entries = await readdir(dataDir);
2004
+ } catch (err) {
2005
+ if (isErrorWithCode(err) && err.code === "ENOENT") return [];
2006
+ throw err;
2007
+ }
2008
+ const results = [];
2009
+ for (const name of entries) {
2010
+ if (!name.endsWith(".json")) continue;
2011
+ if (name.startsWith(".")) continue;
2012
+ const filePath = path.join(dataDir, name);
2013
+ const record = await tryReadRecord(filePath);
2014
+ if (record === null) {
2015
+ log.warn("collections", "skipping record (missing, symlink, or unreadable)", { path: filePath });
2016
+ continue;
2017
+ }
2018
+ results.push(record);
2019
+ }
2020
+ return results;
2021
+ }
2022
+ /** Read one record by id. Returns null when the file is missing,
2023
+ * when the resolved path escapes the workspace via a symlink, or
2024
+ * when the record file itself is a symlink (file-disclosure
2025
+ * defense — see `isRegularFile`). */
2026
+ async function readItem(dataDir, itemId, opts = {}) {
2027
+ const safeId = safeRecordId(itemId);
2028
+ if (safeId === null) return null;
2029
+ if (!isContainedInRoot(dataDir, opts.workspaceRoot ?? getWorkspaceRoot())) return null;
2030
+ const filePath = itemFilePath(dataDir, safeId);
2031
+ if (!await isRegularFile(filePath)) return null;
2032
+ try {
2033
+ return parseRecordJson(await readFile(filePath, "utf-8"));
2034
+ } catch (err) {
2035
+ if (isErrorWithCode(err) && err.code === "ENOENT") return null;
2036
+ throw err;
2037
+ }
2038
+ }
2039
+ /** The symlink-containment refusal every record path shares: one check, one
2040
+ * warn, one answer. Extracted because this is a security RULE applied at
2041
+ * three sites (write pre-mkdir, write post-mkdir, delete) — a fix to the
2042
+ * check must not be able to land at only one of them.
2043
+ *
2044
+ * `stage` names the call site so the warn stays as diagnosable as the three
2045
+ * hand-written copies were.
2046
+ *
2047
+ * Scope, stated explicitly because a reviewer asks every time: this catches
2048
+ * a symlink that EXISTS when we look — `isContainedInRoot` realpaths the
2049
+ * closest existing ancestor, so a pre-planted escape is refused. It does not
2050
+ * and cannot close the check-then-use race, where an ancestor is swapped for
2051
+ * a symlink between this call and the `mkdir` / `open` / `unlink` that
2052
+ * follows. Closing that needs directory-handle I/O anchored at the workspace
2053
+ * (`openat` + `O_NOFOLLOW`), which `node:fs` does not expose — it would mean
2054
+ * a different I/O layer, not a tighter check here.
2055
+ *
2056
+ * That race is deliberately outside this app's threat model: the process is
2057
+ * loopback-bound and bearer-authed, so anyone able to swap directories inside
2058
+ * the workspace is already the workspace owner — the same trust principal the
2059
+ * writes belong to. Revisit if collections ever serve a lower-trust caller. */
2060
+ function escapesWorkspace(dataDir, workspaceRoot, itemId, stage) {
2061
+ if (isContainedInRoot(dataDir, workspaceRoot)) return false;
2062
+ log.warn("collections", `${stage} refused: dataDir escapes workspace via symlink`, {
2063
+ dataDir,
2064
+ itemId
2065
+ });
2066
+ return true;
2067
+ }
2068
+ /** Write a record. Ensures the directory exists, validates the id,
2069
+ * re-checks symlink containment after mkdir, and writes atomically.
2070
+ *
2071
+ * Create path (`refuseOverwrite: true`) uses an O_EXCL `wx` open
2072
+ * rather than `stat` + `writeFileAtomic` to close a check-then-write
2073
+ * race: two concurrent POSTs would otherwise both pass the existence
2074
+ * check and one would silently overwrite the other. The trade-off
2075
+ * is that the create path is not crash-atomic (a partial file could
2076
+ * remain if the process dies mid-write); acceptable here because
2077
+ * records are small JSON blobs and the next read either parses or
2078
+ * is skipped via the "malformed JSON" branch in `listItems`.
2079
+ *
2080
+ * Update path (`refuseOverwrite: false`) uses `writeFileAtomic` so
2081
+ * PUT remains crash-atomic. No race there — the URL pins the id. */
2082
+ async function writeItem(dataDir, itemId, item, opts = {}) {
2083
+ const safeId = safeRecordId(itemId);
2084
+ if (safeId === null) return {
2085
+ kind: "invalid-id",
2086
+ itemId
2087
+ };
2088
+ const workspaceRoot = opts.workspaceRoot ?? getWorkspaceRoot();
2089
+ if (escapesWorkspace(dataDir, workspaceRoot, safeId, "writeItem (pre-mkdir)")) return {
2090
+ kind: "path-escape",
2091
+ itemId: safeId
2092
+ };
2093
+ await mkdir(dataDir, { recursive: true });
2094
+ if (escapesWorkspace(dataDir, workspaceRoot, safeId, "writeItem (post-mkdir)")) return {
2095
+ kind: "path-escape",
2096
+ itemId: safeId
2097
+ };
2098
+ const filePath = itemFilePath(dataDir, safeId);
2099
+ const payload = `${JSON.stringify(item, null, 2)}\n`;
2100
+ if (opts.refuseOverwrite) {
2101
+ let handle;
2102
+ try {
2103
+ handle = await open(filePath, "wx");
2104
+ } catch (err) {
2105
+ if (isErrorWithCode(err) && err.code === "EEXIST") return {
2106
+ kind: "conflict",
2107
+ itemId: safeId
2108
+ };
2109
+ throw err;
2110
+ }
2111
+ try {
2112
+ await handle.writeFile(payload);
2113
+ } finally {
2114
+ await handle.close();
2115
+ }
2116
+ } else await writeFileAtomic(filePath, payload);
2117
+ if (opts.slug) publishCollectionChange(collectionChangePayload({
2118
+ slug: opts.slug,
2119
+ ids: [safeId],
2120
+ op: "upsert"
2121
+ }, opts.workspaceRoot));
2122
+ return {
2123
+ kind: "ok",
2124
+ itemId: safeId,
2125
+ item
2126
+ };
2127
+ }
2128
+ async function deleteItem(dataDir, itemId, opts = {}) {
2129
+ const safeId = safeRecordId(itemId);
2130
+ if (safeId === null) return {
2131
+ kind: "invalid-id",
2132
+ itemId
2133
+ };
2134
+ if (escapesWorkspace(dataDir, opts.workspaceRoot ?? getWorkspaceRoot(), safeId, "deleteItem")) return {
2135
+ kind: "path-escape",
2136
+ itemId: safeId
2137
+ };
2138
+ const filePath = itemFilePath(dataDir, safeId);
2139
+ try {
2140
+ await unlink(filePath);
2141
+ if (opts.slug) publishCollectionChange(collectionChangePayload({
2142
+ slug: opts.slug,
2143
+ ids: [safeId],
2144
+ op: "delete"
2145
+ }, opts.workspaceRoot));
2146
+ return {
2147
+ kind: "ok",
2148
+ itemId: safeId
2149
+ };
2150
+ } catch (err) {
2151
+ if (isErrorWithCode(err) && err.code === "ENOENT") return {
2152
+ kind: "not-found",
2153
+ itemId: safeId
2154
+ };
2155
+ throw err;
2156
+ }
2157
+ }
2158
+ /** Generate a short random hex id. Used by POST when the form doesn't
2159
+ * carry a primary-key value (UI shortcut — Claude normally derives a
2160
+ * semantic id from the record's name). */
2161
+ function generateItemId() {
2162
+ return randomBytes(4).toString("hex");
2163
+ }
2164
+ /** The item id a CREATE should use for `schema`, or null when the
2165
+ * caller should generate one. A singleton collection pins every
2166
+ * create to its fixed `schema.singleton` id, so the "at most one
2167
+ * record" contract is enforced server-side (a second create targets
2168
+ * the same file and hits `writeItem`'s refuseOverwrite conflict) —
2169
+ * not only in the UI. Otherwise the record's own primaryKey value
2170
+ * wins, falling back to a generated id (null = "generate"). */
2171
+ function resolveCreateItemId(schema, record) {
2172
+ if (schema.singleton) return schema.singleton;
2173
+ const primaryRaw = record[schema.primaryKey];
2174
+ return typeof primaryRaw === "string" && primaryRaw.length > 0 ? primaryRaw : null;
2175
+ }
2176
+ //#endregion
2177
+ //#region src/collection/core/queryZ.ts
2178
+ /** Result-column aliases double as SQL identifiers and JSON keys — keep
2179
+ * them to a conservative identifier charset so neither side needs
2180
+ * escaping gymnastics. */
2181
+ var SAFE_ALIAS_PATTERN = /^[A-Za-z_]\w{0,63}$/;
2182
+ /** Hard ceiling on returned rows; `limit` clamps below it. A group-by on
2183
+ * a near-unique column would otherwise return one row per source row —
2184
+ * the exact materialization the aggregate path exists to avoid. */
2185
+ var MAX_QUERY_ROWS = 1e4;
2186
+ /** Default row cap when the query declares no `limit`. */
2187
+ var DEFAULT_QUERY_ROWS = 1e3;
2188
+ /** One aggregate column: `count` (rows; `column` optional to count
2189
+ * non-null cells) or `sum`/`avg`/`min`/`max` over a named CSV column. */
2190
+ var QueryAggregateZ = z.object({
2191
+ op: z.enum([
2192
+ "count",
2193
+ "sum",
2194
+ "avg",
2195
+ "min",
2196
+ "max"
2197
+ ]),
2198
+ column: z.string().min(1).optional()
2199
+ }).refine((aggregate) => aggregate.op === "count" || aggregate.column !== void 0, {
2200
+ message: "`column` is required for every aggregate op except `count`",
2201
+ path: ["column"]
2339
2202
  });
2340
- /** External-data collection: the records ARE the rows of a user-supplied
2341
- * data file (v1: CSV), queried through DuckDB — never copied into
2342
- * `<dataDir>/<id>.json` files. Declaring `dataSource` makes the collection
2343
- * **read-only** in every UI/tool write path; updates happen by replacing /
2344
- * editing the file itself (file-watch republishes the views). `path` is
2345
- * workspace-relative and containment-checked exactly like `dataPath`. The
2346
- * row-id column is the schema's existing `primaryKey` — there is
2347
- * deliberately no second key concept here.
2348
- * See plans/done/feat-collection-csv-duckdb-source.md. */
2349
- var DataSourceZ = z.object({
2350
- type: z.literal("csv"),
2351
- path: z.string().min(1)
2203
+ /** One filter condition. Same op vocabulary as the schema-level `where`
2204
+ * (`core/where.ts`) so authors learn one set; values may be typed
2205
+ * (number / boolean) since CSV columns are. `in` requires an array
2206
+ * value, every other op a scalar. */
2207
+ var QueryWhereZ = z.object({
2208
+ field: z.string().min(1),
2209
+ op: z.enum([
2210
+ "eq",
2211
+ "ne",
2212
+ "in",
2213
+ "gt",
2214
+ "gte",
2215
+ "lt",
2216
+ "lte",
2217
+ "contains"
2218
+ ]),
2219
+ value: z.union([
2220
+ z.string(),
2221
+ z.number(),
2222
+ z.boolean(),
2223
+ z.array(z.union([
2224
+ z.string(),
2225
+ z.number(),
2226
+ z.boolean()
2227
+ ])).min(1).max(100)
2228
+ ])
2229
+ }).refine((cond) => cond.op === "in" === Array.isArray(cond.value), {
2230
+ message: "`in` requires an array value (the allowed set); every other op requires a scalar value",
2231
+ path: ["value"]
2352
2232
  });
2353
- /** Alternative WRITABLE storage backend for a collection's records —
2354
- * unlike `dataSource` (external read-only file), a `storage` collection
2355
- * behaves like a normal writable collection; only where the rows live
2356
- * changes. v1: `sqlite` — records in a single SQLite database file
2357
- * (`node:sqlite`, one JSON record per row keyed by the primaryKey).
2358
- * `path` is workspace-relative and containment-checked exactly like
2359
- * `dataPath`. The store factory registry (`server/store.ts`) picks the
2360
- * implementation by `type` (plans/done/refactor-storage-virtualization.md). */
2361
- var StorageZ = z.object({
2362
- type: z.literal("sqlite"),
2363
- path: z.string().min(1)
2233
+ var QueryOrderZ = z.object({
2234
+ /** A `groupBy` column or an aggregate alias — membership enforced by
2235
+ * the whole-query refine below. */
2236
+ field: z.string().min(1),
2237
+ dir: z.enum(["asc", "desc"]).optional()
2364
2238
  });
2365
- var BareCollectionSchemaZ = z.object({
2366
- title: z.string().min(1),
2367
- icon: z.string().min(1),
2368
- dataPath: z.string().min(1).optional(),
2369
- dataSource: DataSourceZ.optional(),
2370
- storage: StorageZ.optional(),
2371
- primaryKey: z.string().min(1),
2372
- singleton: z.string().trim().min(1).optional(),
2373
- fields: z.record(z.string(), FieldSpecZ),
2374
- actions: z.array(ActionSpecZ).optional(),
2375
- collectionActions: z.array(ActionSpecZ).optional(),
2376
- completionField: z.string().trim().min(1).optional(),
2377
- completionDoneValues: z.array(z.string().trim().min(1)).min(1).optional(),
2378
- displayField: z.string().trim().min(1).optional(),
2379
- triggerField: z.string().trim().min(1).optional(),
2380
- triggerLeadDays: z.number().int().min(0).optional(),
2381
- spawn: SpawnZ.optional(),
2382
- calendarField: z.string().trim().min(1).optional(),
2383
- calendarEndField: z.string().trim().min(1).optional(),
2384
- calendarTimeField: z.string().trim().min(1).optional(),
2385
- kanbanField: z.string().trim().min(1).optional(),
2386
- views: z.array(CustomViewZ).optional(),
2387
- notifyWhen: WhenZ.optional(),
2388
- ingest: IngestZ.optional(),
2389
- googleCalendar: GoogleCalendarSyncZ.optional(),
2390
- dynamicIcon: DynamicIconSpecZ.optional()
2391
- }).refine(declaresExactlyOneStore, {
2392
- message: "declare exactly one of `dataPath` (native JSON records), `dataSource` (external read-only data file), or `storage` (alternative writable backend)",
2393
- path: ["dataPath"]
2394
- }).refine(dataSourceDeclaresNoWriteMachinery, {
2395
- message: "a `dataSource` collection is read-only — it cannot declare `singleton`, `ingest`, `spawn`, or `googleCalendar` (all of them write records)",
2396
- path: ["dataSource"]
2397
- }).refine(googleCalendarMapNamesStoredFields, {
2398
- message: "a `googleCalendar` map key must name a declared, non-computed field, and never the primaryKey (that always holds the Google event id)",
2399
- path: ["googleCalendar"]
2400
- }).refine(dataSourceDeclaresNoMutateAction, {
2401
- message: "a `dataSource` collection is read-only — its actions cannot use `kind: \"mutate\"` (a host write); use `chat`/`agent` actions instead",
2402
- path: ["dataSource"]
2403
- }).refine(singletonIsAValidRecordId, {
2404
- message: "schema `singleton` must be a valid item id (alphanumeric / hyphen / underscore / interior dot, no `..` or path separators)",
2405
- path: ["singleton"]
2406
- }).refine(actionIdsAreUnique, {
2407
- message: "schema `actions` must have unique `id`s",
2408
- path: ["actions"]
2409
- }).refine(collectionActionIdsAreUnique, {
2410
- message: "schema `collectionActions` must have unique `id`s",
2411
- path: ["collectionActions"]
2412
- }).refine(mutateSetKeysNameStoredFields, {
2413
- message: "a mutate action's `set` keys must name declared, non-computed fields (and never the primaryKey)",
2414
- path: ["actions"]
2415
- }).refine(mutateParamRefsAreDeclared, {
2416
- message: "a mutate action's `$params.<name>` references must name keys declared in its `params`",
2417
- path: ["actions"]
2418
- }).refine(collectionActionsAreNotMutate, {
2419
- message: "`collectionActions` cannot contain `kind: \"mutate\"` — a collection-level action has no record to write",
2420
- path: ["collectionActions"]
2421
- }).refine(currencyFieldRefsNameCodeFields, {
2422
- message: "a money field's `currencyField` must name a top-level `string`, `text`, or `enum` field that holds the currency code",
2423
- path: ["fields"]
2424
- }).refine(completionPairIsCoherent, {
2425
- message: "schema `completionField` and `completionDoneValues` must be declared together (both set, or both omitted) — unless `completionField` names a `flag` field, in which case `completionDoneValues` must be omitted (done ⇔ the flag matches)",
2426
- path: ["completionField"]
2427
- }).refine(completionFieldIsDeclared, {
2428
- message: "schema `completionField` must name a top-level field declared in `fields`",
2429
- path: ["completionField"]
2430
- }).refine(displayFieldIsDeclared, {
2431
- message: "schema `displayField` must name a top-level field declared in `fields`",
2432
- path: ["displayField"]
2433
- }).refine(fieldVisibilityGatesNameDeclaredFields, {
2434
- message: "a field's `when.field` must name a top-level field declared in `fields`",
2435
- path: ["fields"]
2436
- }).refine(flagConditionsNameDeclaredFields, {
2437
- message: "a flag field's `where` conditions must name top-level fields declared in `fields` (both `field` and a same-record `valueFrom.field`)",
2438
- path: ["fields"]
2439
- }).refine(completionFlagReadsOnlyStoredFields, {
2440
- message: "a `flag` named by `completionField` may only reference STORED fields in its `where` — completion is evaluated against the raw record (before deriveAll), where computed values (derived/rollup/toggle/flag/embed/backlinks) are absent",
2441
- path: ["completionField"]
2442
- }).refine(flagCompletionSpawnDeclaresWhen, {
2443
- message: "a schema whose `completionField` names a `flag` field must declare an explicit `spawn.when` (the spawn-inert check cannot statically evaluate a flag's `where`)",
2444
- path: ["spawn"]
2445
- }).refine(embedIdFieldsNameIdBearingFields, {
2446
- message: "an embed field's `idField` must name a top-level `ref` or `string` field declared in `fields`",
2447
- path: ["fields"]
2448
- }).refine(triggerFieldRequiresCompletion, {
2449
- message: "schema `triggerField` requires `completionField` / `completionDoneValues` (the gated bell still clears via the done value)",
2450
- path: ["triggerField"]
2451
- }).refine(triggerFieldIsADateField, {
2452
- message: "schema `triggerField` must name a top-level `date` field declared in `fields`",
2453
- path: ["triggerField"]
2454
- }).refine(triggerLeadDaysRequiresTriggerField, {
2455
- message: "schema `triggerLeadDays` requires `triggerField` (it shifts when that field's bell fires)",
2456
- path: ["triggerLeadDays"]
2457
- }).refine(spawnRequiresTriggerField, {
2458
- message: "schema `spawn` requires `triggerField` (the successor's trigger date is `triggerField` advanced by `spawn.every`)",
2459
- path: ["spawn"]
2460
- }).refine(spawnWhenFieldIsDeclared, {
2461
- message: "schema `spawn.when.field` must name a top-level field declared in `fields`",
2462
- path: ["spawn"]
2463
- }).refine(spawnCarryEntriesAreDeclared, {
2464
- message: "every `spawn.carry` entry must name a top-level field declared in `fields`",
2465
- path: ["spawn"]
2466
- }).refine(spawnSuccessorStartsInert, {
2467
- message: "`spawn` must leave the successor in a non-matching state (e.g. `set` the status to a pending value); seeding the predicate field to a matching value via `set`/`carry` would respawn forever",
2468
- path: ["spawn"]
2469
- }).refine(fieldDrivenFromFieldIsEnum, {
2470
- message: "`spawn.every.fromField` must name a top-level `enum` field declared in `fields`",
2471
- path: ["spawn"]
2472
- }).refine(fieldDrivenMapCoversValues, {
2473
- message: "`spawn.every.map` keys must exactly cover the `values` of the `enum` named by `fromField` (no missing or extra keys)",
2474
- path: ["spawn"]
2475
- }).refine(fieldDrivenFromFieldCarried, {
2476
- message: "`spawn.every.fromField` must appear in `spawn.carry`, or be written by `spawn.set` to a value present in `spawn.every.map`, so the successor keeps a resolvable recurrence interval",
2477
- path: ["spawn"]
2478
- }).refine(calendarFieldIsDateLike, {
2479
- message: "schema `calendarField` must name a top-level `date` or `datetime` field declared in `fields`",
2480
- path: ["calendarField"]
2481
- }).refine(calendarEndFieldRequiresCalendarField, {
2482
- message: "schema `calendarEndField` requires `calendarField` (it marks the end of the span that starts at `calendarField`)",
2483
- path: ["calendarEndField"]
2484
- }).refine(calendarEndFieldIsDateLike, {
2485
- message: "schema `calendarEndField` must name a top-level `date` or `datetime` field declared in `fields`",
2486
- path: ["calendarEndField"]
2487
- }).refine(calendarTimeFieldRequiresCalendarField, {
2488
- message: "schema `calendarTimeField` requires `calendarField` (it supplies the time-of-day for the calendar's day view)",
2489
- path: ["calendarTimeField"]
2490
- }).refine(calendarTimeFieldIsDeclared, {
2491
- message: "schema `calendarTimeField` must name a top-level field declared in `fields`",
2492
- path: ["calendarTimeField"]
2493
- }).refine(calendarTimeFieldIsStringBacked, {
2494
- message: "schema `calendarTimeField` must name a top-level `string` or `text` field declared in `fields`",
2495
- path: ["calendarTimeField"]
2496
- }).refine(kanbanFieldIsAnEnum, {
2497
- message: "schema `kanbanField` must name a top-level `enum` field declared in `fields`",
2498
- path: ["kanbanField"]
2499
- }).refine(togglesProjectValidEnums, {
2500
- message: "a `toggle` field's `field` must name a top-level `enum` field, and its `onValue`/`offValue` must be values of that enum",
2501
- path: ["fields"]
2502
- }).refine(notifyWhenRequiresCompletion, {
2503
- message: "schema `notifyWhen` requires `completionField` (it narrows that bell)",
2504
- path: ["notifyWhen"]
2505
- }).refine(notifyWhenFieldIsDeclared, {
2506
- message: "schema `notifyWhen.field` must name a top-level field declared in `fields`",
2507
- path: ["notifyWhen"]
2508
- }).refine(viewIdsAreSlugs, {
2509
- message: "every `views[].id` must be a valid slug (alphanumeric / hyphen / underscore, no path separators)",
2510
- path: ["views"]
2511
- }).refine(viewIdsAreUnique, {
2512
- message: "schema `views` must have unique `id`s",
2513
- path: ["views"]
2239
+ /** The whole query. At least one of `groupBy` / `aggregates` must be
2240
+ * present: bare `groupBy` is a DISTINCT listing, bare `aggregates` a
2241
+ * whole-file scalar row, together a grouped aggregation. */
2242
+ var CollectionQueryZ = z.object({
2243
+ groupBy: z.array(z.string().min(1)).max(8).refine((columns) => new Set(columns.map((column) => column.toLowerCase())).size === columns.length, { message: "`groupBy` columns must be unique (case-insensitively — SQL identifiers ignore case)" }).optional(),
2244
+ aggregates: z.record(z.string().regex(SAFE_ALIAS_PATTERN, "aggregate aliases must be simple identifiers (letters/digits/underscore)"), QueryAggregateZ).optional(),
2245
+ where: z.array(QueryWhereZ).max(16).optional(),
2246
+ orderBy: z.array(QueryOrderZ).max(4).optional(),
2247
+ limit: z.number().int().min(1).max(MAX_QUERY_ROWS).optional()
2248
+ }).refine((query) => (query.groupBy?.length ?? 0) > 0 || Object.keys(query.aggregates ?? {}).length > 0, {
2249
+ message: "declare at least one of `groupBy` (columns to bucket by) or `aggregates` (values to compute)",
2250
+ path: ["groupBy"]
2251
+ }).refine((query) => Object.keys(query.aggregates ?? {}).length <= 32, {
2252
+ message: `\`aggregates\` supports at most 32 entries`,
2253
+ path: ["aggregates"]
2254
+ }).refine((query) => {
2255
+ const groupLower = new Set((query.groupBy ?? []).map((column) => column.toLowerCase()));
2256
+ const seen = /* @__PURE__ */ new Set();
2257
+ return Object.keys(query.aggregates ?? {}).every((alias) => {
2258
+ const lower = alias.toLowerCase();
2259
+ if (groupLower.has(lower) || seen.has(lower)) return false;
2260
+ seen.add(lower);
2261
+ return true;
2262
+ });
2263
+ }, {
2264
+ message: "aggregate aliases must be unique and must not collide with `groupBy` column names (case-insensitively — SQL identifiers ignore case)",
2265
+ path: ["aggregates"]
2266
+ }).refine((query) => {
2267
+ const sortable = /* @__PURE__ */ new Set([...query.groupBy ?? [], ...Object.keys(query.aggregates ?? {})]);
2268
+ return (query.orderBy ?? []).every((order) => sortable.has(order.field));
2269
+ }, {
2270
+ message: "every `orderBy.field` must be a `groupBy` column or an aggregate alias",
2271
+ path: ["orderBy"]
2514
2272
  });
2515
- var PROTOTYPE_KEYS = [
2516
- "__proto__",
2517
- "constructor",
2518
- "prototype"
2519
- ];
2520
- /** The first own prototype-sensitive key of `value`, or null. */
2521
- function ownPrototypeKey(value) {
2522
- if (value === null || typeof value !== "object") return null;
2523
- for (const key of PROTOTYPE_KEYS) if (Object.hasOwn(value, key)) return key;
2524
- return null;
2273
+ //#endregion
2274
+ //#region src/collection/server/csvQuery.ts
2275
+ /** Double-quote a SQL identifier (CSV column name / result alias). */
2276
+ function quoteIdent(name) {
2277
+ return `"${name.replaceAll("\"", "\"\"")}"`;
2278
+ }
2279
+ /** Single-quote a SQL string literal (a `types={...}` struct key). */
2280
+ function quoteLiteral(value) {
2281
+ return `'${value.replaceAll("'", "''")}'`;
2282
+ }
2283
+ /** The `read_csv` argument list shared by every CSV query: the (prepared)
2284
+ * path plus a `types` pin forcing the key column to VARCHAR — without it
2285
+ * DuckDB's sniffer turns `001` into BIGINT 1, so leading zeros vanish
2286
+ * and distinct keys collapse. */
2287
+ function readCsvArgs(primaryKey) {
2288
+ return `?, types={${quoteLiteral(primaryKey)}: 'VARCHAR'}`;
2289
+ }
2290
+ /** One aggregate's SQL expression. `sum`/`avg` TRY_CAST to DOUBLE so a
2291
+ * column the sniffer kept as VARCHAR (mixed values) aggregates over its
2292
+ * numeric cells instead of erroring; non-numeric cells become NULL and
2293
+ * are skipped — standard BI tolerance. `min`/`max` stay native (they are
2294
+ * meaningful on strings and dates too). */
2295
+ function aggregateExpr(aggregate) {
2296
+ const { op, column } = aggregate;
2297
+ if (op === "count") return column === void 0 ? "count(*)" : `count(${quoteIdent(column)})`;
2298
+ if (op === "sum" || op === "avg") return `${op}(TRY_CAST(${quoteIdent(column ?? "")} AS DOUBLE))`;
2299
+ return `${op}(${quoteIdent(column ?? "")})`;
2300
+ }
2301
+ /** One where condition → SQL fragment + its bound parameters. String
2302
+ * equality compares against `CAST(col AS VARCHAR)` so a sniffer-typed
2303
+ * column still matches its textual value; numeric/boolean values compare
2304
+ * natively (DuckDB coerces the column side). */
2305
+ function whereFragment(cond) {
2306
+ const column = quoteIdent(cond.field);
2307
+ const asText = `CAST(${column} AS VARCHAR)`;
2308
+ if (cond.op === "in") {
2309
+ const values = arrayValue(cond);
2310
+ return {
2311
+ sql: `${values.every((value) => typeof value === "string") ? asText : column} IN (${values.map(() => "?").join(", ")})`,
2312
+ params: values
2313
+ };
2314
+ }
2315
+ if (cond.op === "contains") return {
2316
+ sql: `contains(${asText}, ?)`,
2317
+ params: [String(scalarValue(cond))]
2318
+ };
2319
+ const operator = {
2320
+ eq: "=",
2321
+ ne: "<>",
2322
+ gt: ">",
2323
+ gte: ">=",
2324
+ lt: "<",
2325
+ lte: "<="
2326
+ }[cond.op];
2327
+ return {
2328
+ sql: `${typeof cond.value === "string" && (cond.op === "eq" || cond.op === "ne") ? asText : column} ${operator} ?`,
2329
+ params: [scalarValue(cond)]
2330
+ };
2525
2331
  }
2526
- /** Own enumerable entries of an object (arrays keyed by index), none for
2527
- * anything else — the raw input is unvalidated, so `fields` may be junk. */
2528
- function ownEntries(value) {
2529
- if (isUnknownArray(value)) return value.map((entry, index) => [String(index), entry]);
2530
- return isRecord(value) ? Object.entries(value) : [];
2332
+ /** Mirror of `scalarValue` for the one op that takes a set: a scalar under
2333
+ * `in` also means the query skipped `CollectionQueryZ`. Left unchecked it
2334
+ * failed as `values.every is not a function`, naming neither the field nor
2335
+ * the op. */
2336
+ function arrayValue(cond) {
2337
+ if (!Array.isArray(cond.value)) throw new Error(`where condition on '${cond.field}' uses op 'in', which requires an array value, not a scalar`);
2338
+ return cond.value;
2339
+ }
2340
+ /** `CollectionQueryZ` refines "`in` ⇔ array value", so an array reaching a
2341
+ * scalar op means the query was compiled without being validated first —
2342
+ * binding it would send an array to a single `?`. */
2343
+ function scalarValue(cond) {
2344
+ if (Array.isArray(cond.value)) throw new Error(`where condition on '${cond.field}' uses op '${cond.op}', which requires a scalar value, not an array`);
2345
+ return cond.value;
2346
+ }
2347
+ /** Compile a validated query against `fromSql` (a table-function call
2348
+ * whose FIRST placeholder is the source path — the executor binds it).
2349
+ * Returns the SQL and the where-value parameters that follow the path.
2350
+ * Callers MUST have run `CollectionQueryZ` first; this function trusts
2351
+ * the shape (aliases already charset-checked, orderBy membership already
2352
+ * enforced). */
2353
+ function compileQuery(query, fromSql) {
2354
+ const groupBy = query.groupBy ?? [];
2355
+ const aggregates = Object.entries(query.aggregates ?? {});
2356
+ const selectList = [...groupBy.map(quoteIdent), ...aggregates.map(([alias, aggregate]) => `${aggregateExpr(aggregate)} AS ${quoteIdent(alias)}`)];
2357
+ const where = (query.where ?? []).map(whereFragment);
2358
+ const clauses = [`SELECT ${selectList.join(", ")}`, `FROM ${fromSql}`];
2359
+ if (where.length > 0) clauses.push(`WHERE ${where.map((fragment) => fragment.sql).join(" AND ")}`);
2360
+ if (groupBy.length > 0) clauses.push(`GROUP BY ${groupBy.map(quoteIdent).join(", ")}`);
2361
+ const orderBy = (query.orderBy ?? []).map((order) => quoteIdent(order.field) + (order.dir === "desc" ? " DESC" : " ASC"));
2362
+ if (orderBy.length > 0) clauses.push(`ORDER BY ${orderBy.join(", ")}`);
2363
+ clauses.push(`LIMIT ${query.limit ?? 1e3}`);
2364
+ return {
2365
+ sql: clauses.join(" "),
2366
+ params: where.flatMap((fragment) => fragment.params)
2367
+ };
2368
+ }
2369
+ /** Compile against a CSV file (the dataSource store's engine). */
2370
+ function compileCsvQuery(query, primaryKey) {
2371
+ return compileQuery(query, `read_csv(${readCsvArgs(primaryKey)})`);
2372
+ }
2373
+ /** Compile against a JSONL file of ENRICHED records — the file-backed
2374
+ * collections' engine (see `jsonlQuery.ts`). No VARCHAR key pin needed:
2375
+ * enriched record ids are already strings. `sample_size=-1` makes the
2376
+ * schema inference scan EVERY line — with the default sample, a sparse
2377
+ * optional/derived field first appearing past the sample would not be
2378
+ * inferred as a column and the query would binder-error on it (Codex P2
2379
+ * on #2165). The full scan costs nothing extra here: aggregation reads
2380
+ * the whole file anyway. */
2381
+ function compileJsonlQuery(query) {
2382
+ return compileQuery(query, `read_json(?, format='newline_delimited', sample_size=-1)`);
2383
+ }
2384
+ //#endregion
2385
+ //#region src/collection/server/csvStore.ts
2386
+ /** `list()` row cap. Over-cap files are truncated with a warn — the v1
2387
+ * contract is "browse + per-record views", not full-table analytics. */
2388
+ var MAX_CSV_ROWS = 5e3;
2389
+ /** Record ids minted from non-safe key values: `id0x` + utf-8 hex. Raw key
2390
+ * values that themselves match this pattern are ALSO encoded, so the
2391
+ * encoded namespace never collides with a raw value (injective mapping). */
2392
+ var ENCODED_ID_PATTERN = /^id0x([0-9a-f]+)$/;
2393
+ /** A CSV key value → the record id it's addressed by. Safe values pass
2394
+ * through untouched; everything else (and anything shaped like an encoded
2395
+ * id) becomes `id0x<hex>`. Pure + exported for unit tests. */
2396
+ function encodeCsvRecordId(rawKey) {
2397
+ if (safeRecordId(rawKey) === rawKey && !ENCODED_ID_PATTERN.test(rawKey)) return rawKey;
2398
+ return `id0x${Buffer.from(rawKey, "utf-8").toString("hex")}`;
2399
+ }
2400
+ /** A record id → the CSV key value to look up. Inverse of
2401
+ * `encodeCsvRecordId` for encoded ids; anything else is already the raw
2402
+ * value. Pure + exported for unit tests. */
2403
+ function decodeCsvRecordId(itemId) {
2404
+ const hex = ENCODED_ID_PATTERN.exec(itemId)?.[1];
2405
+ if (hex === void 0) return itemId;
2406
+ return Buffer.from(hex, "hex").toString("utf-8");
2407
+ }
2408
+ /** Normalize one DuckDB JS value into a JSON-safe record value: BigInt →
2409
+ * number (string beyond the safe range), DATE/TIMESTAMP → ISO string
2410
+ * (date-only when the clock is exactly UTC midnight, matching the `date`
2411
+ * field contract), exotic DuckDB values → their string form. Pure +
2412
+ * exported for unit tests. */
2413
+ /** `JSON.stringify` restricted to what a CSV cell can survive. Returns the
2414
+ * serialised value, or `String(value)` when serialisation is impossible —
2415
+ * losing the content of one cell is bad, failing the entire query is worse. */
2416
+ function safeJsonCell(value) {
2417
+ try {
2418
+ return JSON.stringify(value, (_key, entry) => typeof entry === "bigint" ? entry.toString() : entry) ?? String(value);
2419
+ } catch {
2420
+ return String(value);
2421
+ }
2422
+ }
2423
+ function normalizeCsvValue(value) {
2424
+ if (typeof value === "bigint") return value <= BigInt(Number.MAX_SAFE_INTEGER) && value >= BigInt(-Number.MAX_SAFE_INTEGER) ? Number(value) : value.toString();
2425
+ if (value instanceof Date) {
2426
+ const iso = value.toISOString();
2427
+ return iso.endsWith("T00:00:00.000Z") ? iso.slice(0, 10) : iso;
2428
+ }
2429
+ if (value !== null && typeof value === "object") return safeJsonCell(value);
2430
+ return value;
2431
+ }
2432
+ /** One raw DuckDB row → a CollectionItem, or null when the key cell is
2433
+ * missing/empty (the row can't be addressed). The primaryKey field is
2434
+ * OVERWRITTEN with the (possibly encoded) record id so `item[primaryKey]`
2435
+ * and the record's address never drift — same invariant the file store's
2436
+ * write path enforces. Pure + exported for unit tests. */
2437
+ function csvRowToItem(row, primaryKey) {
2438
+ const normalized = Object.fromEntries(Object.entries(row).map(([key, value]) => [key, normalizeCsvValue(value)]));
2439
+ const rawKey = normalized[primaryKey];
2440
+ const keyText = fieldTextOrNull(rawKey);
2441
+ if (keyText === null || keyText === "") return null;
2442
+ return {
2443
+ ...normalized,
2444
+ [primaryKey]: encodeCsvRecordId(keyText)
2445
+ };
2446
+ }
2447
+ /** Dedupe by record id, LAST row wins (matches `csvRead`'s last-match
2448
+ * pick). Returns the surviving items in first-seen order. Pure +
2449
+ * exported for unit tests. */
2450
+ function dedupeByRecordId(items, primaryKey) {
2451
+ const byId = /* @__PURE__ */ new Map();
2452
+ for (const item of items) byId.set(String(item[primaryKey]), item);
2453
+ return {
2454
+ items: [...byId.values()],
2455
+ duplicates: items.length - byId.size
2456
+ };
2457
+ }
2458
+ /** True when a thrown DuckDB error is the `types` pin naming a column the
2459
+ * CSV doesn't have — the schema/file-mismatch case the caller downgrades
2460
+ * to "empty collection + warn" instead of a 500. */
2461
+ function isMissingKeyColumnError(err) {
2462
+ return String(err).includes("do not exist in the CSV");
2463
+ }
2464
+ /** Bytes sniffed for UTF-8 validity. The trailing 3 bytes of the sample
2465
+ * are dropped so a multibyte char split at the boundary can't produce a
2466
+ * false negative on a valid file. */
2467
+ var SNIFF_BYTES = 1048576;
2468
+ function isValidUtf8(buf) {
2469
+ try {
2470
+ new TextDecoder("utf-8", { fatal: true }).decode(buf);
2471
+ return true;
2472
+ } catch {
2473
+ return false;
2474
+ }
2475
+ }
2476
+ /** Detect the (best-effort) encoding of a non-UTF-8 buffer. BOMs decide
2477
+ * UTF-16; otherwise cp932 (the Shift_JIS superset — Excel-exported
2478
+ * Japanese CSVs are the primary non-UTF-8 case this feature serves). */
2479
+ function fallbackEncoding(buf) {
2480
+ if (buf.length >= 2 && buf[0] === 255 && buf[1] === 254) return "utf-16le";
2481
+ if (buf.length >= 2 && buf[0] === 254 && buf[1] === 255) return "utf-16be";
2482
+ return "cp932";
2483
+ }
2484
+ function cacheDir() {
2485
+ return path.join(tmpdir(), "mulmoclaude-csv-utf8");
2486
+ }
2487
+ /** Read only the first `bytes` of a file — the encoding sniff must not
2488
+ * pull a multi-hundred-MB CSV into memory on the (common) UTF-8 path. */
2489
+ async function readHead(absPath, bytes) {
2490
+ const handle = await open(absPath, "r");
2491
+ try {
2492
+ const { size } = await handle.stat();
2493
+ const buf = Buffer.alloc(Math.min(bytes, size));
2494
+ await handle.read(buf, 0, buf.length, 0);
2495
+ return buf;
2496
+ } finally {
2497
+ await handle.close();
2498
+ }
2499
+ }
2500
+ /** Decode the whole file into a UTF-8 cache copy and return its path.
2501
+ * Cache key = (path, mtime, size), so a replaced CSV re-decodes and an
2502
+ * unchanged one never does. */
2503
+ async function pathExists(target) {
2504
+ try {
2505
+ await stat(target);
2506
+ return true;
2507
+ } catch {
2508
+ return false;
2509
+ }
2510
+ }
2511
+ /** Best-effort removal of older decode-cache entries for the same source
2512
+ * path — a frequently-replaced large CSV would otherwise accumulate one
2513
+ * full copy per (mtime, size) forever. Runs AFTER the current copy is
2514
+ * published; a concurrent reader holding an old fd is unaffected
2515
+ * (unlink-while-open is safe on POSIX). */
2516
+ async function evictSupersededCache(key, keepBasename) {
2517
+ try {
2518
+ const entries = await readdir(cacheDir());
2519
+ await Promise.all(entries.filter((name) => name.startsWith(`${key}-`) && name !== keepBasename).map((name) => unlink(path.join(cacheDir(), name)).catch(() => void 0)));
2520
+ } catch {}
2521
+ }
2522
+ /** Decode the whole file into a UTF-8 cache copy and return its path.
2523
+ * Cache key = (path, mtime, size), so a replaced CSV re-decodes and an
2524
+ * unchanged one never does; superseded copies are evicted. The cache
2525
+ * lives in the SHARED OS tmpdir, so the dir is 0700 and files 0600 —
2526
+ * decoded rows must not be readable by other local users. */
2527
+ async function decodeToCache(absPath, info) {
2528
+ const key = createHash("sha256").update(absPath).digest("hex").slice(0, 16);
2529
+ const cached = path.join(cacheDir(), `${key}-${Math.trunc(info.mtimeMs)}-${info.size}.csv`);
2530
+ if (!await pathExists(cached)) {
2531
+ const whole = await readFile(absPath);
2532
+ const encoding = fallbackEncoding(whole);
2533
+ const text = iconv.decode(whole, encoding);
2534
+ await mkdir(cacheDir(), {
2535
+ recursive: true,
2536
+ mode: 448
2537
+ });
2538
+ const tmp = `${cached}.${randomBytes(4).toString("hex")}.tmp`;
2539
+ await writeFile(tmp, text, {
2540
+ encoding: "utf-8",
2541
+ mode: 384
2542
+ });
2543
+ await rename(tmp, cached);
2544
+ log.info("collections", "decoded non-UTF-8 dataSource file to cache", {
2545
+ path: absPath,
2546
+ encoding
2547
+ });
2548
+ await evictSupersededCache(key, path.basename(cached));
2549
+ }
2550
+ return cached;
2551
+ }
2552
+ /** Re-validate the dataSource file at READ time, mirroring the JSON
2553
+ * store's per-read defenses: realpath containment (a symlink swapped in
2554
+ * after discovery must not walk out of the workspace) and an lstat
2555
+ * regular-file check (a symlink leaf is refused outright, even one
2556
+ * pointing inside the workspace — same rule as `isRegularFile` on
2557
+ * record files). Returns the stat info, or null for "no readable file"
2558
+ * (ENOENT / refused), which callers render as an empty collection. */
2559
+ async function safeCsvStat(absPath, workspaceRoot) {
2560
+ if (!isContainedInRoot(absPath, workspaceRoot)) {
2561
+ log.warn("collections", "dataSource read refused: path escapes workspace", { path: absPath });
2562
+ return null;
2563
+ }
2564
+ let info;
2565
+ try {
2566
+ info = await lstat(absPath);
2567
+ } catch (err) {
2568
+ if (isErrorWithCode(err) && err.code === "ENOENT") return null;
2569
+ throw err;
2570
+ }
2571
+ if (!info.isFile()) {
2572
+ log.warn("collections", "dataSource read refused: not a regular file (symlink?)", { path: absPath });
2573
+ return null;
2574
+ }
2575
+ return info;
2531
2576
  }
2532
- /** The name-defining sub-record a raw field spec (`of`) or action (`params`)
2533
- * carries, or undefined when the holder isn't an object at all. */
2534
- function nameDefiningSubRecord(holder, key) {
2535
- return isRecord(holder) ? holder[key] : void 0;
2577
+ /** Return a path DuckDB can read as UTF-8: the original file when it
2578
+ * already is UTF-8 (the cheap, common case — only the head is sniffed),
2579
+ * else a decoded cache copy (see `decodeToCache`). Returns null when
2580
+ * there is no readable file (missing, symlink, or containment-refused —
2581
+ * see `safeCsvStat`), which callers render as an empty collection. */
2582
+ async function ensureUtf8CsvPath(absPath, workspaceRoot) {
2583
+ const info = await safeCsvStat(absPath, workspaceRoot);
2584
+ if (info === null) return null;
2585
+ const head = await readHead(absPath, SNIFF_BYTES);
2586
+ const sample = head.length === SNIFF_BYTES ? head.subarray(0, 1048573) : head;
2587
+ if (!(head.length >= 2 && (head[0] === 255 && head[1] === 254 || head[0] === 254 && head[1] === 255)) && isValidUtf8(sample)) return absPath;
2588
+ return decodeToCache(absPath, info);
2536
2589
  }
2537
- /** Dotted path of the first prototype-sensitive `params` name across both
2538
- * action lists, or null. */
2539
- function prototypeActionParamPath(input) {
2540
- for (const [listName, list] of [["actions", input.actions], ["collectionActions", input.collectionActions]]) for (const action of isUnknownArray(list) ? list : []) {
2541
- const badParam = ownPrototypeKey(nameDefiningSubRecord(action, "params"));
2542
- if (badParam !== null) return `${listName}.params.${badParam}`;
2590
+ var instancePromise = null;
2591
+ /** Lazily create one shared in-memory DuckDB instance. The dynamic import
2592
+ * keeps the native module OUT of core's load path — a platform where the
2593
+ * prebuilt binding is missing degrades to a per-query error on dataSource
2594
+ * collections only, never a broken core. A failed init is retried on the
2595
+ * next call (the promise is reset). */
2596
+ async function duckDbInstance() {
2597
+ if (instancePromise === null) instancePromise = import("@duckdb/node-api").then((mod) => mod.DuckDBInstance.create(":memory:"));
2598
+ try {
2599
+ return await instancePromise;
2600
+ } catch (err) {
2601
+ instancePromise = null;
2602
+ throw new BackendUnavailableError(`DuckDB is unavailable on this host (@duckdb/node-api failed to load: ${String(err)}) — dataSource collections cannot be read`);
2543
2603
  }
2544
- return null;
2545
2604
  }
2546
- /** Dotted path of the first prototype-sensitive field name in the raw
2547
- * schema input — top-level `fields`, each table field's `of`, and each
2548
- * action's `params` (the three records that DEFINE names) — or null. */
2549
- function prototypeFieldKeyPath(input) {
2550
- if (!isRecord(input)) return null;
2551
- const bad = ownPrototypeKey(input.fields);
2552
- if (bad !== null) return `fields.${bad}`;
2553
- for (const [key, spec] of ownEntries(input.fields)) {
2554
- const badSub = ownPrototypeKey(nameDefiningSubRecord(spec, "of"));
2555
- if (badSub !== null) return `fields.${key}.of.${badSub}`;
2605
+ async function queryCsv(sql, params) {
2606
+ const connection = await (await duckDbInstance()).connect();
2607
+ try {
2608
+ return (await connection.runAndReadAll(sql, params)).getRowObjectsJS();
2609
+ } finally {
2610
+ connection.disconnectSync();
2556
2611
  }
2557
- return prototypeActionParamPath(input);
2558
2612
  }
2559
- var CollectionSchemaZ = z.preprocess((input, ctx) => {
2560
- const bad = prototypeFieldKeyPath(input);
2561
- if (bad !== null) {
2562
- ctx.addIssue({
2563
- code: "custom",
2564
- message: `'${bad}': field names must not be prototype-sensitive keys (\`__proto__\`, \`constructor\`, \`prototype\`)`
2613
+ async function csvList(absPath, primaryKey, workspaceRoot) {
2614
+ const utf8Path = await ensureUtf8CsvPath(absPath, workspaceRoot ?? getWorkspaceRoot());
2615
+ if (utf8Path === null) return {
2616
+ items: [],
2617
+ truncated: false
2618
+ };
2619
+ let rows;
2620
+ try {
2621
+ rows = await queryCsv(`SELECT * FROM read_csv(${readCsvArgs(primaryKey)}) LIMIT 5001`, [utf8Path]);
2622
+ } catch (err) {
2623
+ if (!isMissingKeyColumnError(err)) throw err;
2624
+ log.warn("collections", "dataSource CSV has no primaryKey column — every row is skipped", {
2625
+ path: absPath,
2626
+ primaryKey
2565
2627
  });
2566
- return z.NEVER;
2628
+ return {
2629
+ items: [],
2630
+ truncated: false
2631
+ };
2567
2632
  }
2568
- return input;
2569
- }, BareCollectionSchemaZ);
2570
- //#endregion
2571
- //#region src/collection/server/discovery.ts
2572
- function applyFeedSchemaDefaults(parsed, slug) {
2573
- if (!isRecord(parsed)) return parsed;
2574
- const icon = typeof parsed.icon === "string" && parsed.icon.trim().length > 0 ? parsed.icon : "dynamic_feed";
2633
+ const truncated = rows.length > MAX_CSV_ROWS;
2634
+ if (truncated) {
2635
+ log.warn("collections", "dataSource CSV truncated to row cap", {
2636
+ path: absPath,
2637
+ cap: MAX_CSV_ROWS
2638
+ });
2639
+ rows.length = MAX_CSV_ROWS;
2640
+ }
2641
+ const items = rows.map((row) => csvRowToItem(row, primaryKey)).filter((item) => item !== null);
2642
+ const skipped = rows.length - items.length;
2643
+ if (skipped > 0) log.warn("collections", "dataSource CSV rows skipped (empty key cell)", {
2644
+ path: absPath,
2645
+ skipped
2646
+ });
2647
+ const deduped = dedupeByRecordId(items, primaryKey);
2648
+ if (deduped.duplicates > 0) log.warn("collections", "dataSource CSV has duplicate key values (last row wins)", {
2649
+ path: absPath,
2650
+ duplicates: deduped.duplicates
2651
+ });
2575
2652
  return {
2576
- ...parsed,
2577
- icon,
2578
- dataPath: `data/feeds/${slug}`
2653
+ items: deduped.items,
2654
+ truncated
2579
2655
  };
2580
2656
  }
2581
- /** The conventional per-slug records dir a `dataSource` / `storage` collection
2582
- * gets as its `dataDir` (records never live there, but archive/delete paths
2583
- * stay well-defined — same shape the registry's R3 normalization uses).
2584
- *
2585
- * INVARIANT — this is NOT a default `dataPath`, and must not be used as one.
2586
- * It applies only to the two backends whose records are not per-file JSON. A
2587
- * normal collection declares its own location and exactly one of `dataPath` /
2588
- * `dataSource` / `storage`; a schema with none of the three is REJECTED, not
2589
- * quietly pointed here. Handing a per-file collection this path would silently
2590
- * relocate its records away from the folder the user (and its SKILL.md) sees. */
2591
- function conventionalDataPath(slug) {
2592
- return `data/collections/${slug}/items`;
2657
+ /** The scan-order ordinal column the last-match read adds. Underscore
2658
+ * prefix keeps it out of any plausible CSV header namespace; it is
2659
+ * stripped from the returned record either way. */
2660
+ var ROW_ORDINAL = "__mc_row";
2661
+ /** One record by id. The comparison value rides as a prepared-statement
2662
+ * parameter, and the LAST matching row is selected IN DuckDB (scan-order
2663
+ * ordinal + LIMIT 1) — a CSV with thousands of duplicate keys must not
2664
+ * materialize them all for one detail read. Consistent with csvList's
2665
+ * last-wins dedupe. */
2666
+ async function csvRead(absPath, primaryKey, itemId, workspaceRoot) {
2667
+ const utf8Path = await ensureUtf8CsvPath(absPath, workspaceRoot ?? getWorkspaceRoot());
2668
+ if (utf8Path === null) return null;
2669
+ const rawKey = decodeCsvRecordId(itemId);
2670
+ const last = (await queryCsv(`SELECT * FROM (SELECT *, row_number() OVER () AS ${quoteIdent(ROW_ORDINAL)} FROM read_csv(${readCsvArgs(primaryKey)})) WHERE CAST(${quoteIdent(primaryKey)} AS VARCHAR) = ? ORDER BY ${quoteIdent(ROW_ORDINAL)} DESC LIMIT 1`, [utf8Path, rawKey])).at(0);
2671
+ if (last === void 0) return null;
2672
+ const { [ROW_ORDINAL]: __ordinal, ...record } = last;
2673
+ return csvRowToItem(record, primaryKey);
2593
2674
  }
2594
- /** The declared field named by `primaryKey`, or `undefined` when the schema
2595
- * declares no such field. Own-property guarded: a `primaryKey` of `toString`
2596
- * / `constructor` / `__proto__` must miss here, not read an Object.prototype
2597
- * member and slip past the "is it a declared field?" gate into the wrong
2598
- * "add `primary: true`" advice. Shared with manageCollection's putSchema
2599
- * gate so both report the SAME reason. */
2600
- function resolvePrimaryField(fields, primaryKey) {
2601
- return Object.hasOwn(fields, primaryKey) ? fields[primaryKey] : void 0;
2675
+ /** Run a validated aggregation query (the structured DSL — see
2676
+ * `core/queryZ.ts`) over the WHOLE file: no row cap on the scan (a
2677
+ * capped aggregate would be a wrong number), only the result-row LIMIT
2678
+ * the compiler emits. Values are normalized like list/read rows so a
2679
+ * chart consumer gets plain JSON scalars. */
2680
+ async function csvRunQuery(absPath, primaryKey, query, workspaceRoot) {
2681
+ const utf8Path = await ensureUtf8CsvPath(absPath, workspaceRoot ?? getWorkspaceRoot());
2682
+ if (utf8Path === null) return [];
2683
+ const { sql, params } = compileCsvQuery(query, primaryKey);
2684
+ return (await queryCsv(sql, [utf8Path, ...params])).map((row) => Object.fromEntries(Object.entries(row).map(([key, value]) => [key, normalizeCsvValue(value)])));
2602
2685
  }
2603
- /** The acceptance gates discovery applies AFTER `CollectionSchemaZ` parses,
2604
- * before a schema becomes a live collection:
2686
+ //#endregion
2687
+ //#region src/collection/server/watchFs.ts
2688
+ /** An atomic file replace (editor save, `mv` over the target) surfaces as
2689
+ * 2-3 events. Collapse them so one user action reports one change. */
2690
+ var REPLACE_DEBOUNCE_MS = 300;
2691
+ /** The path to hand `watch()`, with Windows 8.3 short names resolved away.
2605
2692
  *
2606
- * - the `primaryKey` must be a declared field flagged `primary: true` —
2607
- * without the flag CollectionView renders the field editable, and a
2608
- * rename is silently pinned back to the URL itemId on save, so the user's
2609
- * edit is dropped with no error;
2610
- * - a `feed` schema must declare an `ingest` block (else it's a dead,
2611
- * non-refreshable card);
2612
- * - `dataPath` — or a `dataSource`'s `path` — must resolve INSIDE the
2613
- * workspace (same realpath containment for both).
2693
+ * ReadDirectoryChangesW reports filenames against the LONG path, but a watch
2694
+ * opened on a short path (`C:\Users\RUNNER~1\…` — what `os.tmpdir()` returns
2695
+ * on GitHub's Windows runners) keeps the short form. libuv's
2696
+ * `assert(!_wcsnicmp(filename, dir, dirlen))` in `src/win/fs-event.c` then
2697
+ * aborts the PROCESS on the first event — a native assert, so neither
2698
+ * `watcher.on("error")` nor a try/catch can contain it.
2614
2699
  *
2615
- * Exported so `manageCollection`'s `putSchema` can run the SAME gates before
2616
- * it reports success — a schema that passes `CollectionSchemaZ` but fails one
2617
- * of these would otherwise write cleanly yet be skipped on the next discovery,
2618
- * hiding the collection (the exact failure that tool exists to prevent). */
2619
- function acceptParsedSchema(schema, opts) {
2620
- const primaryField = resolvePrimaryField(schema.fields, schema.primaryKey);
2621
- if (!primaryField) return {
2622
- ok: false,
2623
- reason: `primaryKey '${schema.primaryKey}' is not one of the declared fields`
2624
- };
2625
- if (primaryField.primary !== true) return {
2626
- ok: false,
2627
- reason: `the primaryKey field '${schema.primaryKey}' must be flagged \`primary: true\``
2628
- };
2629
- if (opts.source === "feed" && !schema.ingest) return {
2630
- ok: false,
2631
- reason: "a feed schema must declare an `ingest` block"
2632
- };
2633
- if (schema.dataSource !== void 0) {
2634
- const dataSourceFile = resolveDataDir(schema.dataSource.path, opts.workspaceRoot);
2635
- if (dataSourceFile === null) return {
2636
- ok: false,
2637
- reason: `dataSource.path '${schema.dataSource.path}' escapes the workspace`
2638
- };
2639
- const dataDir = resolveDataDir(conventionalDataPath(opts.slug), opts.workspaceRoot);
2640
- if (dataDir === null) return {
2641
- ok: false,
2642
- reason: `slug '${opts.slug}' yields no workspace-contained data dir`
2643
- };
2644
- return {
2645
- ok: true,
2646
- dataDir,
2647
- dataSourceFile
2648
- };
2649
- }
2650
- if (schema.storage !== void 0) {
2651
- const storageFile = resolveDataDir(schema.storage.path, opts.workspaceRoot);
2652
- if (storageFile === null) return {
2653
- ok: false,
2654
- reason: `storage.path '${schema.storage.path}' escapes the workspace`
2655
- };
2656
- const dataDir = resolveDataDir(conventionalDataPath(opts.slug), opts.workspaceRoot);
2657
- if (dataDir === null) return {
2658
- ok: false,
2659
- reason: `slug '${opts.slug}' yields no workspace-contained data dir`
2660
- };
2661
- return {
2662
- ok: true,
2663
- dataDir,
2664
- storageFile
2665
- };
2666
- }
2667
- const dataDir = resolveDataDir(schema.dataPath ?? "", opts.workspaceRoot);
2668
- if (dataDir === null) return {
2669
- ok: false,
2670
- reason: `dataPath '${schema.dataPath}' escapes the workspace`
2671
- };
2672
- return {
2673
- ok: true,
2674
- dataDir
2675
- };
2700
+ * POSIX is deliberately left alone: `realpath` there also collapses symlinks
2701
+ * (`/var` → `/private/var` on macOS), which we neither need nor want to
2702
+ * change. A failure falls back to the original path — worst case we are no
2703
+ * worse off than before. */
2704
+ function watchablePath(dir) {
2705
+ if (process.platform !== "win32") return dir;
2706
+ try {
2707
+ return realpathSync.native(dir);
2708
+ } catch {
2709
+ return dir;
2710
+ }
2676
2711
  }
2677
- async function loadOneCollection(skillsRoot, slug, source, workspaceRoot) {
2678
- const safeName = safeSlugName(slug);
2679
- if (safeName === null) return null;
2680
- const schemaPath = path.join(skillsRoot, safeName, SCHEMA_FILE);
2681
- let raw;
2712
+ /** Watch `dir`, reporting each accepted filename. `accept` decides what is
2713
+ * noise; a null filename always passes (the platform didn't tell us which
2714
+ * file, so the caller must assume the worst). */
2715
+ async function watchDirectory(dir, accept, onHit) {
2682
2716
  try {
2683
- if (!(await stat(schemaPath)).isFile()) return null;
2684
- raw = await readFile(schemaPath, "utf-8");
2717
+ await mkdir(dir, { recursive: true });
2718
+ const watcher = watch(watchablePath(dir), { persistent: false }, (_eventType, rawFilename) => {
2719
+ const filename = rawFilename === null ? null : String(rawFilename);
2720
+ if (filename !== null && !accept(filename)) return;
2721
+ onHit(filename);
2722
+ });
2723
+ watcher.on("error", (err) => {
2724
+ log.warn("collections", "fs watch error", {
2725
+ dir,
2726
+ error: String(err)
2727
+ });
2728
+ });
2729
+ return { close: () => watcher.close() };
2685
2730
  } catch (err) {
2686
- if (!isErrorWithCode(err) || err.code !== "ENOENT") log.warn("collections", "failed to read schema.json, skipping", {
2687
- slug: safeName,
2688
- path: schemaPath,
2731
+ log.warn("collections", "fs watch start failed", {
2732
+ dir,
2689
2733
  error: String(err)
2690
2734
  });
2691
2735
  return null;
2692
2736
  }
2693
- let parsedJson;
2737
+ }
2738
+ /** Watch the single file `absPath` by watching its PARENT directory, so an
2739
+ * atomic replace can't strand the watch on a dead inode. `alsoAccept`
2740
+ * widens the filter beyond the exact basename (sqlite's `-wal`/`-journal`
2741
+ * sidecars). Reports are debounced: one replace, one call. */
2742
+ async function watchSingleFile(absPath, alsoAccept, onChange) {
2743
+ const dir = path.dirname(absPath);
2744
+ const base = path.basename(absPath);
2745
+ let timer = null;
2746
+ const fire = () => {
2747
+ if (timer) clearTimeout(timer);
2748
+ timer = setTimeout(() => {
2749
+ timer = null;
2750
+ onChange();
2751
+ }, REPLACE_DEBOUNCE_MS);
2752
+ timer.unref?.();
2753
+ };
2754
+ const handle = await watchDirectory(dir, (filename) => filename === base || alsoAccept(base, filename), fire);
2755
+ if (!handle) return null;
2756
+ return { close: () => {
2757
+ if (timer) clearTimeout(timer);
2758
+ timer = null;
2759
+ handle.close();
2760
+ } };
2761
+ }
2762
+ /** An `FsWatchHandle` as a bare unsubscribe — `null` straight through, so an
2763
+ * unarmed watch stays distinguishable from an armed one. Lives here rather
2764
+ * than beside the store contract so both `store.ts` and the backends it
2765
+ * registers can reach it without importing each other. */
2766
+ function closerFor(handle) {
2767
+ return handle === null ? null : () => handle.close();
2768
+ }
2769
+ //#endregion
2770
+ //#region src/collection/server/sqliteStore.ts
2771
+ /** A constructor's parameter and return types are not observable at runtime,
2772
+ * so the check stops at "DatabaseSync is constructible" — the only member of
2773
+ * the module this store ever touches. */
2774
+ function isSqliteModule(mod) {
2775
+ return isRecord(mod) && typeof mod.DatabaseSync === "function";
2776
+ }
2777
+ var sqliteModule = null;
2778
+ /** Drops the memo first so a later call can retry (e.g. tests stubbing the
2779
+ * runtime), then reports why the backend is unusable. */
2780
+ function sqliteUnavailable(reason) {
2781
+ sqliteModule = null;
2782
+ throw new BackendUnavailableError(`sqlite storage needs the node:sqlite module (Node.js >= 22.5) — this runtime cannot load it: ${reason}`);
2783
+ }
2784
+ /** Lazy-load node:sqlite once. A runtime without it (Node < 22.5) throws a
2785
+ * clearly-worded error the caller surfaces — never a bare MODULE_NOT_FOUND. */
2786
+ function loadSqlite() {
2787
+ sqliteModule ??= import("node:sqlite").then((mod) => isSqliteModule(mod) ? mod : sqliteUnavailable("the module exposes no DatabaseSync constructor"), (err) => sqliteUnavailable(String(err)));
2788
+ return sqliteModule;
2789
+ }
2790
+ /** The db file's on-disk state. A symlink or non-regular file is refused
2791
+ * (file-disclosure defense, same rule as io.ts record files); ENOENT is
2792
+ * just "no records yet". Any OTHER lstat failure (EACCES, EIO, …) is
2793
+ * rethrown so reads surface a real filesystem problem instead of
2794
+ * silently reporting an empty collection. */
2795
+ async function dbFileState(absPath) {
2694
2796
  try {
2695
- parsedJson = JSON.parse(raw);
2797
+ return (await lstat(absPath)).isFile() ? "file" : "refused";
2696
2798
  } catch (err) {
2697
- log.warn("collections", "schema.json is not valid JSON, skipping", {
2698
- slug: safeName,
2699
- error: String(err)
2700
- });
2701
- return null;
2799
+ if (isErrorWithCode(err) && err.code === "ENOENT") return "missing";
2800
+ throw err;
2702
2801
  }
2703
- const candidate = source === "feed" ? applyFeedSchemaDefaults(parsedJson, safeName) : parsedJson;
2704
- const parsed = CollectionSchemaZ.safeParse(candidate);
2705
- if (!parsed.success) {
2706
- log.warn("collections", "schema.json failed validation, skipping", {
2707
- slug: safeName,
2708
- issues: parsed.error.issues
2709
- });
2710
- return null;
2802
+ }
2803
+ var CREATE_TABLE = "CREATE TABLE IF NOT EXISTS records (id TEXT PRIMARY KEY, record TEXT NOT NULL)";
2804
+ /** Open the database for one operation, classifying the two unavailable
2805
+ * states so callers can map them honestly (`refused` ⇒ path-escape,
2806
+ * `missing` ⇒ empty / not-found — conflating them would misreport a
2807
+ * containment escape as "item not found"). The containment pre-check runs
2808
+ * BEFORE mkdir even when the file is missing — `isContainedInRoot`
2809
+ * resolves through the closest existing ancestor, so a symlinked-away
2810
+ * parent can never make the recursive mkdir create directories outside
2811
+ * the workspace (same pre/post belt-and-suspenders as io.ts writes). */
2812
+ async function openDb(absPath, workspaceRoot, mode) {
2813
+ const state = await dbFileState(absPath);
2814
+ if (state === "refused") {
2815
+ log.warn("collections", "sqlite database refused: not a regular file", { path: absPath });
2816
+ return { kind: "refused" };
2711
2817
  }
2712
- const schema = parsed.data;
2713
- const acceptance = acceptParsedSchema(schema, {
2714
- source,
2715
- workspaceRoot,
2716
- slug: safeName
2717
- });
2718
- if (!acceptance.ok) {
2719
- log.warn("collections", "schema.json rejected after validation, skipping", {
2720
- slug: safeName,
2721
- reason: acceptance.reason
2722
- });
2723
- return null;
2818
+ if (!isContainedInRoot(path.dirname(absPath), workspaceRoot)) {
2819
+ log.warn("collections", "sqlite refused: database dir escapes workspace via symlink", { path: absPath });
2820
+ return { kind: "refused" };
2821
+ }
2822
+ if (mode === "read" && state === "missing") return { kind: "missing" };
2823
+ if (mode === "write") {
2824
+ await mkdir(path.dirname(absPath), { recursive: true });
2825
+ if (!isContainedInRoot(path.dirname(absPath), workspaceRoot)) {
2826
+ log.warn("collections", "sqlite write refused: database dir escapes workspace via symlink (post-mkdir)", { path: absPath });
2827
+ return { kind: "refused" };
2828
+ }
2724
2829
  }
2830
+ const { DatabaseSync } = await loadSqlite();
2831
+ const database = new DatabaseSync(absPath);
2832
+ database.exec("PRAGMA busy_timeout = 5000");
2833
+ database.exec(CREATE_TABLE);
2725
2834
  return {
2726
- slug: safeName,
2727
- source,
2728
- schema,
2729
- dataDir: acceptance.dataDir,
2730
- ...acceptance.dataSourceFile !== void 0 ? { dataSourceFile: acceptance.dataSourceFile } : {},
2731
- ...acceptance.storageFile !== void 0 ? { storageFile: acceptance.storageFile } : {},
2732
- skillDir: path.join(skillsRoot, safeName)
2835
+ kind: "ok",
2836
+ database
2837
+ };
2838
+ }
2839
+ /** Run `operation` against the database and always close it; unavailable
2840
+ * states resolve through `onUnavailable` so each caller maps `missing`
2841
+ * vs `refused` to its own result kind. */
2842
+ async function withDb(absPath, workspaceRoot, mode, onUnavailable, operation) {
2843
+ const handle = await openDb(absPath, workspaceRoot, mode);
2844
+ if (handle.kind !== "ok") return onUnavailable(handle.kind);
2845
+ try {
2846
+ return await operation(handle.database);
2847
+ } finally {
2848
+ handle.database.close();
2849
+ }
2850
+ }
2851
+ var SQLITE_CONSTRAINT_PRIMARYKEY = 1555;
2852
+ var SQLITE_CONSTRAINT_UNIQUE = 2067;
2853
+ /** node:sqlite throws ERR_SQLITE_ERROR with the SQLite extended result
2854
+ * code on `errcode`. Checked structurally (message text kept only as a
2855
+ * fallback for runtimes that don't expose `errcode`). */
2856
+ function isUniqueConstraintError(err) {
2857
+ if (hasNumberProp(err, "errcode")) return err.errcode === SQLITE_CONSTRAINT_PRIMARYKEY || err.errcode === SQLITE_CONSTRAINT_UNIQUE;
2858
+ return String(err).includes("UNIQUE constraint");
2859
+ }
2860
+ function parseRow(raw) {
2861
+ if (typeof raw !== "string") return null;
2862
+ try {
2863
+ const parsed = JSON.parse(raw);
2864
+ return isRecord(parsed) ? parsed : null;
2865
+ } catch {
2866
+ return null;
2867
+ }
2868
+ }
2869
+ /** One column of a result row. node:sqlite types rows as `unknown`, so a
2870
+ * value that is not a row object yields no column at all. */
2871
+ function readColumn(row, column) {
2872
+ return isRecord(row) ? row[column] : void 0;
2873
+ }
2874
+ function rowsToItems(rows) {
2875
+ return rows.map((row) => parseRow(readColumn(row, "record"))).filter((item) => item !== null);
2876
+ }
2877
+ /** node:sqlite hands back an integer column as `number`, or as `bigint` once
2878
+ * it leaves the safe-integer range — COUNT(*) can be either. */
2879
+ function countRecords(database) {
2880
+ const count = readColumn(database.prepare("SELECT COUNT(*) AS n FROM records").get(), "n");
2881
+ if (typeof count === "number") return count;
2882
+ if (typeof count === "bigint") return Number(count);
2883
+ throw new Error(`sqlite COUNT(*) returned no numeric row count (got ${typeof count})`);
2884
+ }
2885
+ async function sqliteList(absPath, workspaceRoot) {
2886
+ return withDb(absPath, workspaceRoot, "read", () => [], (database) => rowsToItems(database.prepare("SELECT record FROM records ORDER BY id").all()));
2887
+ }
2888
+ async function sqlitePage(absPath, primaryKey, opts, workspaceRoot) {
2889
+ const emptyPage = {
2890
+ items: [],
2891
+ total: 0,
2892
+ truncated: false
2893
+ };
2894
+ return withDb(absPath, workspaceRoot, "read", () => emptyPage, (database) => {
2895
+ const total = countRecords(database);
2896
+ const offset = Math.max(0, opts.offset ?? 0);
2897
+ const limit = opts.limit === void 0 ? -1 : Math.max(0, opts.limit);
2898
+ return {
2899
+ items: projectItemFields(rowsToItems(database.prepare("SELECT record FROM records ORDER BY id LIMIT ? OFFSET ?").all(limit, offset)), opts.fields, primaryKey),
2900
+ total,
2901
+ truncated: false
2902
+ };
2903
+ });
2904
+ }
2905
+ async function sqliteRead(absPath, itemId, workspaceRoot) {
2906
+ const safeId = safeRecordId(itemId);
2907
+ if (safeId === null) return null;
2908
+ return withDb(absPath, workspaceRoot, "read", () => null, (database) => {
2909
+ return parseRow(readColumn(database.prepare("SELECT record FROM records WHERE id = ?").get(safeId), "record"));
2910
+ });
2911
+ }
2912
+ async function sqliteWrite(absPath, itemId, item, opts) {
2913
+ const safeId = safeRecordId(itemId);
2914
+ if (safeId === null) return {
2915
+ kind: "invalid-id",
2916
+ itemId
2917
+ };
2918
+ const outcome = await withDb(absPath, opts.workspaceRoot, "write", () => ({
2919
+ kind: "path-escape",
2920
+ itemId: safeId
2921
+ }), (database) => {
2922
+ const payload = JSON.stringify(item);
2923
+ if (opts.refuseOverwrite) try {
2924
+ database.prepare("INSERT INTO records (id, record) VALUES (?, ?)").run(safeId, payload);
2925
+ } catch (err) {
2926
+ if (isUniqueConstraintError(err)) return {
2927
+ kind: "conflict",
2928
+ itemId: safeId
2929
+ };
2930
+ throw err;
2931
+ }
2932
+ else database.prepare("INSERT INTO records (id, record) VALUES (?, ?) ON CONFLICT(id) DO UPDATE SET record = excluded.record").run(safeId, payload);
2933
+ return {
2934
+ kind: "ok",
2935
+ itemId: safeId,
2936
+ item
2937
+ };
2938
+ });
2939
+ if (outcome.kind === "ok" && opts.slug) publishCollectionChange(collectionChangePayload({
2940
+ slug: opts.slug,
2941
+ ids: [safeId],
2942
+ op: "upsert"
2943
+ }, opts.publishRoot));
2944
+ return outcome;
2945
+ }
2946
+ async function sqliteDelete(absPath, itemId, opts) {
2947
+ const safeId = safeRecordId(itemId);
2948
+ if (safeId === null) return {
2949
+ kind: "invalid-id",
2950
+ itemId
2733
2951
  };
2952
+ const outcome = await withDb(absPath, opts.workspaceRoot, "read", (reason) => reason === "refused" ? {
2953
+ kind: "path-escape",
2954
+ itemId: safeId
2955
+ } : {
2956
+ kind: "not-found",
2957
+ itemId: safeId
2958
+ }, (database) => {
2959
+ const { changes } = database.prepare("DELETE FROM records WHERE id = ?").run(safeId);
2960
+ return Number(changes) === 0 ? {
2961
+ kind: "not-found",
2962
+ itemId: safeId
2963
+ } : {
2964
+ kind: "ok",
2965
+ itemId: safeId
2966
+ };
2967
+ });
2968
+ if (outcome.kind === "ok" && opts.slug) publishCollectionChange(collectionChangePayload({
2969
+ slug: opts.slug,
2970
+ ids: [safeId],
2971
+ op: "delete"
2972
+ }, opts.publishRoot));
2973
+ return outcome;
2734
2974
  }
2735
- async function collectFromDir(skillsRoot, source, workspaceRoot) {
2736
- let entries;
2975
+ /** Best-effort full WAL checkpoint so the MAIN db file alone is a
2976
+ * complete snapshot (committed pages in `<db>-wal` are folded in and the
2977
+ * WAL truncated). Used by `deleteCollection` before archiving. Returns
2978
+ * false on any failure (runtime without node:sqlite, locked db, missing
2979
+ * file) — the caller then archives the sidecar files alongside the db so
2980
+ * no committed data is lost either way. */
2981
+ async function checkpointSqliteDatabase(absPath) {
2737
2982
  try {
2738
- entries = await readdir(skillsRoot);
2739
- } catch (err) {
2740
- if (isErrorWithCode(err) && err.code === "ENOENT") return [];
2741
- log.warn("collections", "failed to list skills dir, returning empty", {
2742
- root: skillsRoot,
2743
- error: String(err)
2744
- });
2745
- return [];
2746
- }
2747
- const results = [];
2748
- for (const name of entries) {
2749
- if (name.startsWith(".")) continue;
2750
- const safeName = safeSlugName(name);
2751
- if (safeName === null) continue;
2752
- const dirPath = path.join(skillsRoot, safeName);
2753
- let dirStat;
2983
+ const { DatabaseSync } = await loadSqlite();
2984
+ const database = new DatabaseSync(absPath);
2754
2985
  try {
2755
- dirStat = await stat(dirPath);
2756
- } catch {
2757
- continue;
2986
+ database.exec("PRAGMA wal_checkpoint(TRUNCATE)");
2987
+ } finally {
2988
+ database.close();
2758
2989
  }
2759
- if (!dirStat.isDirectory()) continue;
2760
- const collection = await loadOneCollection(skillsRoot, safeName, source, workspaceRoot);
2761
- if (collection) results.push(collection);
2990
+ return true;
2991
+ } catch {
2992
+ return false;
2762
2993
  }
2763
- return results;
2764
2994
  }
2765
- /** The user-scope dir this call should scan, or `null` for none. The single
2766
- * place the "explicit override beats the host binding, and either may say
2767
- * none" rule is spelled — `??` cannot express it, because `undefined` there
2768
- * means "ask the host" and would silently re-enable a scope the caller
2769
- * passed `null` to switch off. */
2770
- function resolveUserDir(opts, workspaceRoot) {
2771
- return opts.userSkillsDir !== void 0 ? opts.userSkillsDir : userSkillsDir(workspaceRoot);
2995
+ /** A `storage: sqlite` store over `collection.storageFile`. A schema whose
2996
+ * `storageFile` failed to resolve yields a read-only EMPTY store rather
2997
+ * than a writable one — same fail-closed rule as the CSV store. */
2998
+ function sqliteStoreFor(collection, opts) {
2999
+ const file = collection.storageFile;
3000
+ const key = collection.schema.primaryKey;
3001
+ const slug = opts.slug ?? collection.slug;
3002
+ const root = () => opts.workspaceRoot ?? getWorkspaceRoot();
3003
+ const publishRoot = opts.workspaceRoot;
3004
+ if (file === void 0) return {
3005
+ capabilities: {
3006
+ writable: false,
3007
+ nativeQuery: false,
3008
+ nativePaging: false
3009
+ },
3010
+ list: () => Promise.resolve([]),
3011
+ page: () => Promise.resolve({
3012
+ items: [],
3013
+ total: 0,
3014
+ truncated: false
3015
+ }),
3016
+ read: () => Promise.resolve(null)
3017
+ };
3018
+ return {
3019
+ capabilities: {
3020
+ writable: true,
3021
+ nativeQuery: false,
3022
+ nativePaging: true
3023
+ },
3024
+ list: () => sqliteList(file, root()),
3025
+ page: (pageOpts = {}) => sqlitePage(file, key, pageOpts, root()),
3026
+ read: (itemId) => sqliteRead(file, itemId, root()),
3027
+ write: (itemId, item, writeOpts = {}) => sqliteWrite(file, itemId, item, {
3028
+ workspaceRoot: root(),
3029
+ publishRoot,
3030
+ slug,
3031
+ refuseOverwrite: writeOpts.refuseOverwrite
3032
+ }),
3033
+ delete: (itemId) => sqliteDelete(file, itemId, {
3034
+ workspaceRoot: root(),
3035
+ publishRoot,
3036
+ slug
3037
+ }),
3038
+ watch: async (onChange) => closerFor(await watchSingleFile(file, (base, name) => name.startsWith(base), () => onChange({ kind: "collection" })))
3039
+ };
2772
3040
  }
2773
- /** Discover every schema-driven collection available to this
2774
- * workspace. Project-scope collections override user-scope on slug
2775
- * collision. The `workspaceRoot` override also flows into each
2776
- * collection's dataDir resolution so a tmpdir-scoped test gets
2777
- * dataDirs under the same tmpdir (Codex P1 review on PR #1489 —
2778
- * previously dataDir was always rooted at the live workspacePath
2779
- * regardless of override). */
2780
- async function discoverCollections(opts = {}) {
2781
- const workspaceRoot = opts.workspaceRoot ?? getWorkspaceRoot();
2782
- const userDir = resolveUserDir(opts, workspaceRoot);
2783
- const projectDir = projectSkillsDir(workspaceRoot);
2784
- const feedCollections = await collectFromDir(feedsRoot(workspaceRoot), "feed", workspaceRoot);
2785
- const userCollections = userDir === null ? [] : await collectFromDir(userDir, "user", workspaceRoot);
2786
- const projectCollections = await collectFromDir(projectDir, "project", workspaceRoot);
2787
- const merged = /* @__PURE__ */ new Map();
2788
- for (const entry of feedCollections) merged.set(entry.slug, entry);
2789
- for (const entry of userCollections) merged.set(entry.slug, entry);
2790
- for (const entry of projectCollections) merged.set(entry.slug, entry);
2791
- return [...merged.values()].sort((left, right) => left.slug.localeCompare(right.slug));
3041
+ //#endregion
3042
+ //#region src/collection/server/store.ts
3043
+ /** The file store's stable order: lexicographic by record id (codepoint
3044
+ * compare — locale-independent). `listItems` returns readdir order, which
3045
+ * is filesystem-dependent; paging needs determinism. */
3046
+ function sortByRecordId(items, primaryKey) {
3047
+ return [...items].sort((left, right) => {
3048
+ const leftId = fieldText(left[primaryKey]);
3049
+ const rightId = fieldText(right[primaryKey]);
3050
+ if (leftId < rightId) return -1;
3051
+ return leftId > rightId ? 1 : 0;
3052
+ });
2792
3053
  }
2793
- /** Load one collection by slug. Returns null if the slug is invalid,
2794
- * no matching skill exists, or the schema is malformed. */
2795
- async function loadCollection(slug, opts = {}) {
2796
- const safeName = safeSlugName(slug);
2797
- if (safeName === null) return null;
2798
- const workspaceRoot = opts.workspaceRoot ?? getWorkspaceRoot();
2799
- const userDir = resolveUserDir(opts, workspaceRoot);
2800
- const projectCollection = await loadOneCollection(projectSkillsDir(workspaceRoot), safeName, "project", workspaceRoot);
2801
- if (projectCollection) return projectCollection;
2802
- const userCollection = userDir === null ? null : await loadOneCollection(userDir, safeName, "user", workspaceRoot);
2803
- if (userCollection) return userCollection;
2804
- return loadOneCollection(feedsRoot(workspaceRoot), safeName, "feed", workspaceRoot);
3054
+ /** True when the collection accepts UI/tool writes. A `dataSource`
3055
+ * collection is read-only: updates happen by editing/replacing the
3056
+ * data file itself. Every write entry point checks this BEFORE calling
3057
+ * `writeItem`/`deleteItem` — server-enforced, not just UI-hidden. */
3058
+ function collectionWritable(collection) {
3059
+ return !isReadOnlySchema(collection.schema);
2805
3060
  }
2806
- function toSummary(collection) {
3061
+ /** The one-line refusal write paths surface (HTTP 405 / MCP error text). */
3062
+ function readOnlyRefusal(slug) {
3063
+ return `collection '${slug}' is read-only (backed by an external dataSource) — update the data file itself instead`;
3064
+ }
3065
+ /** A `dataSource` store over `file` (CSV row order; DuckDB-native query).
3066
+ * A schema whose `dataSourceFile` failed to resolve yields a read-only
3067
+ * EMPTY store rather than falling back to the (writable) file store — a
3068
+ * half-loaded read-only collection must never become writable. */
3069
+ function csvStoreFor(collection, opts) {
3070
+ const file = collection.dataSourceFile;
3071
+ const key = collection.schema.primaryKey;
3072
+ const listAll = () => file === void 0 ? Promise.resolve({
3073
+ items: [],
3074
+ truncated: false
3075
+ }) : csvList(file, key, opts.workspaceRoot);
2807
3076
  return {
2808
- slug: collection.slug,
2809
- title: collection.schema.title,
2810
- icon: collection.schema.icon,
2811
- source: collection.source,
2812
- ...collection.schema.dataSource !== void 0 ? { readonly: true } : {}
3077
+ capabilities: {
3078
+ writable: false,
3079
+ nativeQuery: true,
3080
+ nativePaging: false
3081
+ },
3082
+ list: () => listAll().then((result) => result.items),
3083
+ page: (pageOpts = {}) => listAll().then((result) => pageFromFullRead(result.items, pageOpts, key, result.truncated)),
3084
+ read: (itemId) => file === void 0 ? Promise.resolve(null) : csvRead(file, key, itemId, opts.workspaceRoot),
3085
+ query: (query) => file === void 0 ? Promise.resolve([]) : csvRunQuery(file, key, query, opts.workspaceRoot),
3086
+ ...file === void 0 ? {} : { watch: async (onChange) => closerFor(await watchSingleFile(file, () => false, () => onChange({ kind: "collection" }))) }
2813
3087
  };
2814
3088
  }
2815
- function toDetail(collection) {
3089
+ /** The classic file store over `<dataDir>/<itemId>.json` records. */
3090
+ function fileStoreFor(collection, opts) {
3091
+ const key = collection.schema.primaryKey;
3092
+ const ioOpts = {
3093
+ ...opts,
3094
+ slug: opts.slug ?? collection.slug
3095
+ };
2816
3096
  return {
2817
- ...toSummary(collection),
2818
- schema: collection.schema
3097
+ capabilities: {
3098
+ writable: true,
3099
+ nativeQuery: false,
3100
+ nativePaging: false
3101
+ },
3102
+ list: () => listItems(collection.dataDir, opts),
3103
+ page: async (pageOpts = {}) => pageFromFullRead(sortByRecordId(await listItems(collection.dataDir, opts), key), pageOpts, key, false),
3104
+ read: (itemId) => readItem(collection.dataDir, itemId, opts),
3105
+ write: (itemId, item, writeOpts = {}) => writeItem(collection.dataDir, itemId, item, {
3106
+ ...ioOpts,
3107
+ refuseOverwrite: writeOpts.refuseOverwrite
3108
+ }),
3109
+ delete: (itemId) => deleteItem(collection.dataDir, itemId, ioOpts),
3110
+ watch: async (onChange) => closerFor(await watchDirectory(collection.dataDir, (name) => name.endsWith(".json") && !name.startsWith("."), (filename) => onChange(filename === null ? { kind: "collection" } : {
3111
+ kind: "item",
3112
+ itemId: filename.slice(0, -5)
3113
+ })))
2819
3114
  };
2820
3115
  }
3116
+ var storeFactories = /* @__PURE__ */ new Map([
3117
+ ["file", fileStoreFor],
3118
+ ["csv", csvStoreFor],
3119
+ ["sqlite", sqliteStoreFor],
3120
+ ["firestore", firestoreStoreFor]
3121
+ ]);
3122
+ /** Pick the store implementation for a discovered collection via the
3123
+ * factory registry. An unknown kind cannot normally reach here (the
3124
+ * schema's `StorageZ` union gates it), so the throw is a loud invariant
3125
+ * breach, not a user-facing path. */
3126
+ function storeFor(collection, opts = {}) {
3127
+ const kind = storageKindFor(collection.schema);
3128
+ const factory = storeFactories.get(kind);
3129
+ if (!factory) throw new Error(`no store factory registered for storage kind '${kind}'`);
3130
+ return factory(collection, opts);
3131
+ }
2821
3132
  //#endregion
2822
- export { log as $, deleteItem as A, resolveDataDir as B, compileCsvQuery as C, MAX_QUERY_ROWS as D, DEFAULT_QUERY_ROWS as E, resolveCreateItemId as F, archiveDir as G, safeRecordId as H, writeItem as I, collectionsRegistriesConfigPath as J, collectionChangeKey as K, SCHEMA_FILE as L, isRegularFile as M, listItems as N, BackendUnavailableError as O, readItem as P, localCollectionKey as Q, isContainedInRoot as R, queryCsv as S, CollectionQueryZ as T, safeSlugName as U, resolveTemplatePath as V, COLLECTION_ROOT_REQUIRED as W, getWorkspaceRoot as X, configureCollectionHost as Y, isPresetSlug as Z, csvRowToItem as _, toDetail as a, skillsStagingDir as at, encodeCsvRecordId as b, resolveMutateSet as c, createHostSlot as ct, storeFor as d, peekWorkspaceRoot as et, checkpointSqliteDatabase as f, cacheDir as g, MAX_CSV_ROWS as h, resolvePrimaryField as i, sharedCollectionChangePayload as it, generateItemId as j, isBackendUnavailable as k, collectionWritable as l, projectItemFields as m, discoverCollections as n, publishCollectionChange as nt, toSummary as o, stagingSkillDir as ot, pageFromFullRead as p, collectionChangePayload as q, loadCollection as r, setCollectionChangePublisher as rt, CollectionSchemaZ as s, createForwardingLogger as st, acceptParsedSchema as t, projectSkillsDir as tt, readOnlyRefusal as u, decodeCsvRecordId as v, compileJsonlQuery as w, normalizeCsvValue as x, dedupeByRecordId as y, itemFilePath as z };
3133
+ export { collectionsRegistriesConfigPath as $, toSummary as A, SCHEMA_FILE as B, resolveCreateItemId as C, loadCollection as D, discoverCollections as E, loadAppManifest as F, safeRecordId as G, itemFilePath as H, parseAppManifest as I, isBackendUnavailable as J, safeSlugName as K, sharedItemsPath as L, resolveMutateSet as M, APP_MANIFEST_FILE as N, resolvePrimaryField as O, appManifestReason as P, collectionChangePayload as Q, pageFromFullRead as R, readItem as S, acceptParsedSchema as T, resolveDataDir as U, isContainedInRoot as V, resolveTemplatePath as W, archiveDir as X, COLLECTION_ROOT_REQUIRED as Y, collectionChangeKey as Z, MAX_QUERY_ROWS as _, MAX_CSV_ROWS as a, log as at, isRegularFile as b, decodeCsvRecordId as c, publishCollectionChange as ct, normalizeCsvValue as d, sharedCollectionChangePayload as dt, configureCollectionHost as et, queryCsv as f, skillsStagingDir as ft, DEFAULT_QUERY_ROWS as g, CollectionQueryZ as h, createHostSlot as ht, checkpointSqliteDatabase as i, localCollectionKey as it, CollectionSchemaZ as j, toDetail as k, dedupeByRecordId as l, setCollectionChangePublisher as lt, compileJsonlQuery as m, createForwardingLogger as mt, readOnlyRefusal as n, getWorkspaceRoot as nt, cacheDir as o, peekWorkspaceRoot as ot, compileCsvQuery as p, stagingSkillDir as pt, BackendUnavailableError as q, storeFor as r, isPresetSlug as rt, csvRowToItem as s, projectSkillsDir as st, collectionWritable as t, firestoreHandle as tt, encodeCsvRecordId as u, setFirestoreAccessor as ut, deleteItem as v, writeItem as w, listItems as x, generateItemId as y, projectItemFields as z };
2823
3134
 
2824
- //# sourceMappingURL=discovery-DH9wweuj.js.map
3135
+ //# sourceMappingURL=store-_61sO8K8.js.map