@mulmoclaude/core 3.4.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 (63) hide show
  1. package/assets/helps/collection-skills.md +49 -1
  2. package/assets/helps/error-recovery.md +53 -0
  3. package/dist/{calendarGrid-Csy2rpjp.js → calendarGrid-CQ8MVSRb.js} +141 -2
  4. package/dist/calendarGrid-CQ8MVSRb.js.map +1 -0
  5. package/dist/{calendarGrid-p8K-7cuB.cjs → calendarGrid-DGILaVxI.cjs} +194 -1
  6. package/dist/calendarGrid-DGILaVxI.cjs.map +1 -0
  7. package/dist/collection/core/collectionKey.d.ts +67 -0
  8. package/dist/collection/core/schema.d.ts +8 -1
  9. package/dist/collection/core/schemaZ.d.ts +36 -26
  10. package/dist/collection/firestore.cjs +51 -0
  11. package/dist/collection/firestore.cjs.map +1 -0
  12. package/dist/collection/firestore.d.ts +1 -0
  13. package/dist/collection/firestore.js +50 -0
  14. package/dist/collection/firestore.js.map +1 -0
  15. package/dist/collection/index.cjs +11 -2
  16. package/dist/collection/index.d.ts +1 -0
  17. package/dist/collection/index.js +3 -3
  18. package/dist/collection/registry/server/index.cjs +2 -2
  19. package/dist/collection/registry/server/index.js +2 -2
  20. package/dist/collection/server/appManifest.d.ts +53 -0
  21. package/dist/collection/server/delete.d.ts +10 -0
  22. package/dist/collection/server/discoveredCollection.d.ts +10 -0
  23. package/dist/collection/server/discovery.d.ts +1 -0
  24. package/dist/collection/server/firestoreDocs.d.ts +39 -0
  25. package/dist/collection/server/firestoreStore.d.ts +14 -0
  26. package/dist/collection/server/host.d.ts +90 -8
  27. package/dist/collection/server/index.cjs +13 -3
  28. package/dist/collection/server/index.d.ts +4 -1
  29. package/dist/collection/server/index.js +4 -4
  30. package/dist/collection-watchers/index.cjs +161 -49
  31. package/dist/collection-watchers/index.cjs.map +1 -1
  32. package/dist/collection-watchers/index.js +161 -49
  33. package/dist/collection-watchers/index.js.map +1 -1
  34. package/dist/collection-watchers/reconciler.d.ts +36 -1
  35. package/dist/{discovery-D7LCN8gJ.js → discovery-CdkaURVY.js} +411 -57
  36. package/dist/discovery-CdkaURVY.js.map +1 -0
  37. package/dist/{discovery-CrA_EqUJ.cjs → discovery-Cw5xWzZD.cjs} +469 -55
  38. package/dist/discovery-Cw5xWzZD.cjs.map +1 -0
  39. package/dist/feeds/index.cjs +2 -2
  40. package/dist/feeds/index.js +2 -2
  41. package/dist/feeds/server/index.cjs +4 -4
  42. package/dist/feeds/server/index.js +4 -4
  43. package/dist/google/index.cjs +2 -2
  44. package/dist/google/index.js +2 -2
  45. package/dist/{ingestTypes-F2IJhOLJ.js → ingestTypes-CEi-Ot7F.js} +2 -2
  46. package/dist/{ingestTypes-F2IJhOLJ.js.map → ingestTypes-CEi-Ot7F.js.map} +1 -1
  47. package/dist/{ingestTypes-CPR-h-OZ.cjs → ingestTypes-iaiq33Ph.cjs} +2 -2
  48. package/dist/{ingestTypes-CPR-h-OZ.cjs.map → ingestTypes-iaiq33Ph.cjs.map} +1 -1
  49. package/dist/{promptSafety-ACXw_2Yu.js → promptSafety-CpiME8pj.js} +2 -2
  50. package/dist/{promptSafety-ACXw_2Yu.js.map → promptSafety-CpiME8pj.js.map} +1 -1
  51. package/dist/{promptSafety-CNEW3_qS.cjs → promptSafety-NNGViiCr.cjs} +2 -2
  52. package/dist/{promptSafety-CNEW3_qS.cjs.map → promptSafety-NNGViiCr.cjs.map} +1 -1
  53. package/dist/{server-CorfPQVQ.cjs → server-9mFaGLdq.cjs} +25 -6
  54. package/dist/server-9mFaGLdq.cjs.map +1 -0
  55. package/dist/{server-D6QY853r.js → server-DdHaX_Uu.js} +25 -6
  56. package/dist/server-DdHaX_Uu.js.map +1 -0
  57. package/package.json +7 -1
  58. package/dist/calendarGrid-Csy2rpjp.js.map +0 -1
  59. package/dist/calendarGrid-p8K-7cuB.cjs.map +0 -1
  60. package/dist/discovery-CrA_EqUJ.cjs.map +0 -1
  61. package/dist/discovery-D7LCN8gJ.js.map +0 -1
  62. package/dist/server-CorfPQVQ.cjs.map +0 -1
  63. package/dist/server-D6QY853r.js.map +0 -1
@@ -1,9 +1,9 @@
1
1
  import { t as canonicalRoot } from "../root-BMroU_mB.js";
2
- import { k as fieldText } from "../calendarGrid-Csy2rpjp.js";
3
- import { E as whenMatches, f as itemIsDone } from "../promptSafety-ACXw_2Yu.js";
2
+ import { R as fieldText, y as isValidCollectionName } from "../calendarGrid-CQ8MVSRb.js";
3
+ import { E as whenMatches, f as itemIsDone } from "../promptSafety-CpiME8pj.js";
4
4
  import "../collection/index.js";
5
- import { K as collectionChangePayload, Q as peekWorkspaceRoot, d as storeFor, et as publishCollectionChange, n as discoverCollections, r as loadCollection, z as itemFilePath } from "../discovery-D7LCN8gJ.js";
6
- import { d as isTriggerDue, f as maybeSpawnSuccessor } from "../server-D6QY853r.js";
5
+ import { H as itemFilePath, Q as collectionChangePayload, ct as publishCollectionChange, d as storeFor, n as discoverCollections, ot as peekWorkspaceRoot, r as loadCollection, tt as firestoreHandle } from "../discovery-CdkaURVY.js";
6
+ import { d as isTriggerDue, f as maybeSpawnSuccessor } from "../server-DdHaX_Uu.js";
7
7
  import { d as publish, m as updateForPlugin, n as clear, s as listAll } from "../notifier-DhnwR82U.js";
8
8
  import { access } from "node:fs/promises";
9
9
  //#region src/collection-watchers/config.ts
@@ -54,6 +54,12 @@ var LEGACY_ID_PREFIX = "collection-completion:";
54
54
  * so the split is unambiguous. */
55
55
  var ROOT_MARK = "@";
56
56
  var ROOT_SEP = "\0";
57
+ /** Marks a key that belongs to a SHARED collection (`apps/{aid}/collections/
58
+ * {cid}`) rather than to a directory: `<prefix>#<aid>\0<cid>:<itemId>`. A
59
+ * shared collection has no root, and its `cid` may well be the slug some
60
+ * project already owns, so it needs a mark of its own — reusing the rootless
61
+ * form would make the two dedupe into one bell. */
62
+ var SHARED_MARK = "#";
57
63
  /** Stable key encoding (root, slug, item), round-tripped through the entry's
58
64
  * `pluginData` so we can find it later without a side state file. Slug +
59
65
  * itemId are upstream-validated via `safeSlugName`, which forbids the
@@ -75,21 +81,31 @@ var ROOT_SEP = "\0";
75
81
  * this id is written to disk: a caller reaching `reconcileItem` directly with
76
82
  * `/proj/` where the watcher used `/proj` would otherwise publish a SECOND
77
83
  * bell for the same record, and neither pass would ever clear the other's. */
78
- function completionLegacyId(slug, itemId, root) {
79
- return `${LEGACY_ID_PREFIX}${root === void 0 ? "" : `${ROOT_MARK}${canonicalRoot(root)}${ROOT_SEP}`}${slug}:${itemId}`;
84
+ /** Exported for the format pin in `test/collection-watchers/test_completionId.ts`.
85
+ * Deliberately NOT on the package barrel: the id is a disk format, not an API. */
86
+ function completionLegacyId(slug, itemId, root, aid) {
87
+ if (root !== void 0 && aid !== void 0) throw new Error(`completionLegacyId: "${slug}" carries both a root (${root}) and an app (${aid})`);
88
+ if (!isValidCollectionName(slug)) throw new Error(`completionLegacyId: collection name "${slug}" is not a valid collection name`);
89
+ if (aid !== void 0 && !isValidCollectionName(aid)) throw new Error(`completionLegacyId: app id "${aid}" is not a valid collection name`);
90
+ return `${LEGACY_ID_PREFIX}${aid !== void 0 ? `${SHARED_MARK}${aid}${ROOT_SEP}` : root === void 0 ? "" : `${ROOT_MARK}${canonicalRoot(root)}${ROOT_SEP}`}${slug}:${itemId}`;
80
91
  }
81
92
  /** Decode a key back into its (root, slug, itemId), or null if the string
82
93
  * didn't originate from this module. Accepts both the rooted and the
83
94
  * original root-less form — see the compatibility rule above. Used by the
84
95
  * sweep step. */
96
+ /** @see completionLegacyId — exported for the same reason. */
85
97
  function parseCompletionLegacyId(legacyId) {
86
98
  if (!legacyId.startsWith(LEGACY_ID_PREFIX)) return null;
87
99
  let body = legacyId.slice(22);
88
100
  let root;
89
- if (body.startsWith(ROOT_MARK)) {
101
+ let aid;
102
+ if (body.startsWith(ROOT_MARK) || body.startsWith(SHARED_MARK)) {
103
+ const shared = body.startsWith(SHARED_MARK);
90
104
  const sep = body.indexOf(ROOT_SEP);
91
105
  if (sep < 0) return null;
92
- root = body.slice(1, sep);
106
+ const scope = body.slice(1, sep);
107
+ if (shared) aid = scope;
108
+ else root = canonicalRoot(scope);
93
109
  body = body.slice(sep + 1);
94
110
  }
95
111
  const colon = body.indexOf(":");
@@ -98,6 +114,10 @@ function parseCompletionLegacyId(legacyId) {
98
114
  slug: body.slice(0, colon),
99
115
  itemId: body.slice(colon + 1)
100
116
  };
117
+ if (aid !== void 0) return {
118
+ aid,
119
+ ...parsed
120
+ };
101
121
  return root === void 0 ? parsed : {
102
122
  root,
103
123
  ...parsed
@@ -112,16 +132,28 @@ function resolveDisplayLabel(schema, item, itemId) {
112
132
  const label = fieldText(item[displayField]).trim();
113
133
  return label.length > 0 ? label : itemId;
114
134
  }
135
+ /** The scope a collection's bells belong to.
136
+ *
137
+ * A shared collection's `appId` wins over the call's `workspaceRoot`: the
138
+ * root is where this host happens to have the repository checked out, which
139
+ * is exactly the fact that must NOT reach the identity. A local collection is
140
+ * unchanged — its root is present only when the call carried an explicit one,
141
+ * so a single-workspace host's ids stay byte-identical to what it has already
142
+ * written to `active.json`. */
143
+ function bellScopeOf(collection, ioOpts) {
144
+ return collection.appId === void 0 ? { root: ioOpts.workspaceRoot } : { aid: collection.appId };
145
+ }
146
+ var scopedLegacyId = (slug, itemId, scope) => completionLegacyId(slug, itemId, scope.root, scope.aid);
115
147
  /** Every active bell entry whose key matches this (slug, itemId).
116
148
  * Returns multiple when defensive cleanup is needed. Scans `listAll()`
117
149
  * — cheap because the active set is bounded. */
118
- async function findActiveEntries(slug, itemId, root) {
150
+ async function findActiveEntries(slug, itemId, scope) {
119
151
  const adapter = requireAdapter();
120
- const legacyId = completionLegacyId(slug, itemId, root);
152
+ const legacyId = scopedLegacyId(slug, itemId, scope);
121
153
  return (await listAll()).filter((entry) => adapter.readEntry(entry.pluginData)?.legacyId === legacyId);
122
154
  }
123
- async function findActiveEntryIds(slug, itemId, root) {
124
- return (await findActiveEntries(slug, itemId, root)).map((entry) => entry.id);
155
+ async function findActiveEntryIds(slug, itemId, scope) {
156
+ return (await findActiveEntries(slug, itemId, scope)).map((entry) => entry.id);
125
157
  }
126
158
  var ensureLocks = /* @__PURE__ */ new Map();
127
159
  /** Bell priority for a record: the FIRST flagged value in `notifyWhen.in`
@@ -134,8 +166,8 @@ function notifyPriorityForItem(schema, item) {
134
166
  const value = item[spec.field] === void 0 || item[spec.field] === null ? "" : String(item[spec.field]);
135
167
  return spec.in.indexOf(value) === 0 ? "high" : "normal";
136
168
  }
137
- async function ensureItemNotification(slug, schema, itemId, displayLabel, priority, root) {
138
- const legacyId = completionLegacyId(slug, itemId, root);
169
+ async function ensureItemNotification(slug, schema, itemId, displayLabel, priority, scope) {
170
+ const legacyId = scopedLegacyId(slug, itemId, scope);
139
171
  while (true) {
140
172
  const inflight = ensureLocks.get(legacyId);
141
173
  if (!inflight) break;
@@ -148,7 +180,7 @@ async function ensureItemNotification(slug, schema, itemId, displayLabel, priori
148
180
  legacyId,
149
181
  displayLabel,
150
182
  priority,
151
- root
183
+ scope
152
184
  }) };
153
185
  ensureLocks.set(legacyId, lock);
154
186
  try {
@@ -161,8 +193,9 @@ async function ensureItemNotification(slug, schema, itemId, displayLabel, priori
161
193
  * place (preserving id / position / createdAt) so a record whose flagged
162
194
  * value changed while it stayed pending re-colours the bell without a
163
195
  * clear+republish flicker. No-op when the stored priority already matches. */
164
- async function reconcileEntrySeverity(slug, itemId, entries, priority, root) {
196
+ async function reconcileEntrySeverity(slug, itemId, entries, priority, scope) {
165
197
  const adapter = requireAdapter();
198
+ const { root } = scope;
166
199
  for (const entry of entries) {
167
200
  const parsed = adapter.readEntry(entry.pluginData);
168
201
  if (!parsed || parsed.priority === priority) continue;
@@ -179,12 +212,13 @@ async function reconcileEntrySeverity(slug, itemId, entries, priority, root) {
179
212
  });
180
213
  }
181
214
  }
182
- async function doEnsureItemNotification({ slug, schema, itemId, legacyId, displayLabel, priority, root }) {
215
+ async function doEnsureItemNotification({ slug, schema, itemId, legacyId, displayLabel, priority, scope }) {
183
216
  const adapter = requireAdapter();
217
+ const { root } = scope;
184
218
  try {
185
- const existing = await findActiveEntries(slug, itemId, root);
219
+ const existing = await findActiveEntries(slug, itemId, scope);
186
220
  if (existing.length > 0) {
187
- await reconcileEntrySeverity(slug, itemId, existing, priority, root);
221
+ await reconcileEntrySeverity(slug, itemId, existing, priority, scope);
188
222
  return;
189
223
  }
190
224
  const navigateTarget = adapter.buildNavigateTarget(slug, itemId, root);
@@ -214,9 +248,18 @@ async function doEnsureItemNotification({ slug, schema, itemId, legacyId, displa
214
248
  /** Idempotently clear EVERY bell entry that matches this (slug, itemId).
215
249
  * Silent no-op when nothing matches. The "every" is defensive: if a
216
250
  * duplicate ever slips through, this drains the lot. */
217
- async function clearItemNotification(slug, itemId, root) {
251
+ async function clearItemNotification(slug, itemId, root, aid) {
252
+ await clearScoped(slug, itemId, {
253
+ root,
254
+ aid
255
+ });
256
+ }
257
+ /** The scoped form every internal caller uses. `clearItemNotification` keeps
258
+ * its positional `(slug, itemId, root?)` shape because it is public API
259
+ * (MulmoTerminal calls it); `aid` is additive. */
260
+ async function clearScoped(slug, itemId, scope) {
218
261
  try {
219
- const ids = await findActiveEntryIds(slug, itemId, root);
262
+ const ids = await findActiveEntryIds(slug, itemId, scope);
220
263
  for (const entryId of ids) await clear(entryId);
221
264
  } catch (err) {
222
265
  log().warn("notify clear failed", {
@@ -236,19 +279,19 @@ async function clearItemNotification(slug, itemId, root) {
236
279
  * `{ workspaceRoot: <tmpdir> }` so the check accepts a fixture dataDir. */
237
280
  async function reconcileItem(collection, itemId, ioOpts = {}, now = evalNow()) {
238
281
  const { slug, schema } = collection;
239
- const root = ioOpts.workspaceRoot;
282
+ const scope = bellScopeOf(collection, ioOpts);
240
283
  if (!schema.completionField) {
241
- await clearItemNotification(slug, itemId, root);
284
+ await clearScoped(slug, itemId, scope);
242
285
  return;
243
286
  }
244
287
  const item = await storeFor(collection, ioOpts).read(itemId);
245
288
  if (item === null) {
246
- await clearItemNotification(slug, itemId, root);
289
+ await clearScoped(slug, itemId, scope);
247
290
  return;
248
291
  }
249
292
  await maybeSpawnSuccessor(collection, item, itemId, ioOpts);
250
293
  if (itemIsDone(schema, item)) {
251
- await clearItemNotification(slug, itemId, root);
294
+ await clearScoped(slug, itemId, scope);
252
295
  return;
253
296
  }
254
297
  if (schema.triggerField) {
@@ -260,15 +303,15 @@ async function reconcileItem(collection, itemId, ioOpts = {}, now = evalNow()) {
260
303
  triggerField: schema.triggerField
261
304
  });
262
305
  if (due !== true) {
263
- await clearItemNotification(slug, itemId, root);
306
+ await clearScoped(slug, itemId, scope);
264
307
  return;
265
308
  }
266
309
  }
267
310
  if (!whenMatches(schema.notifyWhen, item)) {
268
- await clearItemNotification(slug, itemId, root);
311
+ await clearScoped(slug, itemId, scope);
269
312
  return;
270
313
  }
271
- await ensureItemNotification(slug, schema, itemId, resolveDisplayLabel(schema, item, itemId), notifyPriorityForItem(schema, item), root);
314
+ await ensureItemNotification(slug, schema, itemId, resolveDisplayLabel(schema, item, itemId), notifyPriorityForItem(schema, item), scope);
272
315
  }
273
316
  /** Boot-time reconcile: walk every record of the collection once (through
274
317
  * its store) and reconcile it. Catches up changes that happened while the
@@ -313,9 +356,10 @@ async function reconcileAllItems(collection, ioOpts = {}, now = evalNow()) {
313
356
  * on produces a root-less id, so skipping it would strand a bell no pass can
314
357
  * ever clear, while the reconcile publishes a second rooted one beside it.
315
358
  * Clearing converges — the record republishes rooted if still pending. */
316
- function sweepVerdict(entryRoot, ownRoot) {
317
- if (entryRoot === ownRoot) return "mine";
318
- if (entryRoot === void 0) return "drop-legacy";
359
+ function sweepVerdict(entry, ownRoot) {
360
+ if (entry.aid !== void 0) return "shared";
361
+ if (entry.root === ownRoot) return "mine";
362
+ if (entry.root === void 0) return "drop-legacy";
319
363
  return "skip";
320
364
  }
321
365
  /** Should this entry's bell be dropped? True when the collection is gone, no
@@ -323,12 +367,40 @@ function sweepVerdict(entryRoot, ownRoot) {
323
367
  * `notifyWhen`. The reverse of the reconcile invariant, applied to an entry
324
368
  * whose record the forward pass can never walk to (a delete leaves a bell that
325
369
  * a walk over the SURVIVING records cannot clear). */
326
- async function isStaleEntry(slug, itemId, opts) {
370
+ async function isStaleEntry(slug, itemId, opts, aid) {
327
371
  const collection = await loadCollection(slug, opts);
372
+ if (aid !== void 0 && collection?.appId !== aid) return false;
328
373
  if (!collection || !collection.schema.completionField) return true;
374
+ return recordNoLongerBells(collection, itemId, opts);
375
+ }
376
+ /** The record half of the staleness question: gone, done, or outside
377
+ * `notifyWhen`. Split out so the caller stays one decision — WHOSE entry this
378
+ * is — and this one stays the other. */
379
+ async function recordNoLongerBells(collection, itemId, opts) {
329
380
  const item = await storeFor(collection, opts).read(itemId);
330
381
  return item === null || itemIsDone(collection.schema, item) || !whenMatches(collection.schema.notifyWhen, item);
331
382
  }
383
+ /** One entry's fate: whose it is (`sweepVerdict`), then — when it is this
384
+ * sweep's to judge — whether its record still justifies the bell. Extracted so
385
+ * the loop above stays a walk and this stays the decision. */
386
+ async function sweepOneEntry(entryId, parsed, ownRoot, opts) {
387
+ const verdict = sweepVerdict(parsed, ownRoot);
388
+ if (verdict === "skip") return;
389
+ if (verdict === "drop-legacy") {
390
+ await clear(entryId);
391
+ return;
392
+ }
393
+ const { slug, itemId } = parsed;
394
+ try {
395
+ if (await isStaleEntry(slug, itemId, opts, verdict === "shared" ? parsed.aid : void 0)) await clear(entryId);
396
+ } catch (err) {
397
+ log().warn("sweep entry failed", {
398
+ slug,
399
+ itemId,
400
+ error: errMsg(err)
401
+ });
402
+ }
403
+ }
332
404
  async function sweepStaleActiveEntries(opts = {}) {
333
405
  const adapter = requireAdapter();
334
406
  let entries;
@@ -344,22 +416,7 @@ async function sweepStaleActiveEntries(opts = {}) {
344
416
  if (!own) continue;
345
417
  const parsed = parseCompletionLegacyId(own.legacyId);
346
418
  if (!parsed) continue;
347
- const verdict = sweepVerdict(parsed.root, ownRoot);
348
- if (verdict === "skip") continue;
349
- if (verdict === "drop-legacy") {
350
- await clear(entry.id);
351
- continue;
352
- }
353
- const { slug, itemId } = parsed;
354
- try {
355
- if (await isStaleEntry(slug, itemId, opts)) await clear(entry.id);
356
- } catch (err) {
357
- log().warn("sweep entry failed", {
358
- slug,
359
- itemId,
360
- error: errMsg(err)
361
- });
362
- }
419
+ await sweepOneEntry(entry.id, parsed, ownRoot, opts);
363
420
  }
364
421
  }
365
422
  /** Test-only: clear the per-key in-flight locks. */
@@ -569,9 +626,58 @@ async function _tickTimeTriggersForTesting(now, workspaceRoot) {
569
626
  async function tickTimeTriggers(gen, now = evalNow()) {
570
627
  for (const entry of gen.watchers.values()) {
571
628
  const { schema } = entry.collection;
629
+ if (cannotReportChanges(gen, entry.collection)) continue;
572
630
  if (!schema.triggerField && !schema.spawn) continue;
573
631
  await reconcileAllItems(entry.collection, gen.discoveryOpts, now);
574
632
  }
633
+ await tickUnwatchedCollections(gen, now);
634
+ }
635
+ /** True when the collection's store implements no `watch` — nothing will ever
636
+ * tell this module its records moved, so the clock tick is its only change
637
+ * detection. A capability question, deliberately not a backend one: the day a
638
+ * shared collection's store grows an `onSnapshot` watch, it stops being
639
+ * special here with no edit to this file. */
640
+ function cannotReportChanges(gen, collection) {
641
+ return storeFor(collection, gen.discoveryOpts).watch === void 0;
642
+ }
643
+ /** True when a schema declares behaviour that only a reconcile pass can
644
+ * produce: bells (`completionField`), date-triggered bells (`triggerField`),
645
+ * or recurrence successors (`spawn`). */
646
+ function needsReconcilePass(schema) {
647
+ return Boolean(schema.completionField ?? schema.triggerField ?? schema.spawn);
648
+ }
649
+ /** One unwatched collection's reconcile pass. Extracted so the single-flight
650
+ * callback doesn't close over loop state.
651
+ *
652
+ * `reconcileAllItems` already swallows a failing store read (it logs and
653
+ * returns), so a closed session or a denied rule surfaces there, not as a
654
+ * rejection here. This catch is only for the unexpected — it must not let one
655
+ * collection's fault abort the rest of the tick. */
656
+ async function reconcileUnwatched(gen, collection, now) {
657
+ try {
658
+ await runSingleFlight(gen.collectionSlots, collection.slug, () => reconcileAllItems(collection, gen.discoveryOpts, now));
659
+ } catch (err) {
660
+ log().warn("unwatched collection reconcile failed", {
661
+ slug: collection.slug,
662
+ error: errMsg(err)
663
+ });
664
+ }
665
+ }
666
+ /** Stand in for the store-change path (2) on backends that don't have one.
667
+ *
668
+ * Gated on `needsReconcilePass` so a collection declaring none of it costs
669
+ * nothing: unlike the local backends, every pass here is a network round trip.
670
+ *
671
+ * A collection that DROPS OUT of eligibility (its `completionField` edited
672
+ * away, or the collection removed) is not this pass's problem — `syncWatchers`
673
+ * already sweeps on a changed schema and on a vanished slug, and the sweep is
674
+ * what clears bells the declaration no longer justifies. */
675
+ async function tickUnwatchedCollections(gen, now) {
676
+ if (firestoreHandle() === null) return;
677
+ const pending = [...gen.watchers.values()].map((entry) => entry.collection).filter((collection) => cannotReportChanges(gen, collection) && needsReconcilePass(collection.schema));
678
+ if (pending.length === 0) return;
679
+ for (const collection of pending) await reconcileUnwatched(gen, collection, now);
680
+ await sweepStaleActiveEntries(gen.discoveryOpts);
575
681
  }
576
682
  /** Reconcile the watcher set against the currently-discovered
577
683
  * collections. Adds watchers for new slugs (with a boot reconcile of
@@ -612,7 +718,13 @@ function stopVanishedWatchers(gen, liveSlugs) {
612
718
  * modes. The mounted fs.watch is bound to the OLD location, so it must
613
719
  * be remounted, not just re-reconciled. */
614
720
  function storagePathChanged(previous, next) {
615
- return previous.dataSource?.path !== next.dataSource?.path || previous.dataPath !== next.dataPath || previous.storage?.path !== next.storage?.path;
721
+ return previous.dataSource?.path !== next.dataSource?.path || previous.dataPath !== next.dataPath || previous.storage?.type !== next.storage?.type || storageFilePath(previous.storage) !== storageFilePath(next.storage);
722
+ }
723
+ /** The on-disk path of a storage backend, or undefined when it has none (a
724
+ * shared collection keeps its records off this filesystem, so there is no
725
+ * mount to move — only a `type` change matters for it). */
726
+ function storageFilePath(storage) {
727
+ return storage?.type === "sqlite" ? storage.path : void 0;
616
728
  }
617
729
  /** Re-reconcile already-watched collections whose schema changed since
618
730
  * the last tick. New collections fall through to `startNewWatchers`. */