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