@mulmoclaude/core 3.5.0 → 3.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/assets/helps/collection-skills.md +49 -1
  2. package/assets/helps/error-recovery.md +53 -0
  3. package/dist/calendarGrid-CQ8MVSRb.js.map +1 -1
  4. package/dist/calendarGrid-DGILaVxI.cjs.map +1 -1
  5. package/dist/collection/core/schema.d.ts +8 -1
  6. package/dist/collection/core/schemaZ.d.ts +36 -26
  7. package/dist/collection/firestore.cjs +51 -0
  8. package/dist/collection/firestore.cjs.map +1 -0
  9. package/dist/collection/firestore.d.ts +1 -0
  10. package/dist/collection/firestore.js +50 -0
  11. package/dist/collection/firestore.js.map +1 -0
  12. package/dist/collection/registry/server/index.cjs +2 -2
  13. package/dist/collection/registry/server/index.js +2 -2
  14. package/dist/collection/server/appManifest.d.ts +53 -0
  15. package/dist/collection/server/delete.d.ts +10 -0
  16. package/dist/collection/server/discoveredCollection.d.ts +10 -0
  17. package/dist/collection/server/discovery.d.ts +1 -0
  18. package/dist/collection/server/firestoreDocs.d.ts +39 -0
  19. package/dist/collection/server/firestoreStore.d.ts +14 -0
  20. package/dist/collection/server/host.d.ts +41 -0
  21. package/dist/collection/server/index.cjs +9 -2
  22. package/dist/collection/server/index.d.ts +4 -1
  23. package/dist/collection/server/index.js +3 -3
  24. package/dist/collection-watchers/index.cjs +132 -41
  25. package/dist/collection-watchers/index.cjs.map +1 -1
  26. package/dist/collection-watchers/index.js +132 -41
  27. package/dist/collection-watchers/index.js.map +1 -1
  28. package/dist/collection-watchers/reconciler.d.ts +1 -1
  29. package/dist/{discovery-DH9wweuj.js → discovery-CdkaURVY.js} +368 -57
  30. package/dist/discovery-CdkaURVY.js.map +1 -0
  31. package/dist/{discovery-Ck4AqikY.cjs → discovery-Cw5xWzZD.cjs} +407 -54
  32. package/dist/discovery-Cw5xWzZD.cjs.map +1 -0
  33. package/dist/feeds/server/index.cjs +2 -2
  34. package/dist/feeds/server/index.js +2 -2
  35. package/dist/google/index.cjs +1 -1
  36. package/dist/google/index.js +1 -1
  37. package/dist/{server-5EMj3naj.cjs → server-9mFaGLdq.cjs} +23 -4
  38. package/dist/server-9mFaGLdq.cjs.map +1 -0
  39. package/dist/{server-BiRLLMpW.js → server-DdHaX_Uu.js} +23 -4
  40. package/dist/server-DdHaX_Uu.js.map +1 -0
  41. package/package.json +7 -1
  42. package/dist/discovery-Ck4AqikY.cjs.map +0 -1
  43. package/dist/discovery-DH9wweuj.js.map +0 -1
  44. package/dist/server-5EMj3naj.cjs.map +0 -1
  45. package/dist/server-BiRLLMpW.js.map +0 -1
@@ -1,9 +1,9 @@
1
1
  import { a as isErrorWithCode, l as isUnknownArray, r as hasNumberProp, s as isRecord } from "./dist-D8zokgGo.js";
2
2
  import { n as writeFileAtomic, t as canonicalRoot } from "./root-BMroU_mB.js";
3
- import { A as AGENT_INGEST_KIND, C as sharedCollectionKey, D as isSafeRecordId, I as isReadOnlySchema, L as storageKindFor, M as FEED_SCHEDULES, N as INGEST_KINDS, O as isSafeSlug, R as fieldText, T as SAFE_SLUG_PATTERN, b as localCollectionKeyOf, j as COMPUTED_TYPES, w as SAFE_RECORD_ID_PATTERN, z as fieldTextOrNull } from "./calendarGrid-CQ8MVSRb.js";
3
+ import { A as AGENT_INGEST_KIND, C as sharedCollectionKey, D as isSafeRecordId, I as isReadOnlySchema, L as storageKindFor, M as FEED_SCHEDULES, N as INGEST_KINDS, O as isSafeSlug, R as fieldText, T as SAFE_SLUG_PATTERN, b as localCollectionKeyOf, j as COMPUTED_TYPES, w as SAFE_RECORD_ID_PATTERN, y as isValidCollectionName, z as fieldTextOrNull } from "./calendarGrid-CQ8MVSRb.js";
4
4
  import { n as projectRecordFields } from "./project-B0j_Vf_Q.js";
5
5
  import { n as isSafeCustomViewI18nPath, r as isSafeCustomViewPath, t as isSafeActionTemplatePath } from "./templatePath-k_WNbL_Q.js";
6
- import { realpathSync, watch } from "node:fs";
6
+ import { readFileSync, realpathSync, watch } from "node:fs";
7
7
  import path from "node:path";
8
8
  import { createHash, randomBytes } from "node:crypto";
9
9
  import { lstat, mkdir, open, readFile, readdir, rename, stat, unlink, writeFile } from "node:fs/promises";
@@ -103,6 +103,7 @@ function collectionChangeKey(payload, fallbackRoot) {
103
103
  }
104
104
  var hostSlot = createHostSlot("@mulmoclaude/core/collection/server: configureCollectionHost()");
105
105
  var changePublisher = null;
106
+ var firestoreAccessor = null;
106
107
  /** Wire the engine to a host. Call once at server startup, before any
107
108
  * collection storage operation. Re-binding to a *different* host throws —
108
109
  * silently redirecting later filesystem operations to another workspace
@@ -126,6 +127,25 @@ function setCollectionChangePublisher(publish) {
126
127
  function publishCollectionChange(payload) {
127
128
  changePublisher?.(payload);
128
129
  }
130
+ /** Wire the accessor for the host's authenticated Firestore session.
131
+ *
132
+ * Separate from `configureCollectionHost` for the same reason
133
+ * `setCollectionChangePublisher` is: the host binding is set at the top of
134
+ * server startup, but this session doesn't exist until the user connects
135
+ * remote-host (and closes again on disconnect), so it cannot be part of a
136
+ * one-shot binding. Optional — left unwired, only shared collections are
137
+ * affected, and they report "not connected". Pass `null` to detach. */
138
+ function setFirestoreAccessor(accessor) {
139
+ firestoreAccessor = accessor;
140
+ }
141
+ /** The host's live Firestore access, or null when there is no session (or the
142
+ * host never wired one — every non-shared backend leaves it unset).
143
+ * Callers MUST surface null as an actionable "connect remote-host first",
144
+ * never as an empty result: silence would be indistinguishable from a
145
+ * collection that genuinely has no records. */
146
+ function firestoreHandle() {
147
+ return firestoreAccessor?.() ?? null;
148
+ }
129
149
  function requireHost() {
130
150
  return hostSlot.get();
131
151
  }
@@ -190,6 +210,18 @@ function isPresetSlug(slug) {
190
210
  * without a workspace root. */
191
211
  var log = createForwardingLogger(() => hostSlot.peek()?.log ?? null);
192
212
  //#endregion
213
+ //#region src/collection/server/backendAvailability.ts
214
+ /** Thrown by a store when its engine or session cannot serve the request. */
215
+ var BackendUnavailableError = class extends Error {
216
+ constructor(message) {
217
+ super(message);
218
+ this.name = "BackendUnavailableError";
219
+ }
220
+ };
221
+ function isBackendUnavailable(err) {
222
+ return err instanceof BackendUnavailableError;
223
+ }
224
+ //#endregion
193
225
  //#region src/collection/server/paths.ts
194
226
  var SCHEMA_FILE = "schema.json";
195
227
  /** Sanitise a user-supplied slug into a safe directory-name leaf.
@@ -301,6 +333,280 @@ function resolveTemplatePath(skillDir, templateRelPath) {
301
333
  return resolved;
302
334
  }
303
335
  //#endregion
336
+ //#region src/collection/server/storePage.ts
337
+ /** Project `fields` (+ the primary key, always) out of each record. Thin
338
+ * server-typed alias over the shared isomorphic `projectRecordFields`
339
+ * (../core/project.ts) — kept as the store layer's exported name. */
340
+ function projectItemFields(items, fields, primaryKey) {
341
+ return projectRecordFields(items, fields, primaryKey);
342
+ }
343
+ /** Slice + project an already-ordered full read into a `ListPage` — the
344
+ * shared emulation for stores without native paging. Pure, exported for
345
+ * tests. `limit: 0` is a valid "count only" page. */
346
+ function pageFromFullRead(items, opts, primaryKey, truncated) {
347
+ const offset = Math.max(0, opts.offset ?? 0);
348
+ const end = opts.limit === void 0 ? items.length : offset + Math.max(0, opts.limit);
349
+ return {
350
+ items: projectItemFields(items.slice(offset, end), opts.fields, primaryKey),
351
+ total: items.length,
352
+ truncated
353
+ };
354
+ }
355
+ //#endregion
356
+ //#region src/collection/server/firestoreStore.ts
357
+ /** What every operation throws when there is no live session. Worded as an
358
+ * instruction because it surfaces straight to the user and the agent. */
359
+ var NOT_CONNECTED = "shared collection unavailable: connect remote-host first — these records live in the app's Firestore, not in the workspace, so nothing can be read or written while the session is closed";
360
+ /** What a schema declaring `storage.type: "firestore"` must have had resolved
361
+ * for it before it can be served. Its absence is a programming error here, not
362
+ * a user-facing state: discovery REFUSES such a schema when the repository
363
+ * declares no `aid`, so a collection that reached this store has one. */
364
+ var NO_APP = "shared collection has no app id — discovery should have refused this schema; check that the repository's app.json declares an `aid`";
365
+ /** The records subcollection of one shared collection.
366
+ *
367
+ * Takes a KEY, never loose strings, and the key is the only way to reach this
368
+ * function. `sharedCollectionKey` is where the name rule lives (the charset a
369
+ * Firestore document id, a pubsub channel segment and the completion-bell id
370
+ * must all survive), so building a path cannot be a way around it. */
371
+ function sharedItemsPath(key) {
372
+ return `apps/${key.aid}/collections/${key.cid}/items`;
373
+ }
374
+ /** The collection's identity, from what discovery resolved. Throws on a
375
+ * missing `appId` — see NO_APP. */
376
+ function keyOf(collection) {
377
+ if (collection.appId === void 0) throw new Error(NO_APP);
378
+ return sharedCollectionKey(collection.appId, collection.slug);
379
+ }
380
+ function requireHandle() {
381
+ const handle = firestoreHandle();
382
+ if (handle === null) throw new BackendUnavailableError(NOT_CONNECTED);
383
+ return handle;
384
+ }
385
+ /** Firestore's own refusal, named.
386
+ *
387
+ * `permission-denied` is the failure a shared collection has most often and
388
+ * the one the SDK explains worst ("Missing or insufficient permissions") — it
389
+ * says nothing about WHO was refused, which is the only fact that leads to a
390
+ * fix. Authorization here is the app's member roster, keyed by email, so the
391
+ * signed-in address is what the app's owner needs in order to add it. This is
392
+ * the whole reason `FirestoreHandle` carries `email`.
393
+ *
394
+ * Reported as a `BackendUnavailableError` deliberately, even though it is a
395
+ * refusal rather than an outage: the layers above catch broadly, and without a
396
+ * type to test, `store.read(...).catch(() => null)` reports "record missing"
397
+ * and an ontology count reports 0 — a denial would read as an empty
398
+ * collection, which is the exact confusion this backend refuses to create. */
399
+ function isPermissionDenied(err) {
400
+ return isRecord(err) && err.code === "permission-denied";
401
+ }
402
+ function deniedMessage(key, email) {
403
+ return `permission denied on shared collection '${key.cid}' of app '${key.aid}' — signed in as ${email}. A shared collection is authorized by the app's member roster (by email), so this address needs a role for '${key.cid}' (or '*'); only the app's owner can add it.`;
404
+ }
405
+ /** Run one SDK call, translating a roster denial. Every read and write goes
406
+ * through this — a denial reaching one path and not another would mean the
407
+ * message a user sees depends on which screen they were on. */
408
+ async function guarded(key, email, run) {
409
+ try {
410
+ return await run();
411
+ } catch (err) {
412
+ if (!isPermissionDenied(err)) throw err;
413
+ throw new BackendUnavailableError(deniedMessage(key, email));
414
+ }
415
+ }
416
+ /** A stored document's fields → a record. A document written by hand (or by an
417
+ * older version) can hold anything, so a non-object is dropped rather than
418
+ * surfaced as a broken record — the same fail-soft the file store applies to
419
+ * an unparseable `.json`. */
420
+ function toItem(data) {
421
+ return isRecord(data) ? data : null;
422
+ }
423
+ /** Record ids are validated with the SAME helper every other backend uses.
424
+ * Firestore would accept ids the file store refuses, but a record should stay
425
+ * portable between backends — and an id that can't round-trip to a filename
426
+ * would break an export back to a file collection. */
427
+ function withSafeId(itemId, onInvalid, run) {
428
+ const safeId = safeRecordId(itemId);
429
+ if (safeId === null) return onInvalid();
430
+ return run(safeId, requireHandle());
431
+ }
432
+ async function firestoreList(key) {
433
+ const { docs, email } = requireHandle();
434
+ return (await guarded(key, email, () => docs.list(sharedItemsPath(key)))).map((entry) => toItem(entry.data)).filter((item) => item !== null);
435
+ }
436
+ /** Paging is emulated over a full ordered read rather than pushed into
437
+ * Firestore: `offset` has no server-side form there (the cursor API needs the
438
+ * preceding document, which a stateless offset/limit call doesn't have), and
439
+ * `total` needs the full count anyway. Hence `nativePaging: false` — the
440
+ * capability is honest about the cost. */
441
+ async function firestorePage(key, primaryKey, opts) {
442
+ const items = await firestoreList(key);
443
+ const offset = Math.max(0, opts.offset ?? 0);
444
+ return {
445
+ items: projectItemFields(opts.limit === void 0 ? items.slice(offset) : items.slice(offset, offset + Math.max(0, opts.limit)), opts.fields, primaryKey),
446
+ total: items.length,
447
+ truncated: false
448
+ };
449
+ }
450
+ async function firestoreRead(key, itemId) {
451
+ return withSafeId(itemId, () => Promise.resolve(null), async (safeId, { docs, email }) => toItem(await guarded(key, email, () => docs.get(sharedItemsPath(key), safeId))));
452
+ }
453
+ /** Publish the "records changed" ping for a shared collection.
454
+ *
455
+ * `sharedCollectionChangePayload` NEVER stamps a root, and that matters beyond
456
+ * tidiness: this payload is relayed to the browser and on into an
457
+ * LLM-generated custom-view iframe, so a filesystem path on it would be a
458
+ * disclosure. The type makes it unreachable rather than trusting the caller. */
459
+ function publishShared(key, ids, operation) {
460
+ publishCollectionChange(sharedCollectionChangePayload({
461
+ slug: key.cid,
462
+ ids,
463
+ op: operation
464
+ }, key.aid));
465
+ }
466
+ async function firestoreWrite(key, itemId, item, opts) {
467
+ return withSafeId(itemId, () => Promise.resolve({
468
+ kind: "invalid-id",
469
+ itemId
470
+ }), async (safeId, { docs, email }) => {
471
+ const collectionPath = sharedItemsPath(key);
472
+ if (opts.refuseOverwrite) {
473
+ if (!await guarded(key, email, () => docs.create(collectionPath, safeId, item))) return {
474
+ kind: "conflict",
475
+ itemId: safeId
476
+ };
477
+ } else await guarded(key, email, () => docs.set(collectionPath, safeId, item));
478
+ if (opts.slug) publishShared(key, [safeId], "upsert");
479
+ return {
480
+ kind: "ok",
481
+ itemId: safeId,
482
+ item
483
+ };
484
+ });
485
+ }
486
+ async function firestoreDelete(key, itemId, opts) {
487
+ return withSafeId(itemId, () => Promise.resolve({
488
+ kind: "invalid-id",
489
+ itemId
490
+ }), async (safeId, { docs, email }) => {
491
+ if (!await guarded(key, email, () => docs.delete(sharedItemsPath(key), safeId))) return {
492
+ kind: "not-found",
493
+ itemId: safeId
494
+ };
495
+ if (opts.slug) publishShared(key, [safeId], "delete");
496
+ return {
497
+ kind: "ok",
498
+ itemId: safeId
499
+ };
500
+ });
501
+ }
502
+ /** The store factory registered for `storage.type === "firestore"`.
503
+ * Synchronous and connection-agnostic by contract — see the header. */
504
+ function firestoreStoreFor(collection, opts) {
505
+ const { primaryKey } = collection.schema;
506
+ const ioOpts = {
507
+ ...opts,
508
+ slug: opts.slug ?? collection.slug
509
+ };
510
+ return {
511
+ capabilities: {
512
+ writable: true,
513
+ nativeQuery: false,
514
+ nativePaging: false
515
+ },
516
+ list: async () => firestoreList(keyOf(collection)),
517
+ page: async (pageOpts = {}) => firestorePage(keyOf(collection), primaryKey, pageOpts),
518
+ read: async (itemId) => firestoreRead(keyOf(collection), itemId),
519
+ write: async (itemId, item, writeOpts = {}) => firestoreWrite(keyOf(collection), itemId, item, {
520
+ ...ioOpts,
521
+ refuseOverwrite: writeOpts.refuseOverwrite
522
+ }),
523
+ delete: async (itemId) => firestoreDelete(keyOf(collection), itemId, ioOpts)
524
+ };
525
+ }
526
+ //#endregion
527
+ //#region src/collection/server/appManifest.ts
528
+ /** The app declaration's filename, at the repository root. */
529
+ var APP_MANIFEST_FILE = "app.json";
530
+ /** Read `<root>/app.json` and return its `aid`.
531
+ *
532
+ * SYNCHRONOUS on purpose. The caller is `acceptParsedSchema`, which is sync
533
+ * and is shared by discovery and `manageCollection`'s `putSchema` precisely so
534
+ * that a schema which would be skipped on the next discovery cannot be written
535
+ * as if it were fine. Making this async would split that gate in two, and the
536
+ * half that lost the check is the half the author sees. The file is a few
537
+ * hundred bytes and is read once per firestore collection per discovery pass.
538
+ *
539
+ * Never cached. `app.json` is edited by hand and by the agent, and a cache
540
+ * here would mean the app a collection points at is whatever it was when the
541
+ * server started. */
542
+ function loadAppManifest(root) {
543
+ let raw;
544
+ try {
545
+ raw = readFileSync(path.join(root, APP_MANIFEST_FILE), "utf-8");
546
+ } catch (err) {
547
+ if (isErrorWithCode(err) && err.code === "ENOENT") return {
548
+ ok: false,
549
+ kind: "missing"
550
+ };
551
+ return {
552
+ ok: false,
553
+ kind: "unreadable",
554
+ detail: String(err)
555
+ };
556
+ }
557
+ return parseAppManifest(raw);
558
+ }
559
+ /** The parse half, exported so it can be tested without a filesystem.
560
+ *
561
+ * `aid` is validated with `isValidCollectionName` — the SAME predicate the
562
+ * `CollectionKey` constructors apply — rather than a rule of its own. An `aid`
563
+ * is re-encoded downstream as a Firestore document id, a pubsub channel
564
+ * segment and a cache key, each with a different character that would break
565
+ * it; one rule, stated once, is what keeps those layers from disagreeing.
566
+ * Rejecting here rather than at `sharedCollectionKey` only changes WHERE the
567
+ * author is told: a reason on the collection they wrote, instead of a throw
568
+ * from inside a store call. */
569
+ function parseAppManifest(raw) {
570
+ let parsed;
571
+ try {
572
+ parsed = JSON.parse(raw);
573
+ } catch (err) {
574
+ return {
575
+ ok: false,
576
+ kind: "malformed",
577
+ detail: `not valid JSON (${String(err)})`
578
+ };
579
+ }
580
+ if (!isRecord(parsed)) return {
581
+ ok: false,
582
+ kind: "malformed",
583
+ detail: "is not a JSON object"
584
+ };
585
+ const { aid } = parsed;
586
+ if (typeof aid !== "string" || aid.length === 0) return {
587
+ ok: false,
588
+ kind: "malformed",
589
+ detail: "declares no `aid` string"
590
+ };
591
+ if (!isValidCollectionName(aid)) return {
592
+ ok: false,
593
+ kind: "malformed",
594
+ detail: `\`aid\` '${aid}' is not a valid app id`
595
+ };
596
+ return {
597
+ ok: true,
598
+ manifest: { aid }
599
+ };
600
+ }
601
+ /** The failure as the one line an author can act on. Kept next to the failure
602
+ * type so a new variant cannot be added without wording it. */
603
+ function appManifestReason(failure, root) {
604
+ const manifestPath = path.join(root, APP_MANIFEST_FILE);
605
+ if (failure.kind === "missing") return `a shared collection needs an app: create ${manifestPath} declaring an \`aid\``;
606
+ if (failure.kind === "unreadable") return `cannot read ${manifestPath}: ${failure.detail}`;
607
+ return `${manifestPath} ${failure.detail}`;
608
+ }
609
+ //#endregion
304
610
  //#region src/collection/server/io.ts
305
611
  /** True iff `filePath` exists and is a regular file (NOT a symlink).
306
612
  * Defends `listItems` / `readItem` against `*.json` symlinks placed
@@ -523,18 +829,6 @@ function resolveCreateItemId(schema, record) {
523
829
  return typeof primaryRaw === "string" && primaryRaw.length > 0 ? primaryRaw : null;
524
830
  }
525
831
  //#endregion
526
- //#region src/collection/server/backendAvailability.ts
527
- /** Thrown by a store when its engine or session cannot serve the request. */
528
- var BackendUnavailableError = class extends Error {
529
- constructor(message) {
530
- super(message);
531
- this.name = "BackendUnavailableError";
532
- }
533
- };
534
- function isBackendUnavailable(err) {
535
- return err instanceof BackendUnavailableError;
536
- }
537
- //#endregion
538
832
  //#region src/collection/core/queryZ.ts
539
833
  /** Result-column aliases double as SQL identifiers and JSON keys — keep
540
834
  * them to a conservative identifier charset so neither side needs
@@ -1128,26 +1422,6 @@ function closerFor(handle) {
1128
1422
  return handle === null ? null : () => handle.close();
1129
1423
  }
1130
1424
  //#endregion
1131
- //#region src/collection/server/storePage.ts
1132
- /** Project `fields` (+ the primary key, always) out of each record. Thin
1133
- * server-typed alias over the shared isomorphic `projectRecordFields`
1134
- * (../core/project.ts) — kept as the store layer's exported name. */
1135
- function projectItemFields(items, fields, primaryKey) {
1136
- return projectRecordFields(items, fields, primaryKey);
1137
- }
1138
- /** Slice + project an already-ordered full read into a `ListPage` — the
1139
- * shared emulation for stores without native paging. Pure, exported for
1140
- * tests. `limit: 0` is a valid "count only" page. */
1141
- function pageFromFullRead(items, opts, primaryKey, truncated) {
1142
- const offset = Math.max(0, opts.offset ?? 0);
1143
- const end = opts.limit === void 0 ? items.length : offset + Math.max(0, opts.limit);
1144
- return {
1145
- items: projectItemFields(items.slice(offset, end), opts.fields, primaryKey),
1146
- total: items.length,
1147
- truncated
1148
- };
1149
- }
1150
- //#endregion
1151
1425
  //#region src/collection/server/sqliteStore.ts
1152
1426
  /** A constructor's parameter and return types are not observable at runtime,
1153
1427
  * so the check stops at "DatabaseSync is constructible" — the only member of
@@ -1497,7 +1771,8 @@ function fileStoreFor(collection, opts) {
1497
1771
  var storeFactories = /* @__PURE__ */ new Map([
1498
1772
  ["file", fileStoreFor],
1499
1773
  ["csv", csvStoreFor],
1500
- ["sqlite", sqliteStoreFor]
1774
+ ["sqlite", sqliteStoreFor],
1775
+ ["firestore", firestoreStoreFor]
1501
1776
  ]);
1502
1777
  /** Pick the store implementation for a discovered collection via the
1503
1778
  * factory registry. An unknown kind cannot normally reach here (the
@@ -2353,15 +2628,19 @@ var DataSourceZ = z.object({
2353
2628
  /** Alternative WRITABLE storage backend for a collection's records —
2354
2629
  * unlike `dataSource` (external read-only file), a `storage` collection
2355
2630
  * behaves like a normal writable collection; only where the rows live
2356
- * changes. v1: `sqlite` — records in a single SQLite database file
2357
- * (`node:sqlite`, one JSON record per row keyed by the primaryKey).
2358
- * `path` is workspace-relative and containment-checked exactly like
2359
- * `dataPath`. The store factory registry (`server/store.ts`) picks the
2360
- * implementation by `type` (plans/done/refactor-storage-virtualization.md). */
2361
- var StorageZ = z.object({
2631
+ * changes. The store factory registry (`server/store.ts`) picks the
2632
+ * implementation by `type` (plans/done/refactor-storage-virtualization.md).
2633
+ *
2634
+ * A discriminated union rather than one shape with optional keys, because
2635
+ * only the sqlite variant is a workspace FILE: its `path` is
2636
+ * workspace-relative and containment-checked exactly like `dataPath`, while
2637
+ * the firestore variant has no path to check — its records are not on this
2638
+ * machine at all. Optional keys would let each arm accept the other's, and
2639
+ * the compiler would stop being the thing that tells you which. */
2640
+ var StorageZ = z.discriminatedUnion("type", [z.object({
2362
2641
  type: z.literal("sqlite"),
2363
2642
  path: z.string().min(1)
2364
- });
2643
+ }), z.object({ type: z.literal("firestore") }).strict()]);
2365
2644
  var BareCollectionSchemaZ = z.object({
2366
2645
  title: z.string().min(1),
2367
2646
  icon: z.string().min(1),
@@ -2647,16 +2926,45 @@ function acceptParsedSchema(schema, opts) {
2647
2926
  dataSourceFile
2648
2927
  };
2649
2928
  }
2650
- if (schema.storage !== void 0) {
2651
- const storageFile = resolveDataDir(schema.storage.path, opts.workspaceRoot);
2929
+ if (schema.storage !== void 0) return acceptStorageSchema(schema.storage, opts);
2930
+ const dataDir = resolveDataDir(schema.dataPath ?? "", opts.workspaceRoot);
2931
+ if (dataDir === null) return {
2932
+ ok: false,
2933
+ reason: `dataPath '${schema.dataPath}' escapes the workspace`
2934
+ };
2935
+ return {
2936
+ ok: true,
2937
+ dataDir
2938
+ };
2939
+ }
2940
+ /** The `storage` arm of the acceptance gate. Every storage backend gets the
2941
+ * conventional phantom dataDir; what differs is what else has to resolve
2942
+ * before the collection can exist at all.
2943
+ *
2944
+ * A FILE-backed backend (sqlite) resolves and containment-checks a
2945
+ * `storageFile`. A SHARED one (firestore) has no path on this machine — it
2946
+ * resolves an IDENTITY instead: the `aid` from the repository's `app.json`,
2947
+ * which together with the slug as `cid` names `apps/{aid}/collections/{cid}`.
2948
+ *
2949
+ * Resolving it HERE, once, is the point. The store then receives a settled
2950
+ * `(aid, cid)` and never reads `app.json` itself — otherwise the questions of
2951
+ * caching, staleness and what to do when the file is missing would be decided
2952
+ * inside a read path, where the only cheap answer is to return nothing, and
2953
+ * "this collection is misconfigured" would reach the user as "this collection
2954
+ * is empty". A missing or malformed `app.json` is a CONFIGURATION error, so it
2955
+ * is reported the same way an escaping `storage.path` is: the schema is
2956
+ * refused, with a reason naming the file to create. */
2957
+ function acceptStorageSchema(storage, opts) {
2958
+ const dataDir = resolveDataDir(conventionalDataPath(opts.slug), opts.workspaceRoot);
2959
+ if (dataDir === null) return {
2960
+ ok: false,
2961
+ reason: `slug '${opts.slug}' yields no workspace-contained data dir`
2962
+ };
2963
+ if (storage.type === "sqlite") {
2964
+ const storageFile = resolveDataDir(storage.path, opts.workspaceRoot);
2652
2965
  if (storageFile === null) return {
2653
2966
  ok: false,
2654
- reason: `storage.path '${schema.storage.path}' escapes the workspace`
2655
- };
2656
- const dataDir = resolveDataDir(conventionalDataPath(opts.slug), opts.workspaceRoot);
2657
- if (dataDir === null) return {
2658
- ok: false,
2659
- reason: `slug '${opts.slug}' yields no workspace-contained data dir`
2967
+ reason: `storage.path '${storage.path}' escapes the workspace`
2660
2968
  };
2661
2969
  return {
2662
2970
  ok: true,
@@ -2664,14 +2972,15 @@ function acceptParsedSchema(schema, opts) {
2664
2972
  storageFile
2665
2973
  };
2666
2974
  }
2667
- const dataDir = resolveDataDir(schema.dataPath ?? "", opts.workspaceRoot);
2668
- if (dataDir === null) return {
2975
+ const manifest = loadAppManifest(opts.workspaceRoot);
2976
+ if (!manifest.ok) return {
2669
2977
  ok: false,
2670
- reason: `dataPath '${schema.dataPath}' escapes the workspace`
2978
+ reason: appManifestReason(manifest, opts.workspaceRoot)
2671
2979
  };
2672
2980
  return {
2673
2981
  ok: true,
2674
- dataDir
2982
+ dataDir,
2983
+ appId: manifest.manifest.aid
2675
2984
  };
2676
2985
  }
2677
2986
  async function loadOneCollection(skillsRoot, slug, source, workspaceRoot) {
@@ -2729,6 +3038,7 @@ async function loadOneCollection(skillsRoot, slug, source, workspaceRoot) {
2729
3038
  dataDir: acceptance.dataDir,
2730
3039
  ...acceptance.dataSourceFile !== void 0 ? { dataSourceFile: acceptance.dataSourceFile } : {},
2731
3040
  ...acceptance.storageFile !== void 0 ? { storageFile: acceptance.storageFile } : {},
3041
+ ...acceptance.appId !== void 0 ? { appId: acceptance.appId } : {},
2732
3042
  skillDir: path.join(skillsRoot, safeName)
2733
3043
  };
2734
3044
  }
@@ -2809,7 +3119,8 @@ function toSummary(collection) {
2809
3119
  title: collection.schema.title,
2810
3120
  icon: collection.schema.icon,
2811
3121
  source: collection.source,
2812
- ...collection.schema.dataSource !== void 0 ? { readonly: true } : {}
3122
+ ...collection.schema.dataSource !== void 0 ? { readonly: true } : {},
3123
+ ...collection.appId !== void 0 ? { appId: collection.appId } : {}
2813
3124
  };
2814
3125
  }
2815
3126
  function toDetail(collection) {
@@ -2819,6 +3130,6 @@ function toDetail(collection) {
2819
3130
  };
2820
3131
  }
2821
3132
  //#endregion
2822
- export { log as $, deleteItem as A, resolveDataDir as B, compileCsvQuery as C, MAX_QUERY_ROWS as D, DEFAULT_QUERY_ROWS as E, resolveCreateItemId as F, archiveDir as G, safeRecordId as H, writeItem as I, collectionsRegistriesConfigPath as J, collectionChangeKey as K, SCHEMA_FILE as L, isRegularFile as M, listItems as N, BackendUnavailableError as O, readItem as P, localCollectionKey as Q, isContainedInRoot as R, queryCsv as S, CollectionQueryZ as T, safeSlugName as U, resolveTemplatePath as V, COLLECTION_ROOT_REQUIRED as W, getWorkspaceRoot as X, configureCollectionHost as Y, isPresetSlug as Z, csvRowToItem as _, toDetail as a, skillsStagingDir as at, encodeCsvRecordId as b, resolveMutateSet as c, createHostSlot as ct, storeFor as d, peekWorkspaceRoot as et, checkpointSqliteDatabase as f, cacheDir as g, MAX_CSV_ROWS as h, resolvePrimaryField as i, sharedCollectionChangePayload as it, generateItemId as j, isBackendUnavailable as k, collectionWritable as l, projectItemFields as m, discoverCollections as n, publishCollectionChange as nt, toSummary as o, stagingSkillDir as ot, pageFromFullRead as p, collectionChangePayload as q, loadCollection as r, setCollectionChangePublisher as rt, CollectionSchemaZ as s, createForwardingLogger as st, acceptParsedSchema as t, projectSkillsDir as tt, readOnlyRefusal as u, decodeCsvRecordId as v, compileJsonlQuery as w, normalizeCsvValue as x, dedupeByRecordId as y, itemFilePath as z };
3133
+ export { collectionsRegistriesConfigPath as $, readItem as A, SCHEMA_FILE as B, CollectionQueryZ as C, generateItemId as D, deleteItem as E, loadAppManifest as F, safeRecordId as G, itemFilePath as H, parseAppManifest as I, isBackendUnavailable as J, safeSlugName as K, sharedItemsPath as L, writeItem as M, APP_MANIFEST_FILE as N, isRegularFile as O, appManifestReason as P, collectionChangePayload as Q, pageFromFullRead as R, compileJsonlQuery as S, MAX_QUERY_ROWS as T, resolveDataDir as U, isContainedInRoot as V, resolveTemplatePath as W, archiveDir as X, COLLECTION_ROOT_REQUIRED as Y, collectionChangeKey as Z, dedupeByRecordId as _, toDetail as a, log as at, queryCsv as b, resolveMutateSet as c, publishCollectionChange as ct, storeFor as d, sharedCollectionChangePayload as dt, configureCollectionHost as et, checkpointSqliteDatabase as f, skillsStagingDir as ft, decodeCsvRecordId as g, csvRowToItem as h, createHostSlot as ht, resolvePrimaryField as i, localCollectionKey as it, resolveCreateItemId as j, listItems as k, collectionWritable as l, setCollectionChangePublisher as lt, cacheDir as m, createForwardingLogger as mt, discoverCollections as n, getWorkspaceRoot as nt, toSummary as o, peekWorkspaceRoot as ot, MAX_CSV_ROWS as p, stagingSkillDir as pt, BackendUnavailableError as q, loadCollection as r, isPresetSlug as rt, CollectionSchemaZ as s, projectSkillsDir as st, acceptParsedSchema as t, firestoreHandle as tt, readOnlyRefusal as u, setFirestoreAccessor as ut, encodeCsvRecordId as v, DEFAULT_QUERY_ROWS as w, compileCsvQuery as x, normalizeCsvValue as y, projectItemFields as z };
2823
3134
 
2824
- //# sourceMappingURL=discovery-DH9wweuj.js.map
3135
+ //# sourceMappingURL=discovery-CdkaURVY.js.map