@mulmoclaude/core 0.28.0 → 0.30.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 (53) hide show
  1. package/assets/helps/collection-skills.md +18 -4
  2. package/dist/collection/core/backlinks.d.ts +28 -2
  3. package/dist/collection/core/ontologyGraph.d.ts +6 -2
  4. package/dist/collection/index.cjs +2 -1
  5. package/dist/collection/index.cjs.map +1 -1
  6. package/dist/collection/index.js +2 -2
  7. package/dist/collection/index.js.map +1 -1
  8. package/dist/collection/registry/server/index.cjs +2 -2
  9. package/dist/collection/registry/server/index.js +2 -2
  10. package/dist/collection/server/backendAvailability.d.ts +5 -0
  11. package/dist/collection/server/index.cjs +4 -2
  12. package/dist/collection/server/index.d.ts +1 -0
  13. package/dist/collection/server/index.js +3 -3
  14. package/dist/collection/server/manageTool.d.ts +4 -0
  15. package/dist/collection/server/schemaDocs.d.ts +21 -0
  16. package/dist/collection/server/store.d.ts +37 -0
  17. package/dist/collection/server/watchFs.d.ts +17 -0
  18. package/dist/collection-watchers/index.cjs +114 -203
  19. package/dist/collection-watchers/index.cjs.map +1 -1
  20. package/dist/collection-watchers/index.d.ts +1 -1
  21. package/dist/collection-watchers/index.js +114 -202
  22. package/dist/collection-watchers/index.js.map +1 -1
  23. package/dist/collection-watchers/watcher.d.ts +8 -13
  24. package/dist/{discovery-BVdCgBFN.js → discovery-BbsJwVEq.js} +109 -8
  25. package/dist/discovery-BbsJwVEq.js.map +1 -0
  26. package/dist/{discovery-BKovdRpZ.cjs → discovery-Bklck7Ck.cjs} +119 -6
  27. package/dist/discovery-Bklck7Ck.cjs.map +1 -0
  28. package/dist/feeds/server/index.cjs +2 -2
  29. package/dist/feeds/server/index.js +2 -2
  30. package/dist/google/index.cjs +1 -1
  31. package/dist/google/index.js +1 -1
  32. package/dist/{promptSafety-cZIeiZtB.js → promptSafety-Bugq2kqL.js} +40 -5
  33. package/dist/promptSafety-Bugq2kqL.js.map +1 -0
  34. package/dist/{promptSafety-BFt2g_wn.cjs → promptSafety-DbE6eZmP.cjs} +45 -4
  35. package/dist/promptSafety-DbE6eZmP.cjs.map +1 -0
  36. package/dist/remote-view/index.cjs +8 -0
  37. package/dist/remote-view/index.cjs.map +1 -1
  38. package/dist/remote-view/index.d.ts +7 -0
  39. package/dist/remote-view/index.js +8 -1
  40. package/dist/remote-view/index.js.map +1 -1
  41. package/dist/{server-B8mvfZSF.js → server-8EZggEg7.js} +180 -15
  42. package/dist/server-8EZggEg7.js.map +1 -0
  43. package/dist/{server-rRQkNBvO.cjs → server-BOiz_HDi.cjs} +180 -15
  44. package/dist/server-BOiz_HDi.cjs.map +1 -0
  45. package/dist/whisper/client.cjs.map +1 -1
  46. package/dist/whisper/client.js.map +1 -1
  47. package/package.json +1 -1
  48. package/dist/discovery-BKovdRpZ.cjs.map +0 -1
  49. package/dist/discovery-BVdCgBFN.js.map +0 -1
  50. package/dist/promptSafety-BFt2g_wn.cjs.map +0 -1
  51. package/dist/promptSafety-cZIeiZtB.js.map +0 -1
  52. package/dist/server-B8mvfZSF.js.map +0 -1
  53. package/dist/server-rRQkNBvO.cjs.map +0 -1
@@ -1,8 +1,9 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
2
  const require_ids = require("../../ids-DWmHjm17.cjs");
3
- const require_discovery = require("../../discovery-BKovdRpZ.cjs");
3
+ const require_discovery = require("../../discovery-Bklck7Ck.cjs");
4
4
  const require_templatePath = require("../../templatePath-27vUZowm.cjs");
5
- const require_server = require("../../server-rRQkNBvO.cjs");
5
+ const require_server = require("../../server-BOiz_HDi.cjs");
6
+ exports.BackendUnavailableError = require_discovery.BackendUnavailableError;
6
7
  exports.COMPUTED_TYPES = require_ids.COMPUTED_TYPES;
7
8
  exports.CollectionQueryZ = require_discovery.CollectionQueryZ;
8
9
  exports.CollectionSchemaZ = require_discovery.CollectionSchemaZ;
@@ -40,6 +41,7 @@ exports.firstMutateParamProblem = require_server.firstMutateParamProblem;
40
41
  exports.formatCivil = require_server.formatCivil;
41
42
  exports.generateItemId = require_discovery.generateItemId;
42
43
  exports.getWorkspaceRoot = require_discovery.getWorkspaceRoot;
44
+ exports.isBackendUnavailable = require_discovery.isBackendUnavailable;
43
45
  exports.isContainedInRoot = require_discovery.isContainedInRoot;
44
46
  exports.isContainedInWorkspace = require_discovery.isContainedInWorkspace;
45
47
  exports.isRegularFile = require_discovery.isRegularFile;
@@ -4,6 +4,7 @@ export * from './paths';
4
4
  export * from './templatePath';
5
5
  export * from './io';
6
6
  export * from './store';
7
+ export { BackendUnavailableError, isBackendUnavailable } from './backendAvailability';
7
8
  export { MAX_CSV_ROWS, encodeCsvRecordId, decodeCsvRecordId, normalizeCsvValue, csvRowToItem, dedupeByRecordId } from './csvStore';
8
9
  export { compileCsvQuery, compileJsonlQuery } from './csvQuery';
9
10
  export { runQueryOverRows } from './jsonlQuery';
@@ -1,5 +1,5 @@
1
1
  import { o as COMPUTED_TYPES } from "../../ids-D1M1T6KJ.js";
2
- import { A as generateItemId, C as compileJsonlQuery, D as buildActionSeedPrompt, E as MAX_QUERY_ROWS, F as readCustomViewI18n, G as resolveDataDir, H as isContainedInRoot, I as readItem, J as safeSlugName, K as resolveTemplatePath, L as readSkillTemplate, M as listItems, N as promptPathsFor, O as buildCollectionActionSeedPrompt, P as readCustomViewHtml, Q as getWorkspaceRoot, R as resolveCreateItemId, S as compileCsvQuery, T as DEFAULT_QUERY_ROWS, U as isContainedInWorkspace, V as SCHEMA_FILE, W as itemFilePath, Z as configureCollectionHost, _ as decodeCsvRecordId, a as toSummary, b as normalizeCsvValue, c as collectionWritable, et as log, f as pageFromFullRead, g as csvRowToItem, i as toDetail, j as isRegularFile, k as deleteItem, l as readOnlyRefusal, m as MAX_CSV_ROWS, n as discoverCollections, nt as setCollectionChangePublisher, o as CollectionSchemaZ, p as projectItemFields, q as safeRecordId, r as loadCollection, t as acceptParsedSchema, tt as publishCollectionChange, u as storeFor, v as dedupeByRecordId, w as CollectionQueryZ, y as encodeCsvRecordId, z as writeItem } from "../../discovery-BVdCgBFN.js";
2
+ import { $ as configureCollectionHost, A as buildCollectionActionSeedPrompt, B as resolveCreateItemId, C as compileJsonlQuery, D as BackendUnavailableError, E as MAX_QUERY_ROWS, F as promptPathsFor, G as isContainedInWorkspace, I as readCustomViewHtml, J as resolveTemplatePath, K as itemFilePath, L as readCustomViewI18n, M as generateItemId, N as isRegularFile, O as isBackendUnavailable, P as listItems, R as readItem, S as compileCsvQuery, T as DEFAULT_QUERY_ROWS, U as SCHEMA_FILE, V as writeItem, W as isContainedInRoot, X as safeSlugName, Y as safeRecordId, _ as decodeCsvRecordId, a as toSummary, b as normalizeCsvValue, c as collectionWritable, et as getWorkspaceRoot, f as pageFromFullRead, g as csvRowToItem, i as toDetail, it as setCollectionChangePublisher, j as deleteItem, k as buildActionSeedPrompt, l as readOnlyRefusal, m as MAX_CSV_ROWS, n as discoverCollections, nt as log, o as CollectionSchemaZ, p as projectItemFields, q as resolveDataDir, r as loadCollection, rt as publishCollectionChange, t as acceptParsedSchema, u as storeFor, v as dedupeByRecordId, w as CollectionQueryZ, y as encodeCsvRecordId, z as readSkillTemplate } from "../../discovery-BbsJwVEq.js";
3
3
  import { i as isSafeTemplatePath, n as isSafeCustomViewI18nPath, r as isSafeCustomViewPath, t as isSafeActionTemplatePath } from "../../templatePath-k_WNbL_Q.js";
4
- import { C as validateRecordObject, D as enrichItems, E as runCollectionQuery, O as runQueryOverRows, S as validateCollectionRecords, T as recordFieldProblem, _ as computeCollectionIcon, a as deleteCollection, b as applyMutateAction, c as computeSuccessor, d as isTriggerDue, f as maybeSpawnSuccessor, h as successorId, i as deleteCustomView, l as daysInMonth, m as resolveEvery, n as MAX_UNSELECTIVE_ITEMS, o as deleteCollectionRefusalMessage, p as parseCivil, r as makeManageCollectionTool, s as advanceTriggerDate, t as MAX_SCHEMA_ISSUES, u as formatCivil, v as buildWorkspaceOntology, w as compileRecordZ, x as firstMutateParamProblem, y as schemaRelations } from "../../server-B8mvfZSF.js";
5
- export { COMPUTED_TYPES, CollectionQueryZ, CollectionSchemaZ, DEFAULT_QUERY_ROWS, MAX_CSV_ROWS, MAX_QUERY_ROWS, MAX_SCHEMA_ISSUES, MAX_UNSELECTIVE_ITEMS, SCHEMA_FILE, acceptParsedSchema, advanceTriggerDate, applyMutateAction, buildActionSeedPrompt, buildCollectionActionSeedPrompt, buildWorkspaceOntology, collectionWritable, compileCsvQuery, compileJsonlQuery, compileRecordZ, computeCollectionIcon, computeSuccessor, configureCollectionHost, csvRowToItem, daysInMonth, decodeCsvRecordId, dedupeByRecordId, deleteCollection, deleteCollectionRefusalMessage, deleteCustomView, deleteItem, discoverCollections, encodeCsvRecordId, enrichItems, firstMutateParamProblem, formatCivil, generateItemId, getWorkspaceRoot, isContainedInRoot, isContainedInWorkspace, isRegularFile, isSafeActionTemplatePath, isSafeCustomViewI18nPath, isSafeCustomViewPath, isSafeTemplatePath, isTriggerDue, itemFilePath, listItems, loadCollection, log, makeManageCollectionTool, maybeSpawnSuccessor, normalizeCsvValue, pageFromFullRead, parseCivil, projectItemFields, promptPathsFor, publishCollectionChange, readCustomViewHtml, readCustomViewI18n, readItem, readOnlyRefusal, readSkillTemplate, recordFieldProblem, resolveCreateItemId, resolveDataDir, resolveEvery, resolveTemplatePath, runCollectionQuery, runQueryOverRows, safeRecordId, safeSlugName, schemaRelations, setCollectionChangePublisher, storeFor, successorId, toDetail, toSummary, validateCollectionRecords, validateRecordObject, writeItem };
4
+ import { C as validateRecordObject, D as enrichItems, E as runCollectionQuery, O as runQueryOverRows, S as validateCollectionRecords, T as recordFieldProblem, _ as computeCollectionIcon, a as deleteCollection, b as applyMutateAction, c as computeSuccessor, d as isTriggerDue, f as maybeSpawnSuccessor, h as successorId, i as deleteCustomView, l as daysInMonth, m as resolveEvery, n as MAX_UNSELECTIVE_ITEMS, o as deleteCollectionRefusalMessage, p as parseCivil, r as makeManageCollectionTool, s as advanceTriggerDate, t as MAX_SCHEMA_ISSUES, u as formatCivil, v as buildWorkspaceOntology, w as compileRecordZ, x as firstMutateParamProblem, y as schemaRelations } from "../../server-8EZggEg7.js";
5
+ export { BackendUnavailableError, COMPUTED_TYPES, CollectionQueryZ, CollectionSchemaZ, DEFAULT_QUERY_ROWS, MAX_CSV_ROWS, MAX_QUERY_ROWS, MAX_SCHEMA_ISSUES, MAX_UNSELECTIVE_ITEMS, SCHEMA_FILE, acceptParsedSchema, advanceTriggerDate, applyMutateAction, buildActionSeedPrompt, buildCollectionActionSeedPrompt, buildWorkspaceOntology, collectionWritable, compileCsvQuery, compileJsonlQuery, compileRecordZ, computeCollectionIcon, computeSuccessor, configureCollectionHost, csvRowToItem, daysInMonth, decodeCsvRecordId, dedupeByRecordId, deleteCollection, deleteCollectionRefusalMessage, deleteCustomView, deleteItem, discoverCollections, encodeCsvRecordId, enrichItems, firstMutateParamProblem, formatCivil, generateItemId, getWorkspaceRoot, isBackendUnavailable, isContainedInRoot, isContainedInWorkspace, isRegularFile, isSafeActionTemplatePath, isSafeCustomViewI18nPath, isSafeCustomViewPath, isSafeTemplatePath, isTriggerDue, itemFilePath, listItems, loadCollection, log, makeManageCollectionTool, maybeSpawnSuccessor, normalizeCsvValue, pageFromFullRead, parseCivil, projectItemFields, promptPathsFor, publishCollectionChange, readCustomViewHtml, readCustomViewI18n, readItem, readOnlyRefusal, readSkillTemplate, recordFieldProblem, resolveCreateItemId, resolveDataDir, resolveEvery, resolveTemplatePath, runCollectionQuery, runQueryOverRows, safeRecordId, safeSlugName, schemaRelations, setCollectionChangePublisher, storeFor, successorId, toDetail, toSummary, validateCollectionRecords, validateRecordObject, writeItem };
@@ -72,6 +72,10 @@ export declare function makeManageCollectionTool(deps?: ManageCollectionDeps): {
72
72
  type: string;
73
73
  description: string;
74
74
  };
75
+ topic: {
76
+ type: string;
77
+ description: string;
78
+ };
75
79
  query: {
76
80
  type: string;
77
81
  description: string;
@@ -0,0 +1,21 @@
1
+ /** A doc at/below this size is returned whole — sectioning something the
2
+ * agent can read in one gulp only costs round-trips. This is also what
3
+ * keeps short user-authored workspace copies (config/helps) verbatim. */
4
+ export declare const SCHEMA_DOCS_VERBATIM_LIMIT = 20000;
5
+ /** Ceiling for ANY assembled reply — topic or default — safely inside
6
+ * the agent's per-result limit (the full doc is what overflowed it).
7
+ * A matched section too large to fit degrades to its own prose plus a
8
+ * list of its subsections to fetch individually; an oversized leaf is
9
+ * clipped outright; the default drops whole core sections into a
10
+ * pointer note. Sized so the CURRENT bundled doc's default reply
11
+ * (~33KB) fits without degrading. */
12
+ export declare const SCHEMA_DOCS_RESULT_BUDGET = 36000;
13
+ /** Render the authoring reference for one schemaDocs call. Small docs and
14
+ * docs without headings pass through verbatim — there is nothing better
15
+ * to key a section off. The assembled reply gets one final hard cap: the
16
+ * inner budgeting degrades gracefully, but per-piece overheads (clip
17
+ * markers on thousands of matches, join separators, pointer notes) add
18
+ * up outside any single piece's budget, and the ceiling must hold no
19
+ * matter what shape of document arrives. Only the explicit \`"all"\`
20
+ * opt-in and the small-doc verbatim path may exceed it. */
21
+ export declare function renderSchemaDocs(doc: string, topic?: string): string;
@@ -30,6 +30,22 @@ export interface CollectionStoreCapabilities {
30
30
  * (io.ts is the reference).
31
31
  * 4. HONEST AGGREGATION — `query`, when present, is computed over the
32
32
  * WHOLE data set, never from a capped read. */
33
+ /** What a store reports when its records changed underneath it.
34
+ *
35
+ * Two granularities, because backends genuinely differ: a per-record file
36
+ * store knows WHICH record changed, while a single-artifact backend (one
37
+ * CSV, one db file, one remote snapshot) only knows THAT something did.
38
+ * Naming the difference here is what lets the watcher react uniformly
39
+ * instead of branching on the backend. */
40
+ export type StoreChange = {
41
+ readonly kind: "item";
42
+ readonly itemId: string;
43
+ } | {
44
+ readonly kind: "collection";
45
+ };
46
+ export type StoreChangeListener = (change: StoreChange) => void;
47
+ /** Detaches a `watch` subscription. */
48
+ export type StoreUnsubscribe = () => void;
33
49
  export interface CollectionStore {
34
50
  readonly capabilities: CollectionStoreCapabilities;
35
51
  /** Every record, in the store's stable order. CSV store: capped at
@@ -50,6 +66,27 @@ export interface CollectionStore {
50
66
  * collection's slug into the publish hook, so no writer can forget it. */
51
67
  write?: (itemId: string, item: CollectionItem, opts?: WriteOptions) => Promise<WriteItemResult>;
52
68
  delete?: (itemId: string) => Promise<DeleteItemResult>;
69
+ /** Subscribe to changes made OUTSIDE this process — a file edited by the
70
+ * agent, a CSV replaced by the user, a db written by another tool.
71
+ *
72
+ * Resolves to an unsubscribe function, or to `null` when the backend
73
+ * could not arm the watch (no inotify watches left, an unreadable
74
+ * directory). `null` is NOT the same as absent `watch`: absent means the
75
+ * backend never reports changes and the caller should settle for its
76
+ * periodic pass, while `null` means this attempt failed and mounting
77
+ * should be retried. Swallowing the difference strands a collection on
78
+ * stale data until the process restarts.
79
+ *
80
+ * This is the store's job because only it knows where its bytes live and
81
+ * what its own change events look like (which paths to watch, which
82
+ * filenames are noise, whether an atomic replace surfaces as two events).
83
+ * Absorbing that noise — debouncing a replace into one report — belongs
84
+ * here too. What to DO about a change (reconcile bells, sweep, publish)
85
+ * is the watcher's policy and is deliberately NOT expressed here.
86
+ *
87
+ * Absent ⇒ the backend cannot report external changes; the caller falls
88
+ * back to its periodic pass. */
89
+ watch?: (onChange: StoreChangeListener) => Promise<StoreUnsubscribe | null>;
53
90
  }
54
91
  /** True when the collection accepts UI/tool writes. A `dataSource`
55
92
  * collection is read-only: updates happen by editing/replacing the
@@ -0,0 +1,17 @@
1
+ export interface FsWatchHandle {
2
+ close: () => void;
3
+ }
4
+ /** Watch `dir`, reporting each accepted filename. `accept` decides what is
5
+ * noise; a null filename always passes (the platform didn't tell us which
6
+ * file, so the caller must assume the worst). */
7
+ export declare function watchDirectory(dir: string, accept: (filename: string) => boolean, onHit: (filename: string | null) => void): Promise<FsWatchHandle | null>;
8
+ /** Watch the single file `absPath` by watching its PARENT directory, so an
9
+ * atomic replace can't strand the watch on a dead inode. `alsoAccept`
10
+ * widens the filter beyond the exact basename (sqlite's `-wal`/`-journal`
11
+ * sidecars). Reports are debounced: one replace, one call. */
12
+ export declare function watchSingleFile(absPath: string, alsoAccept: (basename: string, filename: string) => boolean, onChange: () => void): Promise<FsWatchHandle | null>;
13
+ /** An `FsWatchHandle` as a bare unsubscribe — `null` straight through, so an
14
+ * unarmed watch stays distinguishable from an armed one. Lives here rather
15
+ * than beside the store contract so both `store.ts` and the backends it
16
+ * registers can reach it without importing each other. */
17
+ export declare function closerFor(handle: FsWatchHandle | null): (() => void) | null;
@@ -1,14 +1,10 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_rolldown_runtime = require("../rolldown-runtime-D6vf50IK.cjs");
3
2
  const require_ids = require("../ids-DWmHjm17.cjs");
4
- const require_promptSafety = require("../promptSafety-BFt2g_wn.cjs");
3
+ const require_promptSafety = require("../promptSafety-DbE6eZmP.cjs");
5
4
  require("../collection/index.cjs");
6
- const require_discovery = require("../discovery-BKovdRpZ.cjs");
7
- const require_server = require("../server-rRQkNBvO.cjs");
5
+ const require_discovery = require("../discovery-Bklck7Ck.cjs");
6
+ const require_server = require("../server-BOiz_HDi.cjs");
8
7
  const require_notifier = require("../notifier-bS8IEeLA.cjs");
9
- let node_path = require("node:path");
10
- node_path = require_rolldown_runtime.__toESM(node_path, 1);
11
- let node_fs = require("node:fs");
12
8
  let node_fs_promises = require("node:fs/promises");
13
9
  //#region src/collection-watchers/config.ts
14
10
  var NOOP_LOG = {
@@ -302,6 +298,15 @@ var watchers = /* @__PURE__ */ new Map();
302
298
  var rediscoveryTimer = null;
303
299
  var triggerTimer = null;
304
300
  var started = false;
301
+ /** Guards the clock tick against overlapping itself. Paired with
302
+ * `watcherEpoch`: a teardown while a pass is in flight bumps the epoch, so
303
+ * that pass's `finally` knows it belongs to a dead generation and must not
304
+ * clear a guard the restarted watcher set now owns. `triggerTickInFlight`
305
+ * is what teardown AWAITS — clearing the flag alone would let a restart run
306
+ * a second pass alongside the first. */
307
+ var triggerTickRunning = false;
308
+ var watcherEpoch = 0;
309
+ var triggerTickInFlight = null;
305
310
  /** Discovery options threaded into every `discoverCollections` /
306
311
  * `loadCollection` / `sweepStaleActiveEntries` call. Production: empty
307
312
  * (live workspace). Tests: `{ workspaceRoot, userSkillsDir }` pointing
@@ -309,15 +314,10 @@ var started = false;
309
314
  * without threading through every signature. */
310
315
  var discoveryOpts = {};
311
316
  var itemSlots = /* @__PURE__ */ new Map();
312
- /** Per-slug single-flight for a storage (db-file) collection's full
313
- * reconcile pass a burst of db writes collapses to one pass + one
314
- * trailing re-run, mirroring the per-item slots of the file watcher. */
315
- var storageSlots = /* @__PURE__ */ new Map();
316
- /** Trailing debounce per dataSource collection: an atomic file replace
317
- * (Excel save, editor rename) surfaces as 2-3 fs events — collapse them
318
- * into one change publish so live views refetch once. */
319
- var DATA_SOURCE_DEBOUNCE_MS = 300;
320
- var dataSourceTimers = /* @__PURE__ */ new Map();
317
+ /** Per-slug single-flight for a COLLECTION-granularity reconcile — a burst
318
+ * of changes the store couldn't attribute to a record collapses into one
319
+ * pass plus one trailing re-run, mirroring the per-item slots. */
320
+ var collectionSlots = /* @__PURE__ */ new Map();
321
321
  /** Boot entry point: sweep stale active entries, then mount watchers for
322
322
  * every discovered collection and arm the periodic re-discovery poll.
323
323
  * Idempotent — a second call is a no-op. */
@@ -339,8 +339,15 @@ async function startCollectionWatchers(opts = {}) {
339
339
  const triggerMs = opts.triggerTickIntervalMs === void 0 ? TRIGGER_TICK_INTERVAL_MS : opts.triggerTickIntervalMs;
340
340
  if (triggerMs !== null) {
341
341
  triggerTimer = setInterval(() => {
342
- tickTimeTriggers().catch((err) => {
342
+ if (triggerTickRunning) return;
343
+ triggerTickRunning = true;
344
+ const epoch = watcherEpoch;
345
+ triggerTickInFlight = tickTimeTriggers().catch((err) => {
343
346
  log().warn("watcher trigger tick failed", { error: errMsg(err) });
347
+ }).finally(() => {
348
+ if (epoch !== watcherEpoch) return;
349
+ triggerTickRunning = false;
350
+ triggerTickInFlight = null;
344
351
  });
345
352
  }, triggerMs);
346
353
  triggerTimer.unref();
@@ -363,20 +370,22 @@ async function stopCollectionWatchers() {
363
370
  clearInterval(triggerTimer);
364
371
  triggerTimer = null;
365
372
  }
373
+ await triggerTickInFlight;
374
+ triggerTickInFlight = null;
375
+ watcherEpoch += 1;
376
+ triggerTickRunning = false;
366
377
  for (const watcher of watchers.values()) try {
367
- watcher.watcher.close();
378
+ watcher.unsubscribe();
368
379
  } catch {}
369
380
  watchers.clear();
370
381
  itemSlots.clear();
371
- storageSlots.clear();
372
- for (const timer of dataSourceTimers.values()) clearTimeout(timer);
373
- dataSourceTimers.clear();
382
+ collectionSlots.clear();
374
383
  discoveryOpts = {};
375
384
  started = false;
376
385
  }
377
386
  /** Test-only: manually trigger one rediscovery + reconcile pass. */
378
387
  async function _syncWatchersForTesting() {
379
- await syncWatchers();
388
+ return syncWatchers();
380
389
  }
381
390
  /** Test-only: drive one wall-clock tick synchronously, with an optional
382
391
  * injected clock. */
@@ -400,7 +409,6 @@ async function tickTimeTriggers(now = evalNow()) {
400
409
  });
401
410
  continue;
402
411
  }
403
- if (schema.dataSource !== void 0) continue;
404
412
  if (!schema.triggerField && !schema.spawn) continue;
405
413
  await reconcileAllItems(entry.collection, discoveryOpts, now);
406
414
  }
@@ -416,12 +424,14 @@ async function syncWatchers() {
416
424
  collections = await require_discovery.discoverCollections(discoveryOpts);
417
425
  } catch (err) {
418
426
  log().warn("watcher discover failed", { error: errMsg(err) });
419
- return;
427
+ return false;
420
428
  }
421
429
  const vanishedMutated = stopVanishedWatchers(new Set(collections.map((collection) => collection.slug)));
422
430
  const schemaMutated = await reconcileChangedSchemas(collections);
423
431
  const addedMutated = await startNewWatchers(collections);
424
- if (vanishedMutated || schemaMutated || addedMutated) await sweepStaleActiveEntries(discoveryOpts);
432
+ if (!vanishedMutated && !schemaMutated && !addedMutated) return false;
433
+ await sweepStaleActiveEntries(discoveryOpts);
434
+ return true;
425
435
  }
426
436
  function stopVanishedWatchers(liveSlugs) {
427
437
  let mutated = false;
@@ -429,7 +439,7 @@ function stopVanishedWatchers(liveSlugs) {
429
439
  if (liveSlugs.has(slug)) continue;
430
440
  const watcher = watchers.get(slug);
431
441
  if (watcher) try {
432
- watcher.watcher.close();
442
+ watcher.unsubscribe();
433
443
  } catch {}
434
444
  watchers.delete(slug);
435
445
  mutated = true;
@@ -462,7 +472,7 @@ async function reconcileChangedSchemas(collections) {
462
472
  if (storagePathChanged(existing.schemaJson, collection.schema)) {
463
473
  log().info("watcher storage path changed, remounting", { slug: collection.slug });
464
474
  try {
465
- existing.watcher.close();
475
+ existing.unsubscribe();
466
476
  } catch {}
467
477
  watchers.delete(collection.slug);
468
478
  if (collection.schema.dataSource !== void 0) require_discovery.publishCollectionChange({
@@ -474,199 +484,119 @@ async function reconcileChangedSchemas(collections) {
474
484
  }
475
485
  existing.schemaJson = nextJson;
476
486
  existing.collection = collection;
477
- if (collection.schema.dataSource !== void 0) {
478
- log().info("dataSource watcher schema changed, publishing", { slug: collection.slug });
479
- require_discovery.publishCollectionChange({
480
- slug: collection.slug,
481
- op: "upsert"
482
- });
483
- mutated = true;
484
- continue;
485
- }
486
487
  log().info("watcher schema changed, re-reconciling", { slug: collection.slug });
487
488
  await reconcileAllItems(collection, discoveryOpts);
489
+ if (collection.schema.dataSource !== void 0) require_discovery.publishCollectionChange({
490
+ slug: collection.slug,
491
+ op: "upsert"
492
+ });
488
493
  mutated = true;
489
494
  }
490
495
  return mutated;
491
496
  }
497
+ /** Mount a watcher for every collection that doesn't have one yet. Returns
498
+ * whether the watcher SET actually changed — the starters report whether
499
+ * they mounted, because ATTEMPTING is not mounting. A start that throws
500
+ * (logged and swallowed inside the starter) leaves `watchers` untouched, so
501
+ * the collection is retried next tick; counting that as a mutation made
502
+ * `syncWatchers` sweep on every tick for as long as the failure persisted. */
492
503
  async function startNewWatchers(collections) {
493
504
  let mutated = false;
494
505
  for (const collection of collections) {
495
506
  if (watchers.has(collection.slug)) continue;
496
- if (collection.schema.dataSource !== void 0) await startDataSourceWatcher(collection);
497
- else if (collection.schema.storage !== void 0) await startStorageWatcher(collection);
498
- else await startWatcherFor(collection);
499
- mutated = true;
507
+ if (await startWatcherFor(collection)) mutated = true;
500
508
  }
501
509
  return mutated;
502
510
  }
503
- /** Publish one (debounced) change event for a dataSource collection —
504
- * live views refetch the whole collection; no per-row diffing. */
505
- function scheduleDataSourcePublish(slug) {
506
- const existing = dataSourceTimers.get(slug);
507
- if (existing) clearTimeout(existing);
508
- const timer = setTimeout(() => {
509
- dataSourceTimers.delete(slug);
510
- require_discovery.publishCollectionChange({
511
- slug,
512
- op: "upsert"
513
- });
514
- }, DATA_SOURCE_DEBOUNCE_MS);
515
- timer.unref?.();
516
- dataSourceTimers.set(slug, timer);
517
- }
518
- /** Watch a dataSource collection's external data file. The watch mounts
519
- * on the PARENT directory (watching the file itself goes stale after an
520
- * atomic replace — the inode changes) and filters events to the file's
521
- * basename; a null filename (platform quirk) is treated as a hit. No
522
- * reconciler involvement — replacing the CSV just refreshes the views. */
523
- async function startDataSourceWatcher(collection) {
524
- const file = collection.dataSourceFile;
525
- if (file === void 0) return;
526
- const dir = node_path.default.dirname(file);
527
- const base = node_path.default.basename(file);
528
- try {
529
- await (0, node_fs_promises.mkdir)(dir, { recursive: true });
530
- const watcher = (0, node_fs.watch)(dir, { persistent: false }, (_eventType, filename) => {
531
- if (filename !== null && filename !== base) return;
532
- scheduleDataSourcePublish(collection.slug);
533
- });
534
- watcher.on("error", (err) => {
535
- log().warn("dataSource watcher error", {
536
- slug: collection.slug,
537
- error: errMsg(err)
538
- });
539
- });
540
- watchers.set(collection.slug, {
541
- slug: collection.slug,
542
- dataDir: dir,
543
- watcher,
544
- schemaJson: JSON.stringify(collection.schema),
545
- collection
546
- });
547
- log().info("dataSource watcher started", {
548
- slug: collection.slug,
549
- file
550
- });
551
- } catch (err) {
552
- log().warn("dataSource watcher start failed", {
553
- slug: collection.slug,
554
- error: errMsg(err)
555
- });
556
- }
557
- }
558
- /** Watch a `storage` collection's database file. One db file holds every
559
- * record, so an event can't name WHICH record changed — each (debounced)
560
- * event runs a full `reconcileAllItems` pass (bells / spawn) plus a
561
- * change publish so views refetch after EXTERNAL edits too (host writes
562
- * already publish their own change events; the extra ping is debounced
563
- * and idempotent). Mounts on the parent dir, same as the dataSource
564
- * watcher, so an atomic replace can't strand the watch. */
565
- async function startStorageWatcher(collection) {
566
- const file = collection.storageFile;
567
- if (file === void 0) return;
568
- const dir = node_path.default.dirname(file);
569
- const base = node_path.default.basename(file);
511
+ /** Mount one collection's change subscription, whatever its backend.
512
+ *
513
+ * The store decides how to detect a change and at what granularity; this
514
+ * decides what to do about one. That split is the point: adding a backend
515
+ * means implementing `watch` on its store, not another branch here.
516
+ *
517
+ * A store without `watch` cannot report external changes at all — it is
518
+ * still registered (so bells reconcile at boot and on the clock tick), just
519
+ * without live updates. */
520
+ async function startWatcherFor(collection) {
521
+ const { slug } = collection;
570
522
  try {
571
- await (0, node_fs_promises.mkdir)(dir, { recursive: true });
572
523
  await reconcileAllItems(collection, discoveryOpts);
573
- const watcher = (0, node_fs.watch)(dir, { persistent: false }, (_eventType, rawFilename) => {
574
- const filename = rawFilename === null ? null : String(rawFilename);
575
- if (filename !== null && !filename.startsWith(base)) return;
576
- scheduleStorageReconcile(collection.slug).catch((err) => {
577
- log().warn("storage watcher reconcile failed", {
578
- slug: collection.slug,
524
+ const store = require_discovery.storeFor(collection, discoveryOpts);
525
+ const unsubscribe = store.watch ? await store.watch((change) => {
526
+ handleStoreChange(slug, change).catch((err) => {
527
+ log().warn("store change handling failed", {
528
+ slug,
579
529
  error: errMsg(err)
580
530
  });
581
531
  });
582
- });
583
- watcher.on("error", (err) => {
584
- log().warn("storage watcher error", {
585
- slug: collection.slug,
586
- error: errMsg(err)
587
- });
588
- });
589
- watchers.set(collection.slug, {
590
- slug: collection.slug,
591
- dataDir: dir,
592
- watcher,
532
+ }) : () => {};
533
+ if (unsubscribe === null) {
534
+ log().warn("collection watcher could not arm, retrying next sync", { slug });
535
+ return false;
536
+ }
537
+ watchers.set(slug, {
538
+ slug,
539
+ dataDir: collection.dataDir,
540
+ unsubscribe,
593
541
  schemaJson: JSON.stringify(collection.schema),
594
542
  collection
595
543
  });
596
- log().info("storage watcher started", {
597
- slug: collection.slug,
598
- file
544
+ log().info("collection watcher started", {
545
+ slug,
546
+ live: store.watch !== void 0
599
547
  });
548
+ return true;
600
549
  } catch (err) {
601
- log().warn("storage watcher start failed", {
602
- slug: collection.slug,
550
+ log().warn("collection watcher start failed", {
551
+ slug,
603
552
  error: errMsg(err)
604
553
  });
554
+ return false;
605
555
  }
606
556
  }
607
- function scheduleStorageReconcile(slug) {
608
- return runSingleFlight(storageSlots, slug, async () => {
557
+ /** React to one reported change. Backend-agnostic by construction — it sees
558
+ * only the granularity the store reported.
559
+ *
560
+ * `item`: reconcile just that record, then publish it. `collection`: the
561
+ * store couldn't say which record, so re-derive everything and pair it with
562
+ * a sweep — a record deleted remotely leaves a bell that a walk over the
563
+ * SURVIVING records can never clear. */
564
+ async function handleStoreChange(slug, change) {
565
+ if (change.kind === "collection") {
566
+ await scheduleCollectionReconcile(slug);
567
+ return;
568
+ }
569
+ const current = watchers.get(slug)?.collection;
570
+ if (!current) return;
571
+ await scheduleItemReconcile(current, change.itemId);
572
+ }
573
+ /** Full re-derivation for a collection-granularity change, single-flighted
574
+ * per slug so a burst of writes collapses into one pass plus one trailing
575
+ * re-run. */
576
+ function scheduleCollectionReconcile(slug) {
577
+ return runSingleFlight(collectionSlots, slug, async () => {
609
578
  const collection = await require_discovery.loadCollection(slug, discoveryOpts);
610
579
  if (!collection) return;
611
580
  await reconcileAllItems(collection, discoveryOpts);
612
581
  await sweepStaleActiveEntries(discoveryOpts);
613
- require_discovery.publishCollectionChange({
582
+ }, () => {
583
+ safePublish({
614
584
  slug,
615
585
  op: "upsert"
616
586
  });
587
+ return Promise.resolve();
617
588
  });
618
589
  }
619
- /** Test-only: drive one storage-collection reconcile pass directly. */
620
- function _scheduleStorageReconcileForTesting(slug) {
621
- return scheduleStorageReconcile(slug);
590
+ /** Test-only: feed one store-reported change through the same path a live
591
+ * subscription uses, so a test can pin how a change is reacted to without
592
+ * depending on fs.watch timing. */
593
+ function _handleStoreChangeForTesting(slug, change) {
594
+ return handleStoreChange(slug, change);
622
595
  }
623
- async function startWatcherFor(collection) {
624
- const { slug, schema, dataDir } = collection;
625
- try {
626
- await (0, node_fs_promises.mkdir)(dataDir, { recursive: true });
627
- await reconcileAllItems(collection, discoveryOpts);
628
- const watcher = (0, node_fs.watch)(dataDir, { persistent: false }, (_eventType, filename) => {
629
- onEvent(slug, filename).catch((err) => {
630
- log().warn("watcher event failed", {
631
- slug,
632
- filename,
633
- error: errMsg(err)
634
- });
635
- });
636
- });
637
- watcher.on("error", (err) => {
638
- log().warn("watcher error", {
639
- slug,
640
- error: errMsg(err)
641
- });
642
- });
643
- watchers.set(slug, {
644
- slug,
645
- dataDir,
646
- watcher,
647
- schemaJson: JSON.stringify(schema),
648
- collection
649
- });
650
- log().info("watcher started", {
651
- slug,
652
- dataDir
653
- });
654
- } catch (err) {
655
- log().warn("watcher start failed", {
656
- slug,
657
- error: errMsg(err)
658
- });
659
- }
596
+ /** Test-only: drive one collection-granularity reconcile directly. */
597
+ function _scheduleCollectionReconcileForTesting(slug) {
598
+ return scheduleCollectionReconcile(slug);
660
599
  }
661
- /** Test-only: the per-key single-flight scheduler. Exported so test code
662
- * can drive rapid-fire calls directly and observe the trailing coalesce
663
- * — `fs.watch` event timing is too flaky to assert against.
664
- *
665
- * Single-flight semantics: while a reconcile is in flight for a given
666
- * (slug, itemId), additional events on the same key set `pending = true`
667
- * and return — the running reconcile re-runs once after it completes.
668
- * This collapses fs.watch's rapid-fire bursts (atomic rename surfaces as
669
- * 2-3 events) into a single reconcile + one trailing re-run. */
670
600
  function _scheduleItemReconcileForTesting(collection, itemId) {
671
601
  return scheduleItemReconcile(collection, itemId);
672
602
  }
@@ -740,30 +670,11 @@ function safePublish(payload) {
740
670
  });
741
671
  }
742
672
  }
743
- /** Handle a single fs.watch event. Re-loads the collection (schema may
744
- * have changed since startup), filters out non-record files, and
745
- * forwards to the single-flighted reconciler. `filename === null` (rare,
746
- * platform-specific) triggers a full directory rescan to be safe. */
747
- async function onEvent(slug, filename) {
748
- const collection = await require_discovery.loadCollection(slug, discoveryOpts);
749
- if (!collection) return;
750
- if (filename === null) {
751
- try {
752
- await reconcileAllItems(collection, discoveryOpts);
753
- await sweepStaleActiveEntries(discoveryOpts);
754
- } finally {
755
- safePublish({ slug });
756
- }
757
- return;
758
- }
759
- const name = typeof filename === "string" ? filename : filename.toString("utf-8");
760
- if (!name.endsWith(".json") || name.startsWith(".")) return;
761
- await scheduleItemReconcile(collection, name.slice(0, -5));
762
- }
763
673
  //#endregion
674
+ exports._handleStoreChangeForTesting = _handleStoreChangeForTesting;
764
675
  exports._resetReconcilerLocksForTesting = _resetReconcilerLocksForTesting;
676
+ exports._scheduleCollectionReconcileForTesting = _scheduleCollectionReconcileForTesting;
765
677
  exports._scheduleItemReconcileForTesting = _scheduleItemReconcileForTesting;
766
- exports._scheduleStorageReconcileForTesting = _scheduleStorageReconcileForTesting;
767
678
  exports._syncWatchersForTesting = _syncWatchersForTesting;
768
679
  exports._tickTimeTriggersForTesting = _tickTimeTriggersForTesting;
769
680
  exports.clearItemNotification = clearItemNotification;