@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
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { CollectionSchema } from '../core/schema';
|
|
2
|
+
import { AuthoredApp } from './publishManifest';
|
|
3
|
+
/** Who and when. Threaded in rather than read from a clock inside the
|
|
4
|
+
* projection so the projection stays pure and the tests can assert on an
|
|
5
|
+
* exact document. */
|
|
6
|
+
export interface PublishStamp {
|
|
7
|
+
/** The publisher's Firebase uid. The rules require `owner ==
|
|
8
|
+
* request.auth.uid` on CREATE and `owner` unchanged on UPDATE, so this is
|
|
9
|
+
* used only when the app document does not exist yet. */
|
|
10
|
+
uid: string;
|
|
11
|
+
/** The publisher's verified email — the principal the roster is keyed by. */
|
|
12
|
+
email: string;
|
|
13
|
+
/** Wall-clock millis of this publish. */
|
|
14
|
+
publishedAt: number;
|
|
15
|
+
/** The git commit the declaration was read from, when the host could
|
|
16
|
+
* resolve one. Absent is a normal state (a dirty tree, a repository
|
|
17
|
+
* without git), and absent is more honest than a fabricated value. */
|
|
18
|
+
commit?: string | undefined;
|
|
19
|
+
}
|
|
20
|
+
/** One collection's published schema document (`apps/{aid}/collections/{cid}`).
|
|
21
|
+
*
|
|
22
|
+
* The rules never read `publishedSchema` — `schemaRead` gates the whole
|
|
23
|
+
* document and stops there. It is here for the CLIENTS: a member's host
|
|
24
|
+
* renders from it, and a public webview has no other way to know the fields.
|
|
25
|
+
* The whole schema is published rather than a projection of it, because
|
|
26
|
+
* every attempt to guess "the part a view needs" is a guess about a view
|
|
27
|
+
* that has not been written yet. */
|
|
28
|
+
export interface PublishedSchemaDoc extends Record<string, unknown> {
|
|
29
|
+
publishedSchema: CollectionSchema;
|
|
30
|
+
publishedAt: number;
|
|
31
|
+
publishedBy: string;
|
|
32
|
+
publishedCommit?: string;
|
|
33
|
+
}
|
|
34
|
+
/** The public settings document (`apps/{aid}/config/public`).
|
|
35
|
+
*
|
|
36
|
+
* `allow read: if true` — this is the one document an anonymous visitor can
|
|
37
|
+
* read, and it is the reason `apps/{aid}` itself is reader-only: a
|
|
38
|
+
* participant reading the app document would see their classmates'
|
|
39
|
+
* addresses. So the roster is NOT here, and neither is `owner`. What is here
|
|
40
|
+
* is what a public form needs in order to render itself and to tell a visitor
|
|
41
|
+
* "this closed yesterday" instead of failing a write with no explanation. */
|
|
42
|
+
export interface PublishedConfigDoc extends Record<string, unknown> {
|
|
43
|
+
name?: string;
|
|
44
|
+
enabled: boolean;
|
|
45
|
+
read: string[];
|
|
46
|
+
submit: Record<string, Record<string, unknown>>;
|
|
47
|
+
publishedAt: number;
|
|
48
|
+
}
|
|
49
|
+
/** Everything one publish writes. Separate documents because they live at
|
|
50
|
+
* separate paths with separate rules — and in the order given: the app
|
|
51
|
+
* document authorizes the other two, so it goes first. */
|
|
52
|
+
export interface PublishedApp {
|
|
53
|
+
app: Record<string, unknown>;
|
|
54
|
+
schemas: {
|
|
55
|
+
cid: string;
|
|
56
|
+
doc: PublishedSchemaDoc;
|
|
57
|
+
}[];
|
|
58
|
+
config: PublishedConfigDoc;
|
|
59
|
+
}
|
|
60
|
+
/** The document id under `apps/{aid}/config`. One document, named, rather than
|
|
61
|
+
* a spread of them: a second public document is a second thing to keep in
|
|
62
|
+
* step, and nothing yet needs one. */
|
|
63
|
+
export declare const PUBLIC_CONFIG_DOC = "public";
|
|
64
|
+
/** Project the authored declaration into the documents publish writes.
|
|
65
|
+
*
|
|
66
|
+
* `existing` is the app document as it is in Firestore right now, or null on
|
|
67
|
+
* a first publish. Two things need it, both required by the rules:
|
|
68
|
+
* - `owner` must be UNCHANGED on update. Re-stamping the publisher's uid
|
|
69
|
+
* would be refused for any app whose owner ever signed in as a different
|
|
70
|
+
* account, and would silently transfer ownership if it were not.
|
|
71
|
+
* - `previousPublished` is that document, so a rollback has something to
|
|
72
|
+
* put back.
|
|
73
|
+
*
|
|
74
|
+
* Pure: no clock, no filesystem, no Firestore. Everything variable arrives as
|
|
75
|
+
* a parameter, which is what makes the conversion table testable as a table. */
|
|
76
|
+
export declare function projectApp(authored: AuthoredApp, schemas: {
|
|
77
|
+
cid: string;
|
|
78
|
+
schema: CollectionSchema;
|
|
79
|
+
}[], stamp: PublishStamp, existing: Record<string, unknown> | null): PublishedApp;
|
|
80
|
+
/** The app documents' parent path — the `FirestoreDocs` seam takes a
|
|
81
|
+
* collection path plus a document id, and the app document's id is the aid. */
|
|
82
|
+
export declare const APPS_COLLECTION = "apps";
|
|
83
|
+
/** The collection (schema) documents' parent path. */
|
|
84
|
+
export declare const appSchemasPath: (aid: string) => string;
|
|
85
|
+
/** The public-config documents' parent path. */
|
|
86
|
+
export declare const appConfigPath: (aid: string) => string;
|
|
@@ -8,6 +8,18 @@ export interface RecordIssue {
|
|
|
8
8
|
/** Human-readable problem, written to be actionable by the LLM. */
|
|
9
9
|
problem: string;
|
|
10
10
|
}
|
|
11
|
+
/** Don't flood the result; the first batch is enough to act on. Exported
|
|
12
|
+
* because a caller that REPORTS a count has to know the count is a floor —
|
|
13
|
+
* `publish` presents a full batch as "at least N" rather than as a total. */
|
|
14
|
+
export declare const MAX_RECORD_ISSUES = 25;
|
|
15
|
+
/** The `file` of the pseudo-issue reported when the backend could not be read
|
|
16
|
+
* at all.
|
|
17
|
+
*
|
|
18
|
+
* Exported because it is a DIFFERENT KIND of answer from "this record is
|
|
19
|
+
* invalid", and a caller that treats the two alike gets it wrong in the
|
|
20
|
+
* direction that matters: `publish` lets the user override invalid records,
|
|
21
|
+
* and overriding this one would mean publishing without ever having looked. */
|
|
22
|
+
export declare const STORE_UNREADABLE = "(store)";
|
|
11
23
|
export declare function validateCollectionRecords(collection: LoadedCollection, opts?: {
|
|
12
24
|
workspaceRoot?: string | undefined;
|
|
13
25
|
}): Promise<RecordIssue[]>;
|
|
@@ -3,8 +3,8 @@ const require_root = require("../root-rPH6FGDT.cjs");
|
|
|
3
3
|
const require_calendarGrid = require("../calendarGrid-DGILaVxI.cjs");
|
|
4
4
|
const require_promptSafety = require("../promptSafety-NNGViiCr.cjs");
|
|
5
5
|
require("../collection/index.cjs");
|
|
6
|
-
const
|
|
7
|
-
const require_server = require("../server-
|
|
6
|
+
const require_store = require("../store-5_P_NsGa.cjs");
|
|
7
|
+
const require_server = require("../server-CWZyg8fn.cjs");
|
|
8
8
|
const require_notifier = require("../notifier-DZJj0sOh.cjs");
|
|
9
9
|
let node_fs_promises = require("node:fs/promises");
|
|
10
10
|
//#region src/collection-watchers/config.ts
|
|
@@ -133,16 +133,28 @@ function resolveDisplayLabel(schema, item, itemId) {
|
|
|
133
133
|
const label = require_calendarGrid.fieldText(item[displayField]).trim();
|
|
134
134
|
return label.length > 0 ? label : itemId;
|
|
135
135
|
}
|
|
136
|
+
/** The scope a collection's bells belong to.
|
|
137
|
+
*
|
|
138
|
+
* A shared collection's `appId` wins over the call's `workspaceRoot`: the
|
|
139
|
+
* root is where this host happens to have the repository checked out, which
|
|
140
|
+
* is exactly the fact that must NOT reach the identity. A local collection is
|
|
141
|
+
* unchanged — its root is present only when the call carried an explicit one,
|
|
142
|
+
* so a single-workspace host's ids stay byte-identical to what it has already
|
|
143
|
+
* written to `active.json`. */
|
|
144
|
+
function bellScopeOf(collection, ioOpts) {
|
|
145
|
+
return collection.appId === void 0 ? { root: ioOpts.workspaceRoot } : { aid: collection.appId };
|
|
146
|
+
}
|
|
147
|
+
var scopedLegacyId = (slug, itemId, scope) => completionLegacyId(slug, itemId, scope.root, scope.aid);
|
|
136
148
|
/** Every active bell entry whose key matches this (slug, itemId).
|
|
137
149
|
* Returns multiple when defensive cleanup is needed. Scans `listAll()`
|
|
138
150
|
* — cheap because the active set is bounded. */
|
|
139
|
-
async function findActiveEntries(slug, itemId,
|
|
151
|
+
async function findActiveEntries(slug, itemId, scope) {
|
|
140
152
|
const adapter = requireAdapter();
|
|
141
|
-
const legacyId =
|
|
153
|
+
const legacyId = scopedLegacyId(slug, itemId, scope);
|
|
142
154
|
return (await require_notifier.listAll()).filter((entry) => adapter.readEntry(entry.pluginData)?.legacyId === legacyId);
|
|
143
155
|
}
|
|
144
|
-
async function findActiveEntryIds(slug, itemId,
|
|
145
|
-
return (await findActiveEntries(slug, itemId,
|
|
156
|
+
async function findActiveEntryIds(slug, itemId, scope) {
|
|
157
|
+
return (await findActiveEntries(slug, itemId, scope)).map((entry) => entry.id);
|
|
146
158
|
}
|
|
147
159
|
var ensureLocks = /* @__PURE__ */ new Map();
|
|
148
160
|
/** Bell priority for a record: the FIRST flagged value in `notifyWhen.in`
|
|
@@ -155,8 +167,8 @@ function notifyPriorityForItem(schema, item) {
|
|
|
155
167
|
const value = item[spec.field] === void 0 || item[spec.field] === null ? "" : String(item[spec.field]);
|
|
156
168
|
return spec.in.indexOf(value) === 0 ? "high" : "normal";
|
|
157
169
|
}
|
|
158
|
-
async function ensureItemNotification(slug, schema, itemId, displayLabel, priority,
|
|
159
|
-
const legacyId =
|
|
170
|
+
async function ensureItemNotification(slug, schema, itemId, displayLabel, priority, scope) {
|
|
171
|
+
const legacyId = scopedLegacyId(slug, itemId, scope);
|
|
160
172
|
while (true) {
|
|
161
173
|
const inflight = ensureLocks.get(legacyId);
|
|
162
174
|
if (!inflight) break;
|
|
@@ -169,7 +181,7 @@ async function ensureItemNotification(slug, schema, itemId, displayLabel, priori
|
|
|
169
181
|
legacyId,
|
|
170
182
|
displayLabel,
|
|
171
183
|
priority,
|
|
172
|
-
|
|
184
|
+
scope
|
|
173
185
|
}) };
|
|
174
186
|
ensureLocks.set(legacyId, lock);
|
|
175
187
|
try {
|
|
@@ -182,8 +194,9 @@ async function ensureItemNotification(slug, schema, itemId, displayLabel, priori
|
|
|
182
194
|
* place (preserving id / position / createdAt) so a record whose flagged
|
|
183
195
|
* value changed while it stayed pending re-colours the bell without a
|
|
184
196
|
* clear+republish flicker. No-op when the stored priority already matches. */
|
|
185
|
-
async function reconcileEntrySeverity(slug, itemId, entries, priority,
|
|
197
|
+
async function reconcileEntrySeverity(slug, itemId, entries, priority, scope) {
|
|
186
198
|
const adapter = requireAdapter();
|
|
199
|
+
const { root } = scope;
|
|
187
200
|
for (const entry of entries) {
|
|
188
201
|
const parsed = adapter.readEntry(entry.pluginData);
|
|
189
202
|
if (!parsed || parsed.priority === priority) continue;
|
|
@@ -200,12 +213,13 @@ async function reconcileEntrySeverity(slug, itemId, entries, priority, root) {
|
|
|
200
213
|
});
|
|
201
214
|
}
|
|
202
215
|
}
|
|
203
|
-
async function doEnsureItemNotification({ slug, schema, itemId, legacyId, displayLabel, priority,
|
|
216
|
+
async function doEnsureItemNotification({ slug, schema, itemId, legacyId, displayLabel, priority, scope }) {
|
|
204
217
|
const adapter = requireAdapter();
|
|
218
|
+
const { root } = scope;
|
|
205
219
|
try {
|
|
206
|
-
const existing = await findActiveEntries(slug, itemId,
|
|
220
|
+
const existing = await findActiveEntries(slug, itemId, scope);
|
|
207
221
|
if (existing.length > 0) {
|
|
208
|
-
await reconcileEntrySeverity(slug, itemId, existing, priority,
|
|
222
|
+
await reconcileEntrySeverity(slug, itemId, existing, priority, scope);
|
|
209
223
|
return;
|
|
210
224
|
}
|
|
211
225
|
const navigateTarget = adapter.buildNavigateTarget(slug, itemId, root);
|
|
@@ -235,9 +249,18 @@ async function doEnsureItemNotification({ slug, schema, itemId, legacyId, displa
|
|
|
235
249
|
/** Idempotently clear EVERY bell entry that matches this (slug, itemId).
|
|
236
250
|
* Silent no-op when nothing matches. The "every" is defensive: if a
|
|
237
251
|
* duplicate ever slips through, this drains the lot. */
|
|
238
|
-
async function clearItemNotification(slug, itemId, root) {
|
|
252
|
+
async function clearItemNotification(slug, itemId, root, aid) {
|
|
253
|
+
await clearScoped(slug, itemId, {
|
|
254
|
+
root,
|
|
255
|
+
aid
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
/** The scoped form every internal caller uses. `clearItemNotification` keeps
|
|
259
|
+
* its positional `(slug, itemId, root?)` shape because it is public API
|
|
260
|
+
* (MulmoTerminal calls it); `aid` is additive. */
|
|
261
|
+
async function clearScoped(slug, itemId, scope) {
|
|
239
262
|
try {
|
|
240
|
-
const ids = await findActiveEntryIds(slug, itemId,
|
|
263
|
+
const ids = await findActiveEntryIds(slug, itemId, scope);
|
|
241
264
|
for (const entryId of ids) await require_notifier.clear(entryId);
|
|
242
265
|
} catch (err) {
|
|
243
266
|
log().warn("notify clear failed", {
|
|
@@ -257,19 +280,19 @@ async function clearItemNotification(slug, itemId, root) {
|
|
|
257
280
|
* `{ workspaceRoot: <tmpdir> }` so the check accepts a fixture dataDir. */
|
|
258
281
|
async function reconcileItem(collection, itemId, ioOpts = {}, now = evalNow()) {
|
|
259
282
|
const { slug, schema } = collection;
|
|
260
|
-
const
|
|
283
|
+
const scope = bellScopeOf(collection, ioOpts);
|
|
261
284
|
if (!schema.completionField) {
|
|
262
|
-
await
|
|
285
|
+
await clearScoped(slug, itemId, scope);
|
|
263
286
|
return;
|
|
264
287
|
}
|
|
265
|
-
const item = await
|
|
288
|
+
const item = await require_store.storeFor(collection, ioOpts).read(itemId);
|
|
266
289
|
if (item === null) {
|
|
267
|
-
await
|
|
290
|
+
await clearScoped(slug, itemId, scope);
|
|
268
291
|
return;
|
|
269
292
|
}
|
|
270
293
|
await require_server.maybeSpawnSuccessor(collection, item, itemId, ioOpts);
|
|
271
294
|
if (require_promptSafety.itemIsDone(schema, item)) {
|
|
272
|
-
await
|
|
295
|
+
await clearScoped(slug, itemId, scope);
|
|
273
296
|
return;
|
|
274
297
|
}
|
|
275
298
|
if (schema.triggerField) {
|
|
@@ -281,15 +304,15 @@ async function reconcileItem(collection, itemId, ioOpts = {}, now = evalNow()) {
|
|
|
281
304
|
triggerField: schema.triggerField
|
|
282
305
|
});
|
|
283
306
|
if (due !== true) {
|
|
284
|
-
await
|
|
307
|
+
await clearScoped(slug, itemId, scope);
|
|
285
308
|
return;
|
|
286
309
|
}
|
|
287
310
|
}
|
|
288
311
|
if (!require_promptSafety.whenMatches(schema.notifyWhen, item)) {
|
|
289
|
-
await
|
|
312
|
+
await clearScoped(slug, itemId, scope);
|
|
290
313
|
return;
|
|
291
314
|
}
|
|
292
|
-
await ensureItemNotification(slug, schema, itemId, resolveDisplayLabel(schema, item, itemId), notifyPriorityForItem(schema, item),
|
|
315
|
+
await ensureItemNotification(slug, schema, itemId, resolveDisplayLabel(schema, item, itemId), notifyPriorityForItem(schema, item), scope);
|
|
293
316
|
}
|
|
294
317
|
/** Boot-time reconcile: walk every record of the collection once (through
|
|
295
318
|
* its store) and reconcile it. Catches up changes that happened while the
|
|
@@ -301,7 +324,7 @@ async function reconcileAllItems(collection, ioOpts = {}, now = evalNow()) {
|
|
|
301
324
|
if (!schema.completionField) return;
|
|
302
325
|
let items;
|
|
303
326
|
try {
|
|
304
|
-
items = await
|
|
327
|
+
items = await require_store.storeFor(collection, ioOpts).list();
|
|
305
328
|
} catch (err) {
|
|
306
329
|
log().warn("reconcile list failed", {
|
|
307
330
|
slug,
|
|
@@ -335,7 +358,7 @@ async function reconcileAllItems(collection, ioOpts = {}, now = evalNow()) {
|
|
|
335
358
|
* ever clear, while the reconcile publishes a second rooted one beside it.
|
|
336
359
|
* Clearing converges — the record republishes rooted if still pending. */
|
|
337
360
|
function sweepVerdict(entry, ownRoot) {
|
|
338
|
-
if (entry.aid !== void 0) return "
|
|
361
|
+
if (entry.aid !== void 0) return "shared";
|
|
339
362
|
if (entry.root === ownRoot) return "mine";
|
|
340
363
|
if (entry.root === void 0) return "drop-legacy";
|
|
341
364
|
return "skip";
|
|
@@ -345,12 +368,40 @@ function sweepVerdict(entry, ownRoot) {
|
|
|
345
368
|
* `notifyWhen`. The reverse of the reconcile invariant, applied to an entry
|
|
346
369
|
* whose record the forward pass can never walk to (a delete leaves a bell that
|
|
347
370
|
* a walk over the SURVIVING records cannot clear). */
|
|
348
|
-
async function isStaleEntry(slug, itemId, opts) {
|
|
349
|
-
const collection = await
|
|
371
|
+
async function isStaleEntry(slug, itemId, opts, aid) {
|
|
372
|
+
const collection = await require_store.loadCollection(slug, opts);
|
|
373
|
+
if (aid !== void 0 && collection?.appId !== aid) return false;
|
|
350
374
|
if (!collection || !collection.schema.completionField) return true;
|
|
351
|
-
|
|
375
|
+
return recordNoLongerBells(collection, itemId, opts);
|
|
376
|
+
}
|
|
377
|
+
/** The record half of the staleness question: gone, done, or outside
|
|
378
|
+
* `notifyWhen`. Split out so the caller stays one decision — WHOSE entry this
|
|
379
|
+
* is — and this one stays the other. */
|
|
380
|
+
async function recordNoLongerBells(collection, itemId, opts) {
|
|
381
|
+
const item = await require_store.storeFor(collection, opts).read(itemId);
|
|
352
382
|
return item === null || require_promptSafety.itemIsDone(collection.schema, item) || !require_promptSafety.whenMatches(collection.schema.notifyWhen, item);
|
|
353
383
|
}
|
|
384
|
+
/** One entry's fate: whose it is (`sweepVerdict`), then — when it is this
|
|
385
|
+
* sweep's to judge — whether its record still justifies the bell. Extracted so
|
|
386
|
+
* the loop above stays a walk and this stays the decision. */
|
|
387
|
+
async function sweepOneEntry(entryId, parsed, ownRoot, opts) {
|
|
388
|
+
const verdict = sweepVerdict(parsed, ownRoot);
|
|
389
|
+
if (verdict === "skip") return;
|
|
390
|
+
if (verdict === "drop-legacy") {
|
|
391
|
+
await require_notifier.clear(entryId);
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
const { slug, itemId } = parsed;
|
|
395
|
+
try {
|
|
396
|
+
if (await isStaleEntry(slug, itemId, opts, verdict === "shared" ? parsed.aid : void 0)) await require_notifier.clear(entryId);
|
|
397
|
+
} catch (err) {
|
|
398
|
+
log().warn("sweep entry failed", {
|
|
399
|
+
slug,
|
|
400
|
+
itemId,
|
|
401
|
+
error: errMsg(err)
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
}
|
|
354
405
|
async function sweepStaleActiveEntries(opts = {}) {
|
|
355
406
|
const adapter = requireAdapter();
|
|
356
407
|
let entries;
|
|
@@ -366,22 +417,7 @@ async function sweepStaleActiveEntries(opts = {}) {
|
|
|
366
417
|
if (!own) continue;
|
|
367
418
|
const parsed = parseCompletionLegacyId(own.legacyId);
|
|
368
419
|
if (!parsed) continue;
|
|
369
|
-
|
|
370
|
-
if (verdict === "skip") continue;
|
|
371
|
-
if (verdict === "drop-legacy") {
|
|
372
|
-
await require_notifier.clear(entry.id);
|
|
373
|
-
continue;
|
|
374
|
-
}
|
|
375
|
-
const { slug, itemId } = parsed;
|
|
376
|
-
try {
|
|
377
|
-
if (await isStaleEntry(slug, itemId, opts)) await require_notifier.clear(entry.id);
|
|
378
|
-
} catch (err) {
|
|
379
|
-
log().warn("sweep entry failed", {
|
|
380
|
-
slug,
|
|
381
|
-
itemId,
|
|
382
|
-
error: errMsg(err)
|
|
383
|
-
});
|
|
384
|
-
}
|
|
420
|
+
await sweepOneEntry(entry.id, parsed, ownRoot, opts);
|
|
385
421
|
}
|
|
386
422
|
}
|
|
387
423
|
/** Test-only: clear the per-key in-flight locks. */
|
|
@@ -415,7 +451,7 @@ var WATCHER_ROOT_CONFLICT = "WATCHER_ROOT_CONFLICT";
|
|
|
415
451
|
* is a comparison, and under an explicit-root binding there is no default to
|
|
416
452
|
* read, which is not an error here. */
|
|
417
453
|
function effectiveRoot(opts) {
|
|
418
|
-
const root = opts?.workspaceRoot ??
|
|
454
|
+
const root = opts?.workspaceRoot ?? require_store.peekWorkspaceRoot() ?? void 0;
|
|
419
455
|
return root === void 0 ? void 0 : require_root.canonicalRoot(root);
|
|
420
456
|
}
|
|
421
457
|
/** The discovery options a generation runs under, with an explicit root put in
|
|
@@ -591,9 +627,58 @@ async function _tickTimeTriggersForTesting(now, workspaceRoot) {
|
|
|
591
627
|
async function tickTimeTriggers(gen, now = evalNow()) {
|
|
592
628
|
for (const entry of gen.watchers.values()) {
|
|
593
629
|
const { schema } = entry.collection;
|
|
630
|
+
if (cannotReportChanges(gen, entry.collection)) continue;
|
|
594
631
|
if (!schema.triggerField && !schema.spawn) continue;
|
|
595
632
|
await reconcileAllItems(entry.collection, gen.discoveryOpts, now);
|
|
596
633
|
}
|
|
634
|
+
await tickUnwatchedCollections(gen, now);
|
|
635
|
+
}
|
|
636
|
+
/** True when the collection's store implements no `watch` — nothing will ever
|
|
637
|
+
* tell this module its records moved, so the clock tick is its only change
|
|
638
|
+
* detection. A capability question, deliberately not a backend one: the day a
|
|
639
|
+
* shared collection's store grows an `onSnapshot` watch, it stops being
|
|
640
|
+
* special here with no edit to this file. */
|
|
641
|
+
function cannotReportChanges(gen, collection) {
|
|
642
|
+
return require_store.storeFor(collection, gen.discoveryOpts).watch === void 0;
|
|
643
|
+
}
|
|
644
|
+
/** True when a schema declares behaviour that only a reconcile pass can
|
|
645
|
+
* produce: bells (`completionField`), date-triggered bells (`triggerField`),
|
|
646
|
+
* or recurrence successors (`spawn`). */
|
|
647
|
+
function needsReconcilePass(schema) {
|
|
648
|
+
return Boolean(schema.completionField ?? schema.triggerField ?? schema.spawn);
|
|
649
|
+
}
|
|
650
|
+
/** One unwatched collection's reconcile pass. Extracted so the single-flight
|
|
651
|
+
* callback doesn't close over loop state.
|
|
652
|
+
*
|
|
653
|
+
* `reconcileAllItems` already swallows a failing store read (it logs and
|
|
654
|
+
* returns), so a closed session or a denied rule surfaces there, not as a
|
|
655
|
+
* rejection here. This catch is only for the unexpected — it must not let one
|
|
656
|
+
* collection's fault abort the rest of the tick. */
|
|
657
|
+
async function reconcileUnwatched(gen, collection, now) {
|
|
658
|
+
try {
|
|
659
|
+
await runSingleFlight(gen.collectionSlots, collection.slug, () => reconcileAllItems(collection, gen.discoveryOpts, now));
|
|
660
|
+
} catch (err) {
|
|
661
|
+
log().warn("unwatched collection reconcile failed", {
|
|
662
|
+
slug: collection.slug,
|
|
663
|
+
error: errMsg(err)
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
/** Stand in for the store-change path (2) on backends that don't have one.
|
|
668
|
+
*
|
|
669
|
+
* Gated on `needsReconcilePass` so a collection declaring none of it costs
|
|
670
|
+
* nothing: unlike the local backends, every pass here is a network round trip.
|
|
671
|
+
*
|
|
672
|
+
* A collection that DROPS OUT of eligibility (its `completionField` edited
|
|
673
|
+
* away, or the collection removed) is not this pass's problem — `syncWatchers`
|
|
674
|
+
* already sweeps on a changed schema and on a vanished slug, and the sweep is
|
|
675
|
+
* what clears bells the declaration no longer justifies. */
|
|
676
|
+
async function tickUnwatchedCollections(gen, now) {
|
|
677
|
+
if (require_store.firestoreHandle() === null) return;
|
|
678
|
+
const pending = [...gen.watchers.values()].map((entry) => entry.collection).filter((collection) => cannotReportChanges(gen, collection) && needsReconcilePass(collection.schema));
|
|
679
|
+
if (pending.length === 0) return;
|
|
680
|
+
for (const collection of pending) await reconcileUnwatched(gen, collection, now);
|
|
681
|
+
await sweepStaleActiveEntries(gen.discoveryOpts);
|
|
597
682
|
}
|
|
598
683
|
/** Reconcile the watcher set against the currently-discovered
|
|
599
684
|
* collections. Adds watchers for new slugs (with a boot reconcile of
|
|
@@ -603,7 +688,7 @@ async function tickTimeTriggers(gen, now = evalNow()) {
|
|
|
603
688
|
async function syncWatchers(gen) {
|
|
604
689
|
let collections;
|
|
605
690
|
try {
|
|
606
|
-
collections = await
|
|
691
|
+
collections = await require_store.discoverCollections(gen.discoveryOpts);
|
|
607
692
|
} catch (err) {
|
|
608
693
|
log().warn("watcher discover failed", { error: errMsg(err) });
|
|
609
694
|
return false;
|
|
@@ -634,7 +719,13 @@ function stopVanishedWatchers(gen, liveSlugs) {
|
|
|
634
719
|
* modes. The mounted fs.watch is bound to the OLD location, so it must
|
|
635
720
|
* be remounted, not just re-reconciled. */
|
|
636
721
|
function storagePathChanged(previous, next) {
|
|
637
|
-
return previous.dataSource?.path !== next.dataSource?.path || previous.dataPath !== next.dataPath || previous.storage?.
|
|
722
|
+
return previous.dataSource?.path !== next.dataSource?.path || previous.dataPath !== next.dataPath || previous.storage?.type !== next.storage?.type || storageFilePath(previous.storage) !== storageFilePath(next.storage);
|
|
723
|
+
}
|
|
724
|
+
/** The on-disk path of a storage backend, or undefined when it has none (a
|
|
725
|
+
* shared collection keeps its records off this filesystem, so there is no
|
|
726
|
+
* mount to move — only a `type` change matters for it). */
|
|
727
|
+
function storageFilePath(storage) {
|
|
728
|
+
return storage?.type === "sqlite" ? storage.path : void 0;
|
|
638
729
|
}
|
|
639
730
|
/** Re-reconcile already-watched collections whose schema changed since
|
|
640
731
|
* the last tick. New collections fall through to `startNewWatchers`. */
|
|
@@ -697,7 +788,7 @@ async function startWatcherFor(gen, collection) {
|
|
|
697
788
|
const { slug } = collection;
|
|
698
789
|
try {
|
|
699
790
|
await reconcileAllItems(collection, gen.discoveryOpts);
|
|
700
|
-
const store =
|
|
791
|
+
const store = require_store.storeFor(collection, gen.discoveryOpts);
|
|
701
792
|
const unsubscribe = store.watch ? await store.watch((change) => {
|
|
702
793
|
handleStoreChange(gen, slug, change).catch((err) => {
|
|
703
794
|
log().warn("store change handling failed", {
|
|
@@ -751,7 +842,7 @@ async function handleStoreChange(gen, slug, change) {
|
|
|
751
842
|
* re-run. */
|
|
752
843
|
function scheduleCollectionReconcile(gen, slug) {
|
|
753
844
|
return runSingleFlight(gen.collectionSlots, slug, async () => {
|
|
754
|
-
const collection = await
|
|
845
|
+
const collection = await require_store.loadCollection(slug, gen.discoveryOpts);
|
|
755
846
|
if (!collection) return;
|
|
756
847
|
await reconcileAllItems(collection, gen.discoveryOpts);
|
|
757
848
|
await sweepStaleActiveEntries(gen.discoveryOpts);
|
|
@@ -829,7 +920,7 @@ async function publishItemChange(gen, collection, itemId) {
|
|
|
829
920
|
}
|
|
830
921
|
async function itemFileExists(dataDir, itemId) {
|
|
831
922
|
try {
|
|
832
|
-
await (0, node_fs_promises.access)(
|
|
923
|
+
await (0, node_fs_promises.access)(require_store.itemFilePath(dataDir, itemId));
|
|
833
924
|
return true;
|
|
834
925
|
} catch {
|
|
835
926
|
return false;
|
|
@@ -844,9 +935,9 @@ async function itemFileExists(dataDir, itemId) {
|
|
|
844
935
|
* would be a guess. Undefined in a single-workspace host, which is exactly
|
|
845
936
|
* what the payload contract means by "the host's configured root". */
|
|
846
937
|
function safePublish(gen, payload) {
|
|
847
|
-
const enriched =
|
|
938
|
+
const enriched = require_store.collectionChangePayload(payload, gen.discoveryOpts.workspaceRoot);
|
|
848
939
|
try {
|
|
849
|
-
|
|
940
|
+
require_store.publishCollectionChange(enriched);
|
|
850
941
|
} catch (err) {
|
|
851
942
|
log().warn("collection change publish failed", {
|
|
852
943
|
slug: enriched.slug,
|