@mulmoclaude/core 0.1.0 → 0.2.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.
@@ -0,0 +1,49 @@
1
+ import { NotifierSeverity } from '../notifier';
2
+ /** Two-level urgency a pending record can carry, derived from the
3
+ * schema's `notifyWhen` spec. The host maps this onto its own severity
4
+ * scale via `priorityToSeverity`. */
5
+ export type CompletionPriority = "normal" | "high";
6
+ export interface CollectionWatcherLogger {
7
+ info: (message: string, data?: Record<string, unknown>) => void;
8
+ warn: (message: string, data?: Record<string, unknown>) => void;
9
+ }
10
+ /** The host-specific notification surface the reconciler binds to. The
11
+ * reconciler owns the internal `legacyId` key (it encodes slug+itemId
12
+ * and round-trips it through `pluginData`); the adapter only wraps /
13
+ * unwraps it into whatever shape the host's bell expects. */
14
+ export interface CollectionNotificationAdapter {
15
+ /** Plugin namespace these bell entries publish under (MulmoClaude: "todo"). */
16
+ pluginPkg: string;
17
+ /** Map a record's completion priority onto the host's bell severity. */
18
+ priorityToSeverity: (priority: CompletionPriority) => NotifierSeverity;
19
+ /** Build the in-app deep-link the bell row routes to on click. */
20
+ buildNavigateTarget: (slug: string, itemId: string) => string;
21
+ /** Wrap the reconciler's internal key + priority into the host's
22
+ * `pluginData` shape. Stored verbatim on the entry; recovered via
23
+ * `readEntry`. */
24
+ buildPluginData: (input: {
25
+ legacyId: string;
26
+ slug: string;
27
+ itemId: string;
28
+ priority: CompletionPriority;
29
+ navigateTarget: string;
30
+ }) => unknown;
31
+ /** Recognise a bell entry produced by this reconciler and recover its
32
+ * internal key + stored priority. Returns null for entries that didn't
33
+ * originate here, so `listAll()` scans skip foreign entries. */
34
+ readEntry: (pluginData: unknown) => {
35
+ legacyId: string;
36
+ priority: CompletionPriority;
37
+ } | null;
38
+ }
39
+ /** Wire the host adapter + logger. Call once at startup, before
40
+ * `startCollectionWatchers` or any direct reconcile call. */
41
+ export declare function configureCollectionWatchers(config: {
42
+ adapter: CollectionNotificationAdapter;
43
+ log?: CollectionWatcherLogger;
44
+ }): void;
45
+ export declare function requireAdapter(): CollectionNotificationAdapter;
46
+ export declare function log(): CollectionWatcherLogger;
47
+ /** Test-only: clear the host wiring. */
48
+ export declare function resetCollectionWatchersConfig(): void;
49
+ export declare function errMsg(err: unknown): string;
@@ -0,0 +1,554 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ require("../chunk-CKQMccvm.cjs");
3
+ const require_collection_index = require("../collection/index.cjs");
4
+ const require_server = require("../server-BjoKk2tR.cjs");
5
+ const require_notifier = require("../notifier-lJ4v2Y6B.cjs");
6
+ let node_fs = require("node:fs");
7
+ let node_fs_promises = require("node:fs/promises");
8
+ //#region src/collection-watchers/config.ts
9
+ var NOOP_LOG = {
10
+ info: () => {},
11
+ warn: () => {}
12
+ };
13
+ var adapter = null;
14
+ var activeLogger = NOOP_LOG;
15
+ /** Wire the host adapter + logger. Call once at startup, before
16
+ * `startCollectionWatchers` or any direct reconcile call. */
17
+ function configureCollectionWatchers(config) {
18
+ ({adapter} = config);
19
+ activeLogger = config.log ?? NOOP_LOG;
20
+ }
21
+ function requireAdapter() {
22
+ if (!adapter) throw new Error("collection-watchers: configureCollectionWatchers() not called");
23
+ return adapter;
24
+ }
25
+ function log() {
26
+ return activeLogger;
27
+ }
28
+ /** Test-only: clear the host wiring. */
29
+ function resetCollectionWatchersConfig() {
30
+ adapter = null;
31
+ activeLogger = NOOP_LOG;
32
+ }
33
+ function errMsg(err) {
34
+ return err instanceof Error ? err.message : String(err);
35
+ }
36
+ //#endregion
37
+ //#region src/collection-watchers/reconciler.ts
38
+ /** The internal-id prefix every collection-completion bell entry carries.
39
+ * Used both to build new keys and to filter sweep candidates from the
40
+ * active bell. */
41
+ var LEGACY_ID_PREFIX = "collection-completion:";
42
+ /** Stable key encoding slug + item, round-tripped through the entry's
43
+ * `pluginData` so we can find it later without a side state file. Slug +
44
+ * itemId are upstream-validated via `safeSlugName`, which forbids the
45
+ * colon separator, so the two-segment parse below is unambiguous. */
46
+ function completionLegacyId(slug, itemId) {
47
+ return `${LEGACY_ID_PREFIX}${slug}:${itemId}`;
48
+ }
49
+ /** Decode a key back into its (slug, itemId) pair, or null if the string
50
+ * didn't originate from this module. Used by the sweep step. */
51
+ function parseCompletionLegacyId(legacyId) {
52
+ if (!legacyId.startsWith(LEGACY_ID_PREFIX)) return null;
53
+ const body = legacyId.slice(22);
54
+ const colon = body.indexOf(":");
55
+ if (colon < 0) return null;
56
+ return {
57
+ slug: body.slice(0, colon),
58
+ itemId: body.slice(colon + 1)
59
+ };
60
+ }
61
+ /** The human-readable label shown in a completion notification's title.
62
+ * Uses the schema's `displayField` value when declared and non-empty;
63
+ * otherwise falls back to the record's primaryKey (`itemId`). */
64
+ function resolveDisplayLabel(schema, item, itemId) {
65
+ const { displayField } = schema;
66
+ if (!displayField) return itemId;
67
+ const raw = item[displayField];
68
+ if (raw === void 0 || raw === null) return itemId;
69
+ const label = String(raw).trim();
70
+ return label.length > 0 ? label : itemId;
71
+ }
72
+ /** True iff the schema declares completion tracking AND the item's
73
+ * `completionField` value (stringified) is in `completionDoneValues`. */
74
+ function itemIsDone(schema, item) {
75
+ const { completionField, completionDoneValues } = schema;
76
+ if (!completionField || !completionDoneValues) return false;
77
+ const raw = item[completionField];
78
+ if (raw === void 0 || raw === null) return false;
79
+ return completionDoneValues.includes(String(raw));
80
+ }
81
+ /** Every active bell entry whose key matches this (slug, itemId).
82
+ * Returns multiple when defensive cleanup is needed. Scans `listAll()`
83
+ * — cheap because the active set is bounded. */
84
+ async function findActiveEntries(slug, itemId) {
85
+ const adapter = requireAdapter();
86
+ const legacyId = completionLegacyId(slug, itemId);
87
+ return (await require_notifier.listAll()).filter((entry) => adapter.readEntry(entry.pluginData)?.legacyId === legacyId);
88
+ }
89
+ async function findActiveEntryIds(slug, itemId) {
90
+ return (await findActiveEntries(slug, itemId)).map((entry) => entry.id);
91
+ }
92
+ var ensureLocks = /* @__PURE__ */ new Map();
93
+ /** Bell priority for a record: the FIRST flagged value in `notifyWhen.in`
94
+ * (most urgent) reads `high`, every other flagged value `normal`.
95
+ * Collections with no `notifyWhen` (notify for every open record) stay
96
+ * `normal`. */
97
+ function notifyPriorityForItem(schema, item) {
98
+ const spec = schema.notifyWhen;
99
+ if (!spec) return "normal";
100
+ const value = item[spec.field] === void 0 || item[spec.field] === null ? "" : String(item[spec.field]);
101
+ return spec.in.indexOf(value) === 0 ? "high" : "normal";
102
+ }
103
+ async function ensureItemNotification(slug, schema, itemId, displayLabel, priority) {
104
+ const legacyId = completionLegacyId(slug, itemId);
105
+ while (true) {
106
+ const inflight = ensureLocks.get(legacyId);
107
+ if (!inflight) break;
108
+ await inflight.promise;
109
+ }
110
+ const lock = { promise: doEnsureItemNotification(slug, schema, itemId, legacyId, displayLabel, priority) };
111
+ ensureLocks.set(legacyId, lock);
112
+ try {
113
+ await lock.promise;
114
+ } finally {
115
+ if (ensureLocks.get(legacyId) === lock) ensureLocks.delete(legacyId);
116
+ }
117
+ }
118
+ /** Converge any already-present bell entries to `priority`, updating in
119
+ * place (preserving id / position / createdAt) so a record whose flagged
120
+ * value changed while it stayed pending re-colours the bell without a
121
+ * clear+republish flicker. No-op when the stored priority already matches. */
122
+ async function reconcileEntrySeverity(slug, itemId, entries, priority) {
123
+ const adapter = requireAdapter();
124
+ for (const entry of entries) {
125
+ const parsed = adapter.readEntry(entry.pluginData);
126
+ if (!parsed || parsed.priority === priority) continue;
127
+ await require_notifier.updateForPlugin(adapter.pluginPkg, entry.id, {
128
+ severity: adapter.priorityToSeverity(priority),
129
+ pluginData: adapter.buildPluginData({
130
+ legacyId: parsed.legacyId,
131
+ slug,
132
+ itemId,
133
+ priority,
134
+ navigateTarget: adapter.buildNavigateTarget(slug, itemId)
135
+ })
136
+ });
137
+ }
138
+ }
139
+ async function doEnsureItemNotification(slug, schema, itemId, legacyId, displayLabel, priority) {
140
+ const adapter = requireAdapter();
141
+ try {
142
+ const existing = await findActiveEntries(slug, itemId);
143
+ if (existing.length > 0) {
144
+ await reconcileEntrySeverity(slug, itemId, existing, priority);
145
+ return;
146
+ }
147
+ const navigateTarget = adapter.buildNavigateTarget(slug, itemId);
148
+ await require_notifier.publish({
149
+ pluginPkg: adapter.pluginPkg,
150
+ severity: adapter.priorityToSeverity(priority),
151
+ lifecycle: "action",
152
+ title: `${schema.title}: ${displayLabel}`,
153
+ navigateTarget,
154
+ pluginData: adapter.buildPluginData({
155
+ legacyId,
156
+ slug,
157
+ itemId,
158
+ priority,
159
+ navigateTarget
160
+ })
161
+ });
162
+ } catch (err) {
163
+ log().warn("notify ensure failed", {
164
+ slug,
165
+ itemId,
166
+ error: errMsg(err)
167
+ });
168
+ }
169
+ }
170
+ /** Idempotently clear EVERY bell entry that matches this (slug, itemId).
171
+ * Silent no-op when nothing matches. The "every" is defensive: if a
172
+ * duplicate ever slips through, this drains the lot. */
173
+ async function clearItemNotification(slug, itemId) {
174
+ try {
175
+ const ids = await findActiveEntryIds(slug, itemId);
176
+ for (const entryId of ids) await require_notifier.clear(entryId);
177
+ } catch (err) {
178
+ log().warn("notify clear failed", {
179
+ slug,
180
+ itemId,
181
+ error: errMsg(err)
182
+ });
183
+ }
184
+ }
185
+ /** Reconcile one item to the desired bell state. Re-reads the record from
186
+ * disk so the decision is grounded in current truth, not in the event
187
+ * payload. Safe to call when the file is missing (delete path).
188
+ *
189
+ * `ioOpts` flows into `readItem`'s workspace-containment check —
190
+ * production callers (the watcher) pass nothing; tests pass
191
+ * `{ workspaceRoot: <tmpdir> }` so the check accepts a fixture dataDir. */
192
+ async function reconcileItem(slug, schema, dataDir, itemId, ioOpts = {}, now = /* @__PURE__ */ new Date()) {
193
+ if (!schema.completionField) {
194
+ await clearItemNotification(slug, itemId);
195
+ return;
196
+ }
197
+ const item = await require_server.readItem(dataDir, itemId, ioOpts);
198
+ if (item === null) {
199
+ await clearItemNotification(slug, itemId);
200
+ return;
201
+ }
202
+ await require_server.maybeSpawnSuccessor(slug, schema, dataDir, item, itemId, ioOpts);
203
+ if (itemIsDone(schema, item)) {
204
+ await clearItemNotification(slug, itemId);
205
+ return;
206
+ }
207
+ if (schema.triggerField) {
208
+ const triggerRaw = item[schema.triggerField];
209
+ const due = require_server.isTriggerDue(triggerRaw, now, schema.triggerLeadDays);
210
+ if (due === null && !(triggerRaw === void 0 || triggerRaw === null || triggerRaw === "")) log().warn("trigger date unparseable, suppressing bell", {
211
+ slug,
212
+ itemId,
213
+ triggerField: schema.triggerField
214
+ });
215
+ if (due !== true) {
216
+ await clearItemNotification(slug, itemId);
217
+ return;
218
+ }
219
+ }
220
+ if (!require_collection_index.whenMatches(schema.notifyWhen, item)) {
221
+ await clearItemNotification(slug, itemId);
222
+ return;
223
+ }
224
+ await ensureItemNotification(slug, schema, itemId, resolveDisplayLabel(schema, item, itemId), notifyPriorityForItem(schema, item));
225
+ }
226
+ /** Boot-time reconcile: walk every record under `dataDir` once and
227
+ * reconcile it. Catches up changes that happened while the server was
228
+ * down. Deleted items are covered by `sweepStaleActiveEntries`, not this
229
+ * function (it only sees files that exist). */
230
+ async function reconcileAllItems(slug, schema, dataDir, ioOpts = {}, now = /* @__PURE__ */ new Date()) {
231
+ if (!schema.completionField) return;
232
+ let items;
233
+ try {
234
+ items = await require_server.listItems(dataDir, ioOpts);
235
+ } catch (err) {
236
+ log().warn("reconcile list failed", {
237
+ slug,
238
+ dataDir,
239
+ error: errMsg(err)
240
+ });
241
+ return;
242
+ }
243
+ const { primaryKey } = schema;
244
+ for (const item of items) {
245
+ const raw = item[primaryKey];
246
+ if (typeof raw !== "string" || raw.length === 0) continue;
247
+ await reconcileItem(slug, schema, dataDir, raw, ioOpts, now);
248
+ }
249
+ }
250
+ /** Boot-time sweep over the active bell: drop any entries whose underlying
251
+ * file is gone, whose collection was deleted, whose schema no longer
252
+ * tracks completion, or whose item is now done. Reverse-covers the cases
253
+ * `reconcileAllItems` misses (it only walks files that exist). */
254
+ async function sweepStaleActiveEntries(opts = {}) {
255
+ const adapter = requireAdapter();
256
+ let entries;
257
+ try {
258
+ entries = await require_notifier.listAll();
259
+ } catch (err) {
260
+ log().warn("sweep list failed", { error: errMsg(err) });
261
+ return;
262
+ }
263
+ for (const entry of entries) {
264
+ const own = adapter.readEntry(entry.pluginData);
265
+ if (!own) continue;
266
+ const parsed = parseCompletionLegacyId(own.legacyId);
267
+ if (!parsed) continue;
268
+ const { slug, itemId } = parsed;
269
+ try {
270
+ const collection = await require_server.loadCollection(slug, opts);
271
+ if (!collection || !collection.schema.completionField) {
272
+ await require_notifier.clear(entry.id);
273
+ continue;
274
+ }
275
+ const item = await require_server.readItem(collection.dataDir, itemId, opts);
276
+ if (item === null || itemIsDone(collection.schema, item) || !require_collection_index.whenMatches(collection.schema.notifyWhen, item)) await require_notifier.clear(entry.id);
277
+ } catch (err) {
278
+ log().warn("sweep entry failed", {
279
+ slug,
280
+ itemId,
281
+ error: errMsg(err)
282
+ });
283
+ }
284
+ }
285
+ }
286
+ /** Test-only: clear the per-key in-flight locks. */
287
+ function _resetReconcilerLocksForTesting() {
288
+ ensureLocks.clear();
289
+ }
290
+ //#endregion
291
+ //#region src/collection-watchers/watcher.ts
292
+ var ONE_SECOND_MS = 1e3;
293
+ var ONE_MINUTE_MS = 60 * ONE_SECOND_MS;
294
+ var REDISCOVERY_INTERVAL_MS = 30 * ONE_SECOND_MS;
295
+ var TRIGGER_TICK_INTERVAL_MS = ONE_MINUTE_MS;
296
+ var watchers = /* @__PURE__ */ new Map();
297
+ var rediscoveryTimer = null;
298
+ var triggerTimer = null;
299
+ var started = false;
300
+ /** Discovery options threaded into every `discoverCollections` /
301
+ * `loadCollection` / `sweepStaleActiveEntries` call. Production: empty
302
+ * (live workspace). Tests: `{ workspaceRoot, userSkillsDir }` pointing
303
+ * at a fixture tree. Module-level so per-event handlers can read it
304
+ * without threading through every signature. */
305
+ var discoveryOpts = {};
306
+ var itemSlots = /* @__PURE__ */ new Map();
307
+ /** Boot entry point: sweep stale active entries, then mount watchers for
308
+ * every discovered collection and arm the periodic re-discovery poll.
309
+ * Idempotent — a second call is a no-op. */
310
+ async function startCollectionWatchers(opts = {}) {
311
+ if (started) return;
312
+ discoveryOpts = opts.discoveryOpts ?? {};
313
+ try {
314
+ await sweepStaleActiveEntries(discoveryOpts);
315
+ await syncWatchers();
316
+ const intervalMs = opts.rediscoveryIntervalMs === void 0 ? REDISCOVERY_INTERVAL_MS : opts.rediscoveryIntervalMs;
317
+ if (intervalMs !== null) {
318
+ rediscoveryTimer = setInterval(() => {
319
+ syncWatchers().catch((err) => {
320
+ log().warn("watcher rediscovery failed", { error: errMsg(err) });
321
+ });
322
+ }, intervalMs);
323
+ rediscoveryTimer.unref();
324
+ }
325
+ const triggerMs = opts.triggerTickIntervalMs === void 0 ? TRIGGER_TICK_INTERVAL_MS : opts.triggerTickIntervalMs;
326
+ if (triggerMs !== null) {
327
+ triggerTimer = setInterval(() => {
328
+ tickTimeTriggers().catch((err) => {
329
+ log().warn("watcher trigger tick failed", { error: errMsg(err) });
330
+ });
331
+ }, triggerMs);
332
+ triggerTimer.unref();
333
+ }
334
+ started = true;
335
+ } catch (err) {
336
+ discoveryOpts = {};
337
+ throw err;
338
+ }
339
+ }
340
+ /** Tear down every watcher and stop the intervals. Used by tests;
341
+ * production never calls this (process exit reclaims the fds). Resets
342
+ * `started` so a subsequent `startCollectionWatchers` re-mounts. */
343
+ async function stopCollectionWatchers() {
344
+ if (rediscoveryTimer) {
345
+ clearInterval(rediscoveryTimer);
346
+ rediscoveryTimer = null;
347
+ }
348
+ if (triggerTimer) {
349
+ clearInterval(triggerTimer);
350
+ triggerTimer = null;
351
+ }
352
+ for (const watcher of watchers.values()) try {
353
+ watcher.watcher.close();
354
+ } catch {}
355
+ watchers.clear();
356
+ itemSlots.clear();
357
+ discoveryOpts = {};
358
+ started = false;
359
+ }
360
+ /** Test-only: manually trigger one rediscovery + reconcile pass. */
361
+ async function _syncWatchersForTesting() {
362
+ await syncWatchers();
363
+ }
364
+ /** Test-only: drive one wall-clock tick synchronously, with an optional
365
+ * injected clock. */
366
+ async function _tickTimeTriggersForTesting(now) {
367
+ await tickTimeTriggers(now);
368
+ }
369
+ /** Re-reconcile every watched collection that depends on the clock — i.e.
370
+ * declares `triggerField` (a bell that fires at a date) and/or `spawn`
371
+ * (recurrence whose successors come due over time). Collections with
372
+ * neither are skipped. Idempotent. The schema is parsed back from the
373
+ * watcher's cached `schemaJson` to avoid a per-tick disk read. */
374
+ async function tickTimeTriggers(now = /* @__PURE__ */ new Date()) {
375
+ for (const entry of watchers.values()) {
376
+ let schema;
377
+ try {
378
+ schema = JSON.parse(entry.schemaJson);
379
+ } catch (err) {
380
+ log().warn("trigger tick: bad cached schema", {
381
+ slug: entry.slug,
382
+ error: errMsg(err)
383
+ });
384
+ continue;
385
+ }
386
+ if (!schema.triggerField && !schema.spawn) continue;
387
+ await reconcileAllItems(entry.slug, schema, entry.dataDir, discoveryOpts, now);
388
+ }
389
+ }
390
+ /** Reconcile the watcher set against the currently-discovered
391
+ * collections. Adds watchers for new slugs (with a boot reconcile of
392
+ * their items), drops watchers for vanished slugs, and re-reconciles
393
+ * items for collections whose schema changed. Runs a final sweep when
394
+ * this tick changed the watcher set or any schema. */
395
+ async function syncWatchers() {
396
+ let collections;
397
+ try {
398
+ collections = await require_server.discoverCollections(discoveryOpts);
399
+ } catch (err) {
400
+ log().warn("watcher discover failed", { error: errMsg(err) });
401
+ return;
402
+ }
403
+ const vanishedMutated = stopVanishedWatchers(new Set(collections.map((collection) => collection.slug)));
404
+ const schemaMutated = await reconcileChangedSchemas(collections);
405
+ const addedMutated = await startNewWatchers(collections);
406
+ if (vanishedMutated || schemaMutated || addedMutated) await sweepStaleActiveEntries(discoveryOpts);
407
+ }
408
+ function stopVanishedWatchers(liveSlugs) {
409
+ let mutated = false;
410
+ for (const slug of [...watchers.keys()]) {
411
+ if (liveSlugs.has(slug)) continue;
412
+ const watcher = watchers.get(slug);
413
+ if (watcher) try {
414
+ watcher.watcher.close();
415
+ } catch {}
416
+ watchers.delete(slug);
417
+ mutated = true;
418
+ log().info("watcher stopped", { slug });
419
+ }
420
+ return mutated;
421
+ }
422
+ /** Re-reconcile already-watched collections whose schema changed since
423
+ * the last tick. New collections fall through to `startNewWatchers`. */
424
+ async function reconcileChangedSchemas(collections) {
425
+ let mutated = false;
426
+ for (const collection of collections) {
427
+ const existing = watchers.get(collection.slug);
428
+ if (!existing) continue;
429
+ const nextJson = JSON.stringify(collection.schema);
430
+ if (existing.schemaJson === nextJson) continue;
431
+ existing.schemaJson = nextJson;
432
+ log().info("watcher schema changed, re-reconciling", { slug: collection.slug });
433
+ await reconcileAllItems(collection.slug, collection.schema, collection.dataDir, discoveryOpts);
434
+ mutated = true;
435
+ }
436
+ return mutated;
437
+ }
438
+ async function startNewWatchers(collections) {
439
+ let mutated = false;
440
+ for (const collection of collections) {
441
+ if (watchers.has(collection.slug)) continue;
442
+ await startWatcherFor(collection.slug, collection.schema, collection.dataDir);
443
+ mutated = true;
444
+ }
445
+ return mutated;
446
+ }
447
+ async function startWatcherFor(slug, schema, dataDir) {
448
+ try {
449
+ await (0, node_fs_promises.mkdir)(dataDir, { recursive: true });
450
+ await reconcileAllItems(slug, schema, dataDir, discoveryOpts);
451
+ const watcher = (0, node_fs.watch)(dataDir, { persistent: false }, (_eventType, filename) => {
452
+ onEvent(slug, filename).catch((err) => {
453
+ log().warn("watcher event failed", {
454
+ slug,
455
+ filename,
456
+ error: errMsg(err)
457
+ });
458
+ });
459
+ });
460
+ watcher.on("error", (err) => {
461
+ log().warn("watcher error", {
462
+ slug,
463
+ error: errMsg(err)
464
+ });
465
+ });
466
+ watchers.set(slug, {
467
+ slug,
468
+ dataDir,
469
+ watcher,
470
+ schemaJson: JSON.stringify(schema)
471
+ });
472
+ log().info("watcher started", {
473
+ slug,
474
+ dataDir
475
+ });
476
+ } catch (err) {
477
+ log().warn("watcher start failed", {
478
+ slug,
479
+ error: errMsg(err)
480
+ });
481
+ }
482
+ }
483
+ /** Test-only: the per-key single-flight scheduler. Exported so test code
484
+ * can drive rapid-fire calls directly and observe the trailing coalesce
485
+ * — `fs.watch` event timing is too flaky to assert against.
486
+ *
487
+ * Single-flight semantics: while a reconcile is in flight for a given
488
+ * (slug, itemId), additional events on the same key set `pending = true`
489
+ * and return — the running reconcile re-runs once after it completes.
490
+ * This collapses fs.watch's rapid-fire bursts (atomic rename surfaces as
491
+ * 2-3 events) into a single reconcile + one trailing re-run. */
492
+ function _scheduleItemReconcileForTesting(slug, schema, dataDir, itemId) {
493
+ return scheduleItemReconcile(slug, schema, dataDir, itemId);
494
+ }
495
+ function scheduleItemReconcile(slug, schema, dataDir, itemId) {
496
+ const key = `${slug}\x00${itemId}`;
497
+ const existing = itemSlots.get(key);
498
+ if (existing) {
499
+ existing.pending = true;
500
+ return existing.running;
501
+ }
502
+ const slot = {
503
+ running: Promise.resolve(),
504
+ pending: false
505
+ };
506
+ slot.running = (async () => {
507
+ try {
508
+ let keepGoing = true;
509
+ while (keepGoing) {
510
+ slot.pending = false;
511
+ await reconcileItem(slug, schema, dataDir, itemId, discoveryOpts);
512
+ keepGoing = slot.pending;
513
+ }
514
+ } finally {
515
+ itemSlots.delete(key);
516
+ }
517
+ })();
518
+ itemSlots.set(key, slot);
519
+ return slot.running;
520
+ }
521
+ /** Handle a single fs.watch event. Re-loads the collection (schema may
522
+ * have changed since startup), filters out non-record files, and
523
+ * forwards to the single-flighted reconciler. `filename === null` (rare,
524
+ * platform-specific) triggers a full directory rescan to be safe. */
525
+ async function onEvent(slug, filename) {
526
+ const collection = await require_server.loadCollection(slug, discoveryOpts);
527
+ if (!collection) return;
528
+ if (filename === null) {
529
+ await reconcileAllItems(slug, collection.schema, collection.dataDir, discoveryOpts);
530
+ await sweepStaleActiveEntries(discoveryOpts);
531
+ return;
532
+ }
533
+ const name = typeof filename === "string" ? filename : filename.toString("utf-8");
534
+ if (!name.endsWith(".json") || name.startsWith(".")) return;
535
+ const itemId = name.slice(0, -5);
536
+ await scheduleItemReconcile(slug, collection.schema, collection.dataDir, itemId);
537
+ }
538
+ //#endregion
539
+ exports._resetReconcilerLocksForTesting = _resetReconcilerLocksForTesting;
540
+ exports._scheduleItemReconcileForTesting = _scheduleItemReconcileForTesting;
541
+ exports._syncWatchersForTesting = _syncWatchersForTesting;
542
+ exports._tickTimeTriggersForTesting = _tickTimeTriggersForTesting;
543
+ exports.clearItemNotification = clearItemNotification;
544
+ exports.configureCollectionWatchers = configureCollectionWatchers;
545
+ exports.itemIsDone = itemIsDone;
546
+ exports.reconcileAllItems = reconcileAllItems;
547
+ exports.reconcileItem = reconcileItem;
548
+ exports.resetCollectionWatchersConfig = resetCollectionWatchersConfig;
549
+ exports.resolveDisplayLabel = resolveDisplayLabel;
550
+ exports.startCollectionWatchers = startCollectionWatchers;
551
+ exports.stopCollectionWatchers = stopCollectionWatchers;
552
+ exports.sweepStaleActiveEntries = sweepStaleActiveEntries;
553
+
554
+ //# sourceMappingURL=index.cjs.map