@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.
- package/assets/helps/collection-skills.md +49 -1
- package/assets/helps/error-recovery.md +53 -0
- package/dist/{calendarGrid-Csy2rpjp.js → calendarGrid-CQ8MVSRb.js} +141 -2
- package/dist/calendarGrid-CQ8MVSRb.js.map +1 -0
- package/dist/{calendarGrid-p8K-7cuB.cjs → calendarGrid-DGILaVxI.cjs} +194 -1
- package/dist/calendarGrid-DGILaVxI.cjs.map +1 -0
- package/dist/collection/core/collectionKey.d.ts +67 -0
- 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/index.cjs +11 -2
- package/dist/collection/index.d.ts +1 -0
- package/dist/collection/index.js +3 -3
- package/dist/collection/registry/server/index.cjs +2 -2
- 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 +90 -8
- package/dist/collection/server/index.cjs +13 -3
- package/dist/collection/server/index.d.ts +4 -1
- package/dist/collection/server/index.js +4 -4
- package/dist/collection-watchers/index.cjs +161 -49
- package/dist/collection-watchers/index.cjs.map +1 -1
- package/dist/collection-watchers/index.js +161 -49
- package/dist/collection-watchers/index.js.map +1 -1
- package/dist/collection-watchers/reconciler.d.ts +36 -1
- package/dist/{discovery-D7LCN8gJ.js → discovery-CdkaURVY.js} +411 -57
- package/dist/discovery-CdkaURVY.js.map +1 -0
- package/dist/{discovery-CrA_EqUJ.cjs → discovery-Cw5xWzZD.cjs} +469 -55
- package/dist/discovery-Cw5xWzZD.cjs.map +1 -0
- package/dist/feeds/index.cjs +2 -2
- package/dist/feeds/index.js +2 -2
- package/dist/feeds/server/index.cjs +4 -4
- package/dist/feeds/server/index.js +4 -4
- package/dist/google/index.cjs +2 -2
- package/dist/google/index.js +2 -2
- package/dist/{ingestTypes-F2IJhOLJ.js → ingestTypes-CEi-Ot7F.js} +2 -2
- package/dist/{ingestTypes-F2IJhOLJ.js.map → ingestTypes-CEi-Ot7F.js.map} +1 -1
- package/dist/{ingestTypes-CPR-h-OZ.cjs → ingestTypes-iaiq33Ph.cjs} +2 -2
- package/dist/{ingestTypes-CPR-h-OZ.cjs.map → ingestTypes-iaiq33Ph.cjs.map} +1 -1
- package/dist/{promptSafety-ACXw_2Yu.js → promptSafety-CpiME8pj.js} +2 -2
- package/dist/{promptSafety-ACXw_2Yu.js.map → promptSafety-CpiME8pj.js.map} +1 -1
- package/dist/{promptSafety-CNEW3_qS.cjs → promptSafety-NNGViiCr.cjs} +2 -2
- package/dist/{promptSafety-CNEW3_qS.cjs.map → promptSafety-NNGViiCr.cjs.map} +1 -1
- package/dist/{server-CorfPQVQ.cjs → server-9mFaGLdq.cjs} +25 -6
- package/dist/server-9mFaGLdq.cjs.map +1 -0
- package/dist/{server-D6QY853r.js → server-DdHaX_Uu.js} +25 -6
- package/dist/server-DdHaX_Uu.js.map +1 -0
- package/package.json +7 -1
- package/dist/calendarGrid-Csy2rpjp.js.map +0 -1
- package/dist/calendarGrid-p8K-7cuB.cjs.map +0 -1
- package/dist/discovery-CrA_EqUJ.cjs.map +0 -1
- package/dist/discovery-D7LCN8gJ.js.map +0 -1
- package/dist/server-CorfPQVQ.cjs.map +0 -1
- 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 {
|
|
3
|
-
import { E as whenMatches, f as itemIsDone } from "../promptSafety-
|
|
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 {
|
|
6
|
-
import { d as isTriggerDue, f as maybeSpawnSuccessor } from "../server-
|
|
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
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
150
|
+
async function findActiveEntries(slug, itemId, scope) {
|
|
119
151
|
const adapter = requireAdapter();
|
|
120
|
-
const legacyId =
|
|
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,
|
|
124
|
-
return (await findActiveEntries(slug, itemId,
|
|
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,
|
|
138
|
-
const legacyId =
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
219
|
+
const existing = await findActiveEntries(slug, itemId, scope);
|
|
186
220
|
if (existing.length > 0) {
|
|
187
|
-
await reconcileEntrySeverity(slug, itemId, existing, priority,
|
|
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,
|
|
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
|
|
282
|
+
const scope = bellScopeOf(collection, ioOpts);
|
|
240
283
|
if (!schema.completionField) {
|
|
241
|
-
await
|
|
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
|
|
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
|
|
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
|
|
306
|
+
await clearScoped(slug, itemId, scope);
|
|
264
307
|
return;
|
|
265
308
|
}
|
|
266
309
|
}
|
|
267
310
|
if (!whenMatches(schema.notifyWhen, item)) {
|
|
268
|
-
await
|
|
311
|
+
await clearScoped(slug, itemId, scope);
|
|
269
312
|
return;
|
|
270
313
|
}
|
|
271
|
-
await ensureItemNotification(slug, schema, itemId, resolveDisplayLabel(schema, item, itemId), notifyPriorityForItem(schema, item),
|
|
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(
|
|
317
|
-
if (
|
|
318
|
-
if (
|
|
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
|
-
|
|
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?.
|
|
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`. */
|