@mulmoclaude/core 1.13.0 → 1.14.1
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/error-recovery.md +12 -1
- package/dist/atomic-C_7YpMiM.cjs.map +1 -1
- package/dist/atomic-DPpdrJzO.js.map +1 -1
- package/dist/{calendarGrid-CrGxeCOG.js → calendarGrid-Csy2rpjp.js} +65 -36
- package/dist/{calendarGrid-CrGxeCOG.js.map → calendarGrid-Csy2rpjp.js.map} +1 -1
- package/dist/{calendarGrid-CaS9er8i.cjs → calendarGrid-p8K-7cuB.cjs} +65 -36
- package/dist/{calendarGrid-CaS9er8i.cjs.map → calendarGrid-p8K-7cuB.cjs.map} +1 -1
- package/dist/collection/core/actionVisible.d.ts +3 -3
- package/dist/collection/core/completion.d.ts +5 -5
- package/dist/collection/core/schemaZ.d.ts +4 -4
- package/dist/collection/index.cjs +7 -10
- package/dist/collection/index.cjs.map +1 -1
- package/dist/collection/index.js +7 -10
- package/dist/collection/index.js.map +1 -1
- package/dist/collection/registry/registryIndex.d.ts +1 -1
- package/dist/collection/registry/server/index.cjs +15 -13
- package/dist/collection/registry/server/index.cjs.map +1 -1
- package/dist/collection/registry/server/index.js +15 -13
- package/dist/collection/registry/server/index.js.map +1 -1
- package/dist/collection/server/csvQuery.d.ts +7 -2
- package/dist/collection/server/csvStore.d.ts +2 -1
- package/dist/collection/server/discovery.d.ts +2 -2
- package/dist/collection/server/index.cjs +3 -3
- package/dist/collection/server/index.js +3 -3
- package/dist/collection/server/io.d.ts +3 -3
- package/dist/collection/server/manageTool.d.ts +3 -3
- package/dist/collection/server/store.d.ts +3 -3
- package/dist/collection/server/validate.d.ts +1 -1
- package/dist/collection-watchers/index.cjs +10 -25
- package/dist/collection-watchers/index.cjs.map +1 -1
- package/dist/collection-watchers/index.js +10 -25
- package/dist/collection-watchers/index.js.map +1 -1
- package/dist/{discovery-D-ZJ6a6z.js → discovery-BSedPW8A.js} +64 -36
- package/dist/discovery-BSedPW8A.js.map +1 -0
- package/dist/{discovery-DAoMnBYM.cjs → discovery-DSLgRQhN.cjs} +64 -36
- package/dist/discovery-DSLgRQhN.cjs.map +1 -0
- package/dist/{dist-21pMsI1a.cjs → dist-CsgSfWwR.cjs} +22 -1
- package/dist/{dist-21pMsI1a.cjs.map → dist-CsgSfWwR.cjs.map} +1 -1
- package/dist/{dist-DP1FTGnO.js → dist-D8zokgGo.js} +11 -2
- package/dist/{dist-DP1FTGnO.js.map → dist-D8zokgGo.js.map} +1 -1
- package/dist/feeds/index.cjs +2 -2
- package/dist/feeds/index.js +2 -2
- package/dist/feeds/server/host.d.ts +2 -2
- package/dist/feeds/server/index.cjs +35 -29
- package/dist/feeds/server/index.cjs.map +1 -1
- package/dist/feeds/server/index.js +35 -29
- package/dist/feeds/server/index.js.map +1 -1
- package/dist/feeds/server/refreshResult.d.ts +2 -2
- package/dist/files/index.cjs +3 -2
- package/dist/files/index.cjs.map +1 -1
- package/dist/files/index.js +3 -2
- package/dist/files/index.js.map +1 -1
- package/dist/google/calendar.d.ts +47 -33
- package/dist/google/collectionPush.d.ts +13 -8
- package/dist/google/collectionSync.d.ts +2 -2
- package/dist/google/index.cjs +63 -38
- package/dist/google/index.cjs.map +1 -1
- package/dist/google/index.d.ts +2 -2
- package/dist/google/index.js +61 -38
- package/dist/google/index.js.map +1 -1
- package/dist/{graph-DjE1Az1C.cjs → graph-BZJxiv1E.cjs} +10 -12
- package/dist/graph-BZJxiv1E.cjs.map +1 -0
- package/dist/{graph-BcwqiSpR.js → graph-Ce7xsDTt.js} +10 -12
- package/dist/graph-Ce7xsDTt.js.map +1 -0
- package/dist/{ingestTypes-D1GQdG8e.cjs → ingestTypes-CPR-h-OZ.cjs} +2 -2
- package/dist/{ingestTypes-D1GQdG8e.cjs.map → ingestTypes-CPR-h-OZ.cjs.map} +1 -1
- package/dist/{ingestTypes-XReG-li7.js → ingestTypes-F2IJhOLJ.js} +2 -2
- package/dist/{ingestTypes-XReG-li7.js.map → ingestTypes-F2IJhOLJ.js.map} +1 -1
- package/dist/notifier/index.cjs +1 -1
- package/dist/notifier/index.js +1 -1
- package/dist/notifier/types.d.ts +4 -4
- package/dist/{notifier-C3IhYxqh.cjs → notifier-CKFVVwDP.cjs} +36 -25
- package/dist/notifier-CKFVVwDP.cjs.map +1 -0
- package/dist/{notifier-CMFlw74b.js → notifier-CQD8NljP.js} +36 -25
- package/dist/notifier-CQD8NljP.js.map +1 -0
- package/dist/plugin-vue/index.cjs +4 -2
- package/dist/plugin-vue/index.cjs.map +1 -1
- package/dist/plugin-vue/index.js +4 -2
- package/dist/plugin-vue/index.js.map +1 -1
- package/dist/{project-B9NSyr6L.js → project-B0j_Vf_Q.js} +7 -2
- package/dist/project-B0j_Vf_Q.js.map +1 -0
- package/dist/{project-CCtT_fQr.cjs → project-C1ep9pvo.cjs} +7 -2
- package/dist/project-C1ep9pvo.cjs.map +1 -0
- package/dist/{promptSafety-FD8cQn3e.cjs → promptSafety-DUZ5BiW5.cjs} +106 -88
- package/dist/promptSafety-DUZ5BiW5.cjs.map +1 -0
- package/dist/{promptSafety-C--op_6a.js → promptSafety-DgHMF4Pz.js} +106 -88
- package/dist/promptSafety-DgHMF4Pz.js.map +1 -0
- package/dist/remote-host/index.cjs +2 -1
- package/dist/remote-host/index.d.ts +10 -0
- package/dist/remote-host/index.js +2 -2
- package/dist/remote-host/server/hostRunner.d.ts +7 -2
- package/dist/remote-host/server/index.cjs +14 -11
- package/dist/remote-host/server/index.cjs.map +1 -1
- package/dist/remote-host/server/index.js +13 -10
- package/dist/remote-host/server/index.js.map +1 -1
- package/dist/{remote-host-DkDVxNim.cjs → remote-host-CtQjagPt.cjs} +53 -1
- package/dist/remote-host-CtQjagPt.cjs.map +1 -0
- package/dist/remote-host-Dba4lF3Z.js +82 -0
- package/dist/remote-host-Dba4lF3Z.js.map +1 -0
- package/dist/remote-view/index.cjs +6 -4
- package/dist/remote-view/index.cjs.map +1 -1
- package/dist/remote-view/index.d.ts +1 -1
- package/dist/remote-view/index.js +6 -4
- package/dist/remote-view/index.js.map +1 -1
- package/dist/scheduler/adapter.d.ts +4 -4
- package/dist/scheduler/index.cjs +35 -3
- package/dist/scheduler/index.cjs.map +1 -1
- package/dist/scheduler/index.js +35 -3
- package/dist/scheduler/index.js.map +1 -1
- package/dist/scheduler/task-manager.d.ts +2 -2
- package/dist/{server-B_b0bLSW.cjs → server-1c6gB372.cjs} +54 -31
- package/dist/server-1c6gB372.cjs.map +1 -0
- package/dist/{server-Ci_A3chZ.js → server-Dv_L7l0y.js} +54 -31
- package/dist/server-Dv_L7l0y.js.map +1 -0
- package/dist/skill-bridge/index.cjs +3 -4
- package/dist/skill-bridge/index.cjs.map +1 -1
- package/dist/skill-bridge/index.js +3 -4
- package/dist/skill-bridge/index.js.map +1 -1
- package/dist/types-BKVZrtyc.js.map +1 -1
- package/dist/types-CNqkLT4M.cjs.map +1 -1
- package/dist/utils/fetch.cjs.map +1 -1
- package/dist/utils/fetch.d.ts +1 -1
- package/dist/utils/fetch.js.map +1 -1
- package/dist/utils/index.cjs +1 -1
- package/dist/utils/index.js +1 -1
- package/dist/whisper/client.cjs.map +1 -1
- package/dist/whisper/client.d.ts +3 -3
- package/dist/whisper/client.js.map +1 -1
- package/dist/whisper/index.cjs +2 -6
- package/dist/whisper/index.cjs.map +1 -1
- package/dist/whisper/index.js +2 -6
- package/dist/whisper/index.js.map +1 -1
- package/dist/wiki/index.cjs +4 -4
- package/dist/wiki/index.cjs.map +1 -1
- package/dist/wiki/index.js +4 -4
- package/dist/wiki/index.js.map +1 -1
- package/dist/wiki/server/index.cjs +15 -12
- package/dist/wiki/server/index.cjs.map +1 -1
- package/dist/wiki/server/index.js +15 -12
- package/dist/wiki/server/index.js.map +1 -1
- package/package.json +6 -6
- package/dist/discovery-D-ZJ6a6z.js.map +0 -1
- package/dist/discovery-DAoMnBYM.cjs.map +0 -1
- package/dist/graph-BcwqiSpR.js.map +0 -1
- package/dist/graph-DjE1Az1C.cjs.map +0 -1
- package/dist/notifier-C3IhYxqh.cjs.map +0 -1
- package/dist/notifier-CMFlw74b.js.map +0 -1
- package/dist/project-B9NSyr6L.js.map +0 -1
- package/dist/project-CCtT_fQr.cjs.map +0 -1
- package/dist/promptSafety-C--op_6a.js.map +0 -1
- package/dist/promptSafety-FD8cQn3e.cjs.map +0 -1
- package/dist/remote-host-D_BRFHcI.js +0 -36
- package/dist/remote-host-D_BRFHcI.js.map +0 -1
- package/dist/remote-host-DkDVxNim.cjs.map +0 -1
- package/dist/server-B_b0bLSW.cjs.map +0 -1
- package/dist/server-Ci_A3chZ.js.map +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../../src/collection-watchers/config.ts","../../src/collection-watchers/clock.ts","../../src/collection-watchers/reconciler.ts","../../src/collection-watchers/watcher.ts"],"sourcesContent":["// Host-injected configuration for the collection-completion watchers.\n// The reconciler logic + watcher plumbing are host-agnostic; the\n// notification TAXONOMY (which plugin namespace, how a record priority\n// maps to a bell severity) and the in-app ROUTING (the deep-link a bell\n// row navigates to) are host-specific, so the host supplies them via an\n// adapter. MulmoClaude wires its legacy notification machinery; a future\n// MulmoTerminal wires its own routes + pluginData shape.\n\nimport type { MinimalLogger } from \"@mulmoclaude/common\";\nimport type { NotifierSeverity } from \"../notifier\";\n\n/** Two-level urgency a pending record can carry, derived from the\n * schema's `notifyWhen` spec. The host maps this onto its own severity\n * scale via `priorityToSeverity`. */\nexport type CompletionPriority = \"normal\" | \"high\";\n\nexport type CollectionWatcherLogger = Pick<MinimalLogger, \"info\" | \"warn\">;\n\n/** The host-specific notification surface the reconciler binds to. The\n * reconciler owns the internal `legacyId` key (it encodes slug+itemId\n * and round-trips it through `pluginData`); the adapter only wraps /\n * unwraps it into whatever shape the host's bell expects. */\nexport interface CollectionNotificationAdapter {\n /** Plugin namespace these bell entries publish under (MulmoClaude: \"todo\"). */\n pluginPkg: string;\n /** Map a record's completion priority onto the host's bell severity. */\n priorityToSeverity: (priority: CompletionPriority) => NotifierSeverity;\n /** Build the in-app deep-link the bell row routes to on click. */\n buildNavigateTarget: (slug: string, itemId: string) => string;\n /** Wrap the reconciler's internal key + priority into the host's\n * `pluginData` shape. Stored verbatim on the entry; recovered via\n * `readEntry`. */\n buildPluginData: (input: { legacyId: string; slug: string; itemId: string; priority: CompletionPriority; navigateTarget: string }) => unknown;\n /** Recognise a bell entry produced by this reconciler and recover its\n * internal key + stored priority. Returns null for entries that didn't\n * originate here, so `listAll()` scans skip foreign entries. */\n readEntry: (pluginData: unknown) => { legacyId: string; priority: CompletionPriority } | null;\n}\n\nconst NOOP_LOG: CollectionWatcherLogger = { info: () => {}, warn: () => {} };\n\nlet adapter: CollectionNotificationAdapter | null = null;\nlet activeLogger: CollectionWatcherLogger = NOOP_LOG;\n\n/** Wire the host adapter + logger. Call once at startup, before\n * `startCollectionWatchers` or any direct reconcile call. */\nexport function configureCollectionWatchers(config: { adapter: CollectionNotificationAdapter; log?: CollectionWatcherLogger }): void {\n ({ adapter } = config);\n activeLogger = config.log ?? NOOP_LOG;\n}\n\nexport function requireAdapter(): CollectionNotificationAdapter {\n if (!adapter) throw new Error(\"collection-watchers: configureCollectionWatchers() not called\");\n return adapter;\n}\n\nexport function log(): CollectionWatcherLogger {\n return activeLogger;\n}\n\n/** Test-only: clear the host wiring. */\nexport function resetCollectionWatchersConfig(): void {\n adapter = null;\n activeLogger = NOOP_LOG;\n}\n\nexport function errMsg(err: unknown): string {\n return err instanceof Error ? err.message : String(err);\n}\n","// Evaluation-only injectable clock. When MULMOCLAUDE_FAKE_NOW is set to\n// a parseable date/datetime string, every reconcile pass derives \"now\"\n// from it instead of the wall clock — letting evaluation runs and tests\n// advance time deterministically (a `triggerField` coming due, a `spawn`\n// successor's own trigger firing later) without waiting for real days to\n// pass. Unset or unparseable (i.e. normal operation) → real wall clock.\n// Read per call, so a long-lived server picks up changes without restart.\nexport function evalNow(): Date {\n const raw = process.env.MULMOCLAUDE_FAKE_NOW;\n if (!raw) return new Date();\n const parsed = new Date(raw);\n return Number.isNaN(parsed.getTime()) ? new Date() : parsed;\n}\n","// Bell-notification reconciler for collections whose schema declares\n// `completionField`. Driven by `watcher.ts`, which calls into the\n// functions below on file-system events and on boot.\n//\n// The model is **convergent**: a watcher event re-reads the record from\n// disk and the reconciler enforces the invariant\n//\n// bell entry exists for (slug, itemId) ↔\n// schema has completionField ∧\n// file exists ∧\n// `String(item[completionField])` ∉ completionDoneValues\n// (∧ trigger due ∧ notifyWhen matches, when declared)\n//\n// Each reconcile is idempotent (`ensure*` / `clear*` no-op when state\n// already matches). This is why event-type quirks of `fs.watch`\n// (`rename` vs `change`, missed events, atomic-write coalescence) don't\n// matter — every event re-derives the desired state from the file.\n//\n// Lookup uses a deterministic internal `legacyId` derived from\n// `<slug>:<itemId>`, stashed on each entry's `pluginData` via the host\n// adapter, so the clear path and the dedup check both find the entry\n// without a side state file.\n\nimport { clear as notifierClear, listAll, publish as notifierPublish, updateForPlugin as notifierUpdate, type NotifierEntry } from \"../notifier\";\nimport { fieldText, itemIsDone, whenMatches, type CollectionItem, type CollectionSchema } from \"../collection\";\nimport {\n type DiscoveryOptions,\n type IoOptions,\n isTriggerDue,\n maybeSpawnSuccessor,\n loadCollection,\n storeFor,\n type LoadedCollection,\n} from \"../collection/server\";\nimport { type CompletionPriority, errMsg, log, requireAdapter } from \"./config.js\";\nimport { evalNow } from \"./clock.js\";\n\n/** The internal-id prefix every collection-completion bell entry carries.\n * Used both to build new keys and to filter sweep candidates from the\n * active bell. */\nconst LEGACY_ID_PREFIX = \"collection-completion:\";\n\n/** Stable key encoding slug + item, round-tripped through the entry's\n * `pluginData` so we can find it later without a side state file. Slug +\n * itemId are upstream-validated via `safeSlugName`, which forbids the\n * colon separator, so the two-segment parse below is unambiguous. */\nfunction completionLegacyId(slug: string, itemId: string): string {\n return `${LEGACY_ID_PREFIX}${slug}:${itemId}`;\n}\n\n/** Decode a key back into its (slug, itemId) pair, or null if the string\n * didn't originate from this module. Used by the sweep step. */\nfunction parseCompletionLegacyId(legacyId: string): { slug: string; itemId: string } | null {\n if (!legacyId.startsWith(LEGACY_ID_PREFIX)) return null;\n const body = legacyId.slice(LEGACY_ID_PREFIX.length);\n const colon = body.indexOf(\":\");\n if (colon < 0) return null;\n return { slug: body.slice(0, colon), itemId: body.slice(colon + 1) };\n}\n\n/** The human-readable label shown in a completion notification's title.\n * Uses the schema's `displayField` value when declared and non-empty;\n * otherwise falls back to the record's primaryKey (`itemId`). */\nexport function resolveDisplayLabel(schema: CollectionSchema, item: CollectionItem, itemId: string): string {\n const { displayField } = schema;\n if (!displayField) return itemId;\n // Same rule as `itemLabelOf`: an array/object display field falls back to\n // the id rather than labelling the record \"[object Object]\".\n const label = fieldText(item[displayField]).trim();\n return label.length > 0 ? label : itemId;\n}\n\n// The done predicate moved to `../collection/core/completion` (flag-aware,\n// browser-safe, shared with spawn and view filters). Re-exported so the\n// `collection-watchers` barrel keeps its public surface (MulmoTerminal).\nexport { itemIsDone };\n\n/** Every active bell entry whose key matches this (slug, itemId).\n * Returns multiple when defensive cleanup is needed. Scans `listAll()`\n * — cheap because the active set is bounded. */\nasync function findActiveEntries(slug: string, itemId: string): Promise<NotifierEntry[]> {\n const adapter = requireAdapter();\n const legacyId = completionLegacyId(slug, itemId);\n const entries = await listAll();\n return entries.filter((entry) => adapter.readEntry(entry.pluginData)?.legacyId === legacyId);\n}\n\nasync function findActiveEntryIds(slug: string, itemId: string): Promise<string[]> {\n return (await findActiveEntries(slug, itemId)).map((entry) => entry.id);\n}\n\n/** Per-key in-flight lock. Serializes concurrent `ensureItemNotification`\n * calls for the same (slug, itemId) so the `findActiveEntries → publish`\n * check stays atomic across callers — not just across watcher events.\n * `listAll` bypasses the engine's write queue, so without this lock two\n * reconcile paths (a watcher event + a `reconcileAllItems` pass that a\n * readdir-triggered event raced) could both miss each other's in-flight\n * publish and produce duplicate entries. */\ninterface EnsureLock {\n promise: Promise<void>;\n}\nconst ensureLocks = new Map<string, EnsureLock>();\n\n/** Bell priority for a record: the FIRST flagged value in `notifyWhen.in`\n * (most urgent) reads `high`, every other flagged value `normal`.\n * Collections with no `notifyWhen` (notify for every open record) stay\n * `normal`. */\nfunction notifyPriorityForItem(schema: CollectionSchema, item: CollectionItem): CompletionPriority {\n const spec = schema.notifyWhen;\n if (!spec) return \"normal\";\n const value = item[spec.field] === undefined || item[spec.field] === null ? \"\" : String(item[spec.field]);\n return spec.in.indexOf(value) === 0 ? \"high\" : \"normal\";\n}\n\nasync function ensureItemNotification(\n slug: string,\n schema: CollectionSchema,\n itemId: string,\n displayLabel: string,\n priority: CompletionPriority,\n): Promise<void> {\n const legacyId = completionLegacyId(slug, itemId);\n // Drain any in-flight publish for this key BEFORE our check + set. The\n // drain + claim runs synchronously between `ensureLocks.get` and\n // `ensureLocks.set`, so two callers can't both observe an empty slot.\n\n while (true) {\n const inflight = ensureLocks.get(legacyId);\n if (!inflight) break;\n await inflight.promise;\n }\n const lock: EnsureLock = { promise: doEnsureItemNotification(slug, schema, itemId, legacyId, displayLabel, priority) };\n ensureLocks.set(legacyId, lock);\n try {\n await lock.promise;\n } finally {\n // Only clear the slot if it still points at OUR lock — a\n // sufficiently-delayed cleanup must not stomp a later claim.\n if (ensureLocks.get(legacyId) === lock) {\n ensureLocks.delete(legacyId);\n }\n }\n}\n\n/** Converge any already-present bell entries to `priority`, updating in\n * place (preserving id / position / createdAt) so a record whose flagged\n * value changed while it stayed pending re-colours the bell without a\n * clear+republish flicker. No-op when the stored priority already matches. */\nasync function reconcileEntrySeverity(slug: string, itemId: string, entries: NotifierEntry[], priority: CompletionPriority): Promise<void> {\n const adapter = requireAdapter();\n for (const entry of entries) {\n const parsed = adapter.readEntry(entry.pluginData);\n if (!parsed || parsed.priority === priority) continue;\n await notifierUpdate(adapter.pluginPkg, entry.id, {\n severity: adapter.priorityToSeverity(priority),\n pluginData: adapter.buildPluginData({\n legacyId: parsed.legacyId,\n slug,\n itemId,\n priority,\n navigateTarget: adapter.buildNavigateTarget(slug, itemId),\n }),\n });\n }\n}\n\nasync function doEnsureItemNotification(\n slug: string,\n schema: CollectionSchema,\n itemId: string,\n legacyId: string,\n displayLabel: string,\n priority: CompletionPriority,\n): Promise<void> {\n const adapter = requireAdapter();\n try {\n const existing = await findActiveEntries(slug, itemId);\n if (existing.length > 0) {\n await reconcileEntrySeverity(slug, itemId, existing, priority);\n return;\n }\n const navigateTarget = adapter.buildNavigateTarget(slug, itemId);\n // `lifecycle: \"action\"` — these are state-of-the-world entries\n // mirroring an outstanding obligation (the item is pending), not\n // transient pings. Validation requires a non-info severity and a\n // non-empty `navigateTarget` (the slug + itemId deep-link).\n await notifierPublish({\n pluginPkg: adapter.pluginPkg,\n severity: adapter.priorityToSeverity(priority),\n lifecycle: \"action\",\n title: `${schema.title}: ${displayLabel}`,\n navigateTarget,\n pluginData: adapter.buildPluginData({ legacyId, slug, itemId, priority, navigateTarget }),\n });\n } catch (err) {\n log().warn(\"notify ensure failed\", { slug, itemId, error: errMsg(err) });\n }\n}\n\n/** Idempotently clear EVERY bell entry that matches this (slug, itemId).\n * Silent no-op when nothing matches. The \"every\" is defensive: if a\n * duplicate ever slips through, this drains the lot. */\nexport async function clearItemNotification(slug: string, itemId: string): Promise<void> {\n try {\n const ids = await findActiveEntryIds(slug, itemId);\n for (const entryId of ids) {\n await notifierClear(entryId);\n }\n } catch (err) {\n log().warn(\"notify clear failed\", { slug, itemId, error: errMsg(err) });\n }\n}\n\n/** Reconcile one item to the desired bell state. Re-reads the record\n * through the collection's STORE (file, sqlite, …) so the decision is\n * grounded in current truth, not in the event payload. Safe to call when\n * the record is missing (delete path).\n *\n * `ioOpts` flows into the store's workspace-containment checks —\n * production callers (the watcher) pass nothing; tests pass\n * `{ workspaceRoot: <tmpdir> }` so the check accepts a fixture dataDir. */\nexport async function reconcileItem(collection: LoadedCollection, itemId: string, ioOpts: IoOptions = {}, now: Date = evalNow()): Promise<void> {\n const { slug, schema } = collection;\n if (!schema.completionField) {\n // Schema doesn't track completion — drop any stale entry.\n await clearItemNotification(slug, itemId);\n return;\n }\n const item = await storeFor(collection, ioOpts).read(itemId);\n if (item === null) {\n await clearItemNotification(slug, itemId);\n return;\n }\n // Recurrence: predicate-gated + create-if-absent, idempotent and\n // independent of this item's own bell state. Runs before the done-clear\n // below so marking an item done still spawns its successor.\n await maybeSpawnSuccessor(collection, item, itemId, ioOpts);\n if (itemIsDone(schema, item)) {\n await clearItemNotification(slug, itemId);\n return;\n }\n // Time gate: when the schema declares `triggerField`, suppress the bell\n // until the clock reaches that date (minus `triggerLeadDays`).\n // Unparseable date ⇒ fail safe (no bell); warn ONLY when the field carries\n // a non-empty value that won't parse — an empty optional trigger date is a\n // normal state and must not spam a WARN every reconcile tick.\n if (schema.triggerField) {\n const triggerRaw = item[schema.triggerField];\n const due = isTriggerDue(triggerRaw, now, schema.triggerLeadDays);\n const isEmpty = triggerRaw === undefined || triggerRaw === null || triggerRaw === \"\";\n if (due === null && !isEmpty) {\n log().warn(\"trigger date unparseable, suppressing bell\", { slug, itemId, triggerField: schema.triggerField });\n }\n if (due !== true) {\n await clearItemNotification(slug, itemId);\n return;\n }\n }\n // Condition gate: when the schema declares `notifyWhen`, only bell\n // records matching the predicate. Convergent — a record that stops\n // matching has its bell cleared.\n if (!whenMatches(schema.notifyWhen, item)) {\n await clearItemNotification(slug, itemId);\n return;\n }\n await ensureItemNotification(slug, schema, itemId, resolveDisplayLabel(schema, item, itemId), notifyPriorityForItem(schema, item));\n}\n\n/** Boot-time reconcile: walk every record of the collection once (through\n * its store) and reconcile it. Catches up changes that happened while the\n * server was down. Deleted items are covered by\n * `sweepStaleActiveEntries`, not this function (it only sees records\n * that exist). */\nexport async function reconcileAllItems(collection: LoadedCollection, ioOpts: IoOptions = {}, now: Date = evalNow()): Promise<void> {\n const { slug, schema } = collection;\n if (!schema.completionField) return;\n let items: CollectionItem[];\n try {\n items = await storeFor(collection, ioOpts).list();\n } catch (err) {\n log().warn(\"reconcile list failed\", { slug, error: errMsg(err) });\n return;\n }\n const { primaryKey } = schema;\n for (const item of items) {\n const raw = item[primaryKey];\n if (typeof raw !== \"string\" || raw.length === 0) continue;\n await reconcileItem(collection, raw, ioOpts, now);\n }\n}\n\n/** Boot-time sweep over the active bell: drop any entries whose underlying\n * file is gone, whose collection was deleted, whose schema no longer\n * tracks completion, or whose item is now done. Reverse-covers the cases\n * `reconcileAllItems` misses (it only walks files that exist). */\nexport async function sweepStaleActiveEntries(opts: DiscoveryOptions = {}): Promise<void> {\n const adapter = requireAdapter();\n let entries;\n try {\n entries = await listAll();\n } catch (err) {\n log().warn(\"sweep list failed\", { error: errMsg(err) });\n return;\n }\n for (const entry of entries) {\n const own = adapter.readEntry(entry.pluginData);\n if (!own) continue;\n const parsed = parseCompletionLegacyId(own.legacyId);\n if (!parsed) continue;\n const { slug, itemId } = parsed;\n try {\n const collection = await loadCollection(slug, opts);\n if (!collection || !collection.schema.completionField) {\n await notifierClear(entry.id);\n continue;\n }\n const item = await storeFor(collection, opts).read(itemId);\n if (item === null || itemIsDone(collection.schema, item) || !whenMatches(collection.schema.notifyWhen, item)) {\n await notifierClear(entry.id);\n }\n } catch (err) {\n log().warn(\"sweep entry failed\", { slug, itemId, error: errMsg(err) });\n }\n }\n}\n\n/** Test-only: clear the per-key in-flight locks. */\nexport function _resetReconcilerLocksForTesting(): void {\n ensureLocks.clear();\n}\n","// Filesystem watchers that drive collection-completion bell\n// notifications AND the live-refresh change event. One `fs.watch` per\n// discovered collection's `dataDir`, fanned out from a single boot call\n// + a 30-second re-discovery interval that catches newly-created /\n// deleted collections (there is no in-process \"collections changed\"\n// event broadcast).\n//\n// Why a watcher, not just route hooks: the canonical pattern for\n// collection-skills has the agent Write records directly with the Write\n// tool — that path never hits the REST API, so a route-level hook would\n// miss most of the traffic the user generates. The watcher catches every\n// mutation regardless of who wrote the file.\n//\n// That same reasoning is why the watcher publishes change events: a\n// write through `io.ts` publishes its own (immediately, and reliably\n// even where fs.watch is unavailable), but a direct file write has no\n// other producer, so open views would never refresh. Both producers\n// firing for one `io.ts` write is intentional — the payload carries no\n// bodies, so a duplicate costs one redundant refetch, whereas a missed\n// event leaves the UI silently stale.\n//\n// FOUR paths can re-derive a collection's bells, and every backend —\n// including read-only `dataSource` — must be covered by all four. They are\n// listed because they were NOT: this module grew up when only JSON records\n// reconciled, so each path had its own `dataSource` short-circuit, and\n// routing every backend through the store contract left the short-circuits\n// behind. Three of the four were separate live bugs (PR #2243 review).\n//\n// 1. mount — `startWatcherFor`, boot + every remount\n// 2. store change — `handleStoreChange`, the backend reported bytes moved\n// 3. schema change — `reconcileChangedSchemas`, the RULES moved instead\n// 4. clock tick — `tickTimeTriggers`, `triggerField` came due\n//\n// 3 and 4 are the ones that look skippable and are not: a read-only backend's\n// rows never change, but the completion rules applied to them and the wall\n// clock both do, and neither produces a data event to react to. Before adding\n// a `dataSource` (or any per-backend) early-exit here, check it against all\n// four — the surviving ones below are view-refresh publishes, not skips.\n//\n// All decisions live in `reconciler.ts`; this module is pure plumbing:\n// discover, mkdir, fs.watch, forward events into the reconciler. Every\n// reconcile call is idempotent so fs.watch's well-known quirks (`rename`\n// vs `change`, atomic-write coalescence, filename === null on some\n// platforms) don't need special handling.\n\nimport { access } from \"node:fs/promises\";\nimport {\n discoverCollections,\n itemFilePath,\n loadCollection,\n publishCollectionChange,\n storeFor,\n type DiscoveryOptions,\n type LoadedCollection,\n type StoreChange,\n} from \"../collection/server\";\nimport type { CollectionSchema } from \"../collection\";\nimport { errMsg, log } from \"./config.js\";\nimport { evalNow } from \"./clock.js\";\nimport { reconcileAllItems, reconcileItem, sweepStaleActiveEntries } from \"./reconciler.js\";\n\n// Collections don't get added / removed rapidly; 30 s is a comfortable\n// upper bound on how long a new schema can sit before its watcher is up.\nconst ONE_SECOND_MS = 1000;\nconst ONE_MINUTE_MS = 60 * ONE_SECOND_MS;\nconst REDISCOVERY_INTERVAL_MS = 30 * ONE_SECOND_MS;\n\n// Wall-clock tick that re-reconciles time-dependent collections (those\n// declaring `triggerField` and/or `spawn`). The fs.watcher only re-runs\n// the reconciler on FILE changes; a `triggerField` bell that should fire\n// \"when the clock reaches date X\" — and a `spawn` whose successor's own\n// trigger later comes due — change no file at that moment, so a periodic\n// re-derivation is required.\nconst TRIGGER_TICK_INTERVAL_MS = ONE_MINUTE_MS;\n\ninterface CollectionWatcher {\n slug: string;\n dataDir: string;\n /** Unsubscribe from the store's change stream. The store owns HOW changes\n * are detected (which paths, which filenames are noise, how an atomic\n * replace is debounced); this module only holds the handle. */\n unsubscribe: () => void;\n /** Last-seen serialized schema for change detection. When a rediscovery\n * tick observes a different value, the watcher's items are reconciled\n * and the cache is refreshed — this catches schema-only edits (e.g.\n * flipping `completionField` on or off) that don't touch any record\n * file and would otherwise leave bell state stale indefinitely. */\n schemaJson: string;\n /** The discovered collection this watcher was mounted for — what the\n * reconciler needs to pick the right STORE (file records vs a sqlite\n * `storage` db). Refreshed whenever `schemaJson` is. */\n collection: LoadedCollection;\n}\n\nconst watchers = new Map<string, CollectionWatcher>();\nlet rediscoveryTimer: ReturnType<typeof setInterval> | null = null;\nlet triggerTimer: ReturnType<typeof setInterval> | null = null;\nlet started = false;\n/** Guards the clock tick against overlapping itself. Paired with\n * `watcherEpoch`: a teardown while a pass is in flight bumps the epoch, so\n * that pass's `finally` knows it belongs to a dead generation and must not\n * clear a guard the restarted watcher set now owns. `triggerTickInFlight`\n * is what teardown AWAITS — clearing the flag alone would let a restart run\n * a second pass alongside the first. */\nlet triggerTickRunning = false;\nlet watcherEpoch = 0;\nlet triggerTickInFlight: Promise<void> | null = null;\n/** Discovery options threaded into every `discoverCollections` /\n * `loadCollection` / `sweepStaleActiveEntries` call. Production: empty\n * (live workspace). Tests: `{ workspaceRoot, userSkillsDir }` pointing\n * at a fixture tree. Module-level so per-event handlers can read it\n * without threading through every signature. */\nlet discoveryOpts: DiscoveryOptions = {};\n\n/** Per-key single-flight slot (declared here so `stopCollectionWatchers`\n * can clear it during teardown). */\ninterface ReconcileSlot {\n running: Promise<void>;\n pending: boolean;\n}\nconst itemSlots = new Map<string, ReconcileSlot>();\n\n/** Per-slug single-flight for a COLLECTION-granularity reconcile — a burst\n * of changes the store couldn't attribute to a record collapses into one\n * pass plus one trailing re-run, mirroring the per-item slots. */\nconst collectionSlots = new Map<string, ReconcileSlot>();\n\n/** Test-only configuration knobs. Production callers pass nothing and get\n * the live workspace defaults; tests pass a tmpdir-rooted `discoveryOpts`\n * and override the tick cadences (or set them to `null` to disable the\n * auto-ticks so the test drives sync manually). */\nexport interface CollectionWatcherOptions {\n discoveryOpts?: DiscoveryOptions;\n rediscoveryIntervalMs?: number | null;\n triggerTickIntervalMs?: number | null;\n}\n\n/** Boot entry point: sweep stale active entries, then mount watchers for\n * every discovered collection and arm the periodic re-discovery poll.\n * Idempotent — a second call is a no-op. */\nexport async function startCollectionWatchers(opts: CollectionWatcherOptions = {}): Promise<void> {\n if (started) return;\n // `started` only flips on AFTER boot finishes. If sweep or syncWatchers\n // throws mid-boot, reset state on failure so a supervisor / test\n // harness can retry instead of being permanently latched.\n discoveryOpts = opts.discoveryOpts ?? {};\n try {\n // Boot reconcile is split in two: sweep first (drop bell entries whose\n // files / collections / schemas vanished while the server was down),\n // then `syncWatchers` runs the per-collection forward fill. Both paths\n // are idempotent and converge on the same end state.\n await sweepStaleActiveEntries(discoveryOpts);\n await syncWatchers();\n const intervalMs = opts.rediscoveryIntervalMs === undefined ? REDISCOVERY_INTERVAL_MS : opts.rediscoveryIntervalMs;\n if (intervalMs !== null) {\n rediscoveryTimer = setInterval(() => {\n syncWatchers().catch((err: unknown) => {\n log().warn(\"watcher rediscovery failed\", { error: errMsg(err) });\n });\n }, intervalMs);\n // `unref` so a clean process exit isn't blocked waiting for the tick.\n rediscoveryTimer.unref();\n }\n const triggerMs = opts.triggerTickIntervalMs === undefined ? TRIGGER_TICK_INTERVAL_MS : opts.triggerTickIntervalMs;\n if (triggerMs !== null) {\n triggerTimer = setInterval(() => {\n // Skip rather than overlap. The pass is idempotent and the next one\n // is a minute away, so dropping a tick is harmless — whereas letting\n // firings pile up on a slow pass is not.\n if (triggerTickRunning) return;\n triggerTickRunning = true;\n const epoch = watcherEpoch;\n triggerTickInFlight = tickTimeTriggers()\n .catch((err: unknown) => {\n log().warn(\"watcher trigger tick failed\", { error: errMsg(err) });\n })\n .finally(() => {\n // Only the generation that set the guard may clear it.\n if (epoch !== watcherEpoch) return;\n triggerTickRunning = false;\n triggerTickInFlight = null;\n });\n }, triggerMs);\n triggerTimer.unref();\n }\n started = true;\n } catch (err) {\n discoveryOpts = {};\n throw err;\n }\n}\n\n/** Tear down every watcher and stop the intervals. Used by tests;\n * production never calls this (process exit reclaims the fds). Resets\n * `started` so a subsequent `startCollectionWatchers` re-mounts. */\nexport async function stopCollectionWatchers(): Promise<void> {\n if (rediscoveryTimer) {\n clearInterval(rediscoveryTimer);\n rediscoveryTimer = null;\n }\n if (triggerTimer) {\n clearInterval(triggerTimer);\n triggerTimer = null;\n }\n // Wait for a clock pass that is still running: the interval is disarmed\n // above, but a pass already in flight keeps touching the notifier and the\n // slot maps, and a restart would otherwise run a second one beside it.\n await triggerTickInFlight;\n triggerTickInFlight = null;\n // Bump AFTER the await: any later `finally` from that pass is now a dead\n // generation and becomes a no-op, so it can't undo this teardown.\n watcherEpoch += 1;\n triggerTickRunning = false;\n for (const watcher of watchers.values()) {\n try {\n watcher.unsubscribe();\n } catch {\n /* unsubscribe is best-effort */\n }\n }\n watchers.clear();\n itemSlots.clear();\n collectionSlots.clear();\n discoveryOpts = {};\n started = false;\n}\n\n/** Test-only: manually trigger one rediscovery + reconcile pass. */\nexport async function _syncWatchersForTesting(): Promise<boolean> {\n return syncWatchers();\n}\n\n/** Test-only: drive one wall-clock tick synchronously, with an optional\n * injected clock. */\nexport async function _tickTimeTriggersForTesting(now?: Date): Promise<void> {\n await tickTimeTriggers(now);\n}\n\n/** Re-reconcile every watched collection that depends on the clock — i.e.\n * declares `triggerField` (a bell that fires at a date) and/or `spawn`\n * (recurrence whose successors come due over time). Collections with\n * neither are skipped. Idempotent. The schema is parsed back from the\n * watcher's cached `schemaJson` to avoid a per-tick disk read. */\nasync function tickTimeTriggers(now: Date = evalNow()): Promise<void> {\n for (const entry of watchers.values()) {\n let schema: CollectionSchema;\n try {\n schema = JSON.parse(entry.schemaJson) as CollectionSchema;\n } catch (err) {\n log().warn(\"trigger tick: bad cached schema\", { slug: entry.slug, error: errMsg(err) });\n continue;\n }\n // dataSource is NOT excluded. Its rows are read-only, but `triggerField`\n // is not among the keys zod forbids on it, and a trigger date fires from\n // the CLOCK — the one state change that arrives without the file moving.\n // Skipping it here left CSV rows that were pending-but-not-yet-due unable\n // to ever bell unless the file happened to be rewritten.\n if (!schema.triggerField && !schema.spawn) continue;\n await reconcileAllItems(entry.collection, discoveryOpts, now);\n }\n}\n\n/** Reconcile the watcher set against the currently-discovered\n * collections. Adds watchers for new slugs (with a boot reconcile of\n * their items), drops watchers for vanished slugs, and re-reconciles\n * items for collections whose schema changed. Runs a final sweep when\n * this tick changed the watcher set or any schema. */\nasync function syncWatchers(): Promise<boolean> {\n let collections;\n try {\n collections = await discoverCollections(discoveryOpts);\n } catch (err) {\n log().warn(\"watcher discover failed\", { error: errMsg(err) });\n return false;\n }\n const liveSlugs = new Set(collections.map((collection) => collection.slug));\n const vanishedMutated = stopVanishedWatchers(liveSlugs);\n const schemaMutated = await reconcileChangedSchemas(collections);\n const addedMutated = await startNewWatchers(collections);\n if (!vanishedMutated && !schemaMutated && !addedMutated) return false;\n await sweepStaleActiveEntries(discoveryOpts);\n return true;\n}\n\nfunction stopVanishedWatchers(liveSlugs: Set<string>): boolean {\n let mutated = false;\n for (const slug of [...watchers.keys()]) {\n if (liveSlugs.has(slug)) continue;\n const watcher = watchers.get(slug);\n if (watcher) {\n try {\n watcher.unsubscribe();\n } catch {\n /* best-effort */\n }\n }\n watchers.delete(slug);\n mutated = true;\n log().info(\"watcher stopped\", { slug });\n }\n return mutated;\n}\n\n/** True when a schema edit moved the collection's storage — a different\n * `dataSource.path`, a different `dataPath`, or a flip between the two\n * modes. The mounted fs.watch is bound to the OLD location, so it must\n * be remounted, not just re-reconciled. */\nfunction storagePathChanged(previousJson: string, next: LoadedCollection[\"schema\"]): boolean {\n let previous: LoadedCollection[\"schema\"];\n try {\n previous = JSON.parse(previousJson) as LoadedCollection[\"schema\"];\n } catch {\n return true; // unreadable cache — remount to be safe\n }\n return previous.dataSource?.path !== next.dataSource?.path || previous.dataPath !== next.dataPath || previous.storage?.path !== next.storage?.path;\n}\n\n/** Re-reconcile already-watched collections whose schema changed since\n * the last tick. New collections fall through to `startNewWatchers`. */\nasync function reconcileChangedSchemas(collections: readonly LoadedCollection[]): Promise<boolean> {\n let mutated = false;\n for (const collection of collections) {\n const existing = watchers.get(collection.slug);\n if (!existing) continue;\n const nextJson = JSON.stringify(collection.schema);\n if (existing.schemaJson === nextJson) continue;\n if (storagePathChanged(existing.schemaJson, collection.schema)) {\n // Drop the stale mount; `startNewWatchers` (which runs right after\n // this pass in syncWatchers) remounts on the new location. A\n // dataSource collection also gets a change ping so open views\n // refetch against the new file immediately.\n log().info(\"watcher storage path changed, remounting\", { slug: collection.slug });\n try {\n existing.unsubscribe();\n } catch {\n /* best-effort */\n }\n watchers.delete(collection.slug);\n if (collection.schema.dataSource !== undefined) publishCollectionChange({ slug: collection.slug, op: \"upsert\" });\n mutated = true;\n continue;\n }\n existing.schemaJson = nextJson;\n existing.collection = collection;\n log().info(\"watcher schema changed, re-reconciling\", { slug: collection.slug });\n // Completion rules live in the SCHEMA, so a schema-only edit can change\n // which items are pending without any record changing. This runs for\n // every backend including dataSource: those rows are read-only, but the\n // rules applied to them are not. Re-deriving no-ops unless the schema\n // declares `completionField`; a completionField that was REMOVED is the\n // sweep's job, which `mutated` schedules at the end of the tick.\n await reconcileAllItems(collection, discoveryOpts);\n if (collection.schema.dataSource !== undefined) {\n // Read-only rows can't have changed, but a schema edit changes what the\n // views render (fields, displayField, …), so ping them.\n publishCollectionChange({ slug: collection.slug, op: \"upsert\" });\n }\n mutated = true;\n }\n return mutated;\n}\n\n/** Mount a watcher for every collection that doesn't have one yet. Returns\n * whether the watcher SET actually changed — the starters report whether\n * they mounted, because ATTEMPTING is not mounting. A start that throws\n * (logged and swallowed inside the starter) leaves `watchers` untouched, so\n * the collection is retried next tick; counting that as a mutation made\n * `syncWatchers` sweep on every tick for as long as the failure persisted. */\nasync function startNewWatchers(collections: readonly LoadedCollection[]): Promise<boolean> {\n let mutated = false;\n for (const collection of collections) {\n if (watchers.has(collection.slug)) continue;\n if (await startWatcherFor(collection)) mutated = true;\n }\n return mutated;\n}\n\n/** Mount one collection's change subscription, whatever its backend.\n *\n * The store decides how to detect a change and at what granularity; this\n * decides what to do about one. That split is the point: adding a backend\n * means implementing `watch` on its store, not another branch here.\n *\n * A store without `watch` cannot report external changes at all — it is\n * still registered (so bells reconcile at boot and on the clock tick), just\n * without live updates. */\nasync function startWatcherFor(collection: LoadedCollection): Promise<boolean> {\n const { slug } = collection;\n try {\n // Boot reconcile BEFORE subscribing: an item that went pending while the\n // server was down needs its bell even if no event ever fires.\n await reconcileAllItems(collection, discoveryOpts);\n const store = storeFor(collection, discoveryOpts);\n const unsubscribe = store.watch\n ? await store.watch((change) => {\n void handleStoreChange(slug, change).catch((err: unknown) => {\n log().warn(\"store change handling failed\", { slug, error: errMsg(err) });\n });\n })\n : () => {};\n // `null` means the backend HAS a watch but could not arm it this time.\n // Registering anyway would mark the slug mounted forever: `startNewWatchers`\n // skips slugs already in `watchers`, so nothing would re-arm it and the\n // collection would serve stale data until a restart. Leave it out and the\n // next sync tick retries — the boot reconcile above is idempotent.\n if (unsubscribe === null) {\n log().warn(\"collection watcher could not arm, retrying next sync\", { slug });\n return false;\n }\n watchers.set(slug, { slug, dataDir: collection.dataDir, unsubscribe, schemaJson: JSON.stringify(collection.schema), collection });\n log().info(\"collection watcher started\", { slug, live: store.watch !== undefined });\n return true;\n } catch (err) {\n log().warn(\"collection watcher start failed\", { slug, error: errMsg(err) });\n return false;\n }\n}\n\n/** React to one reported change. Backend-agnostic by construction — it sees\n * only the granularity the store reported.\n *\n * `item`: reconcile just that record, then publish it. `collection`: the\n * store couldn't say which record, so re-derive everything and pair it with\n * a sweep — a record deleted remotely leaves a bell that a walk over the\n * SURVIVING records can never clear. */\nasync function handleStoreChange(slug: string, change: StoreChange): Promise<void> {\n if (change.kind === \"collection\") {\n await scheduleCollectionReconcile(slug);\n return;\n }\n // Resolve the collection at EVENT time, not at mount time. A schema-only\n // edit (one that leaves the storage location alone, so nothing remounts)\n // refreshes `watchers`' entry in place — a callback closed over the mount-\n // time snapshot would keep reconciling against the old `completionField` /\n // `notifyWhen` / `triggerField` and undo what the schema-change pass had\n // just converged on.\n const current = watchers.get(slug)?.collection;\n if (!current) return; // unmounted between the event and now\n await scheduleItemReconcile(current, change.itemId);\n}\n\n/** Full re-derivation for a collection-granularity change, single-flighted\n * per slug so a burst of writes collapses into one pass plus one trailing\n * re-run. */\nfunction scheduleCollectionReconcile(slug: string): Promise<void> {\n return runSingleFlight(\n collectionSlots,\n slug,\n async () => {\n const collection = await loadCollection(slug, discoveryOpts);\n if (!collection) return;\n await reconcileAllItems(collection, discoveryOpts);\n // A record deleted underneath us leaves a bell that a walk over the\n // SURVIVING records can never clear — the sweep is the other half.\n await sweepStaleActiveEntries(discoveryOpts);\n },\n // Publish from `onSettled`, i.e. even when the pass above threw: the\n // data changed regardless of whether we managed to re-derive bells from\n // it, and a missed event leaves every open view silently stale. The slot\n // is the coalescing unit, so one burst still yields one publish.\n () => {\n safePublish({ slug, op: \"upsert\" });\n return Promise.resolve();\n },\n );\n}\n\n/** Test-only: feed one store-reported change through the same path a live\n * subscription uses, so a test can pin how a change is reacted to without\n * depending on fs.watch timing. */\nexport function _handleStoreChangeForTesting(slug: string, change: StoreChange): Promise<void> {\n return handleStoreChange(slug, change);\n}\n\n/** Test-only: drive one collection-granularity reconcile directly. */\nexport function _scheduleCollectionReconcileForTesting(slug: string): Promise<void> {\n return scheduleCollectionReconcile(slug);\n}\n\nexport function _scheduleItemReconcileForTesting(collection: LoadedCollection, itemId: string): Promise<void> {\n return scheduleItemReconcile(collection, itemId);\n}\n\nfunction scheduleItemReconcile(collection: LoadedCollection, itemId: string): Promise<void> {\n return runSingleFlight(\n itemSlots,\n `${collection.slug}\\x00${itemId}`,\n () => reconcileItem(collection, itemId, discoveryOpts),\n () => publishItemChange(collection, itemId),\n );\n}\n\n/** The shared single-flight loop behind both schedulers. Re-runs `pass`\n * while events keep arriving — the trailing re-run captures any state\n * change that landed during a prior pass. After each pass we read\n * `pending` and zero it before the next iteration, so an event that\n * fires *during* the last pass's await still triggers one more pass\n * before the slot is freed. `onSettled`, if given, runs once in the\n * `finally` after the slot is freed — the item scheduler uses it to\n * publish exactly one live-refresh event per burst. */\nfunction runSingleFlight(slots: Map<string, ReconcileSlot>, key: string, pass: () => Promise<void>, onSettled?: () => Promise<void>): Promise<void> {\n const existing = slots.get(key);\n if (existing) {\n existing.pending = true;\n return existing.running;\n }\n const slot: ReconcileSlot = { running: Promise.resolve(), pending: false };\n slot.running = (async () => {\n try {\n let keepGoing = true;\n while (keepGoing) {\n slot.pending = false;\n await pass();\n keepGoing = slot.pending;\n }\n } finally {\n slots.delete(key);\n // `onSettled` runs once after the slot is freed — the slot is the\n // coalescing primitive, so one burst yields one call. It's in the\n // `finally` because a failed reconcile still means a file changed,\n // and open views must be told to refetch either way.\n if (onSettled) await onSettled();\n }\n })();\n slots.set(key, slot);\n return slot.running;\n}\n\n/** Emit the live-refresh event for one record. `op` is derived from\n * whether the file is still there — `fs.watch` reports neither the kind\n * of change nor, reliably, which of `rename`/`change` means what. Only\n * file-backed collections reach here; a `storage` (db) collection has no\n * per-record file and publishes wholesale in `scheduleStorageReconcile`. */\nasync function publishItemChange(collection: LoadedCollection, itemId: string): Promise<void> {\n const changeOp = (await itemFileExists(collection.dataDir, itemId)) ? \"upsert\" : \"delete\";\n safePublish({ slug: collection.slug, ids: [itemId], op: changeOp });\n}\n\nasync function itemFileExists(dataDir: string, itemId: string): Promise<boolean> {\n try {\n await access(itemFilePath(dataDir, itemId));\n return true;\n } catch {\n return false;\n }\n}\n\n/** The publisher is host-supplied, so treat it as untrusted: a throw here\n * runs inside a `finally` and would mask the reconcile's own error. */\nfunction safePublish(payload: { slug: string; ids?: string[]; op?: \"upsert\" | \"delete\" }): void {\n try {\n publishCollectionChange(payload);\n } catch (err) {\n log().warn(\"collection change publish failed\", { slug: payload.slug, error: errMsg(err) });\n }\n}\n"],"mappings":";;;;;;;;AAuCA,IAAM,WAAoC;CAAE,YAAY,CAAC;CAAG,YAAY,CAAC;AAAE;AAE3E,IAAI,UAAgD;AACpD,IAAI,eAAwC;;;AAI5C,SAAgB,4BAA4B,QAAyF;CACnI,CAAC,CAAE,WAAY;CACf,eAAe,OAAO,OAAO;AAC/B;AAEA,SAAgB,iBAAgD;CAC9D,IAAI,CAAC,SAAS,MAAM,IAAI,MAAM,+DAA+D;CAC7F,OAAO;AACT;AAEA,SAAgB,MAA+B;CAC7C,OAAO;AACT;;AAGA,SAAgB,gCAAsC;CACpD,UAAU;CACV,eAAe;AACjB;AAEA,SAAgB,OAAO,KAAsB;CAC3C,OAAO,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;AACxD;;;AC7DA,SAAgB,UAAgB;CAC9B,MAAM,MAAM,QAAQ,IAAI;CACxB,IAAI,CAAC,KAAK,uBAAO,IAAI,KAAK;CAC1B,MAAM,SAAS,IAAI,KAAK,GAAG;CAC3B,OAAO,OAAO,MAAM,OAAO,QAAQ,CAAC,oBAAI,IAAI,KAAK,IAAI;AACvD;;;;;;AC4BA,IAAM,mBAAmB;;;;;AAMzB,SAAS,mBAAmB,MAAc,QAAwB;CAChE,OAAO,GAAG,mBAAmB,KAAK,GAAG;AACvC;;;AAIA,SAAS,wBAAwB,UAA2D;CAC1F,IAAI,CAAC,SAAS,WAAW,gBAAgB,GAAG,OAAO;CACnD,MAAM,OAAO,SAAS,MAAM,EAAuB;CACnD,MAAM,QAAQ,KAAK,QAAQ,GAAG;CAC9B,IAAI,QAAQ,GAAG,OAAO;CACtB,OAAO;EAAE,MAAM,KAAK,MAAM,GAAG,KAAK;EAAG,QAAQ,KAAK,MAAM,QAAQ,CAAC;CAAE;AACrE;;;;AAKA,SAAgB,oBAAoB,QAA0B,MAAsB,QAAwB;CAC1G,MAAM,EAAE,iBAAiB;CACzB,IAAI,CAAC,cAAc,OAAO;CAG1B,MAAM,QAAQ,UAAU,KAAK,aAAa,CAAC,CAAC,KAAK;CACjD,OAAO,MAAM,SAAS,IAAI,QAAQ;AACpC;;;;AAUA,eAAe,kBAAkB,MAAc,QAA0C;CACvF,MAAM,UAAU,eAAe;CAC/B,MAAM,WAAW,mBAAmB,MAAM,MAAM;CAEhD,QAAO,MADe,QAAQ,EAAA,CACf,QAAQ,UAAU,QAAQ,UAAU,MAAM,UAAU,CAAC,EAAE,aAAa,QAAQ;AAC7F;AAEA,eAAe,mBAAmB,MAAc,QAAmC;CACjF,QAAQ,MAAM,kBAAkB,MAAM,MAAM,EAAA,CAAG,KAAK,UAAU,MAAM,EAAE;AACxE;AAYA,IAAM,8BAAc,IAAI,IAAwB;;;;;AAMhD,SAAS,sBAAsB,QAA0B,MAA0C;CACjG,MAAM,OAAO,OAAO;CACpB,IAAI,CAAC,MAAM,OAAO;CAClB,MAAM,QAAQ,KAAK,KAAK,WAAW,KAAA,KAAa,KAAK,KAAK,WAAW,OAAO,KAAK,OAAO,KAAK,KAAK,MAAM;CACxG,OAAO,KAAK,GAAG,QAAQ,KAAK,MAAM,IAAI,SAAS;AACjD;AAEA,eAAe,uBACb,MACA,QACA,QACA,cACA,UACe;CACf,MAAM,WAAW,mBAAmB,MAAM,MAAM;CAKhD,OAAO,MAAM;EACX,MAAM,WAAW,YAAY,IAAI,QAAQ;EACzC,IAAI,CAAC,UAAU;EACf,MAAM,SAAS;CACjB;CACA,MAAM,OAAmB,EAAE,SAAS,yBAAyB,MAAM,QAAQ,QAAQ,UAAU,cAAc,QAAQ,EAAE;CACrH,YAAY,IAAI,UAAU,IAAI;CAC9B,IAAI;EACF,MAAM,KAAK;CACb,UAAU;EAGR,IAAI,YAAY,IAAI,QAAQ,MAAM,MAChC,YAAY,OAAO,QAAQ;CAE/B;AACF;;;;;AAMA,eAAe,uBAAuB,MAAc,QAAgB,SAA0B,UAA6C;CACzI,MAAM,UAAU,eAAe;CAC/B,KAAK,MAAM,SAAS,SAAS;EAC3B,MAAM,SAAS,QAAQ,UAAU,MAAM,UAAU;EACjD,IAAI,CAAC,UAAU,OAAO,aAAa,UAAU;EAC7C,MAAM,gBAAe,QAAQ,WAAW,MAAM,IAAI;GAChD,UAAU,QAAQ,mBAAmB,QAAQ;GAC7C,YAAY,QAAQ,gBAAgB;IAClC,UAAU,OAAO;IACjB;IACA;IACA;IACA,gBAAgB,QAAQ,oBAAoB,MAAM,MAAM;GAC1D,CAAC;EACH,CAAC;CACH;AACF;AAEA,eAAe,yBACb,MACA,QACA,QACA,UACA,cACA,UACe;CACf,MAAM,UAAU,eAAe;CAC/B,IAAI;EACF,MAAM,WAAW,MAAM,kBAAkB,MAAM,MAAM;EACrD,IAAI,SAAS,SAAS,GAAG;GACvB,MAAM,uBAAuB,MAAM,QAAQ,UAAU,QAAQ;GAC7D;EACF;EACA,MAAM,iBAAiB,QAAQ,oBAAoB,MAAM,MAAM;EAK/D,MAAM,QAAgB;GACpB,WAAW,QAAQ;GACnB,UAAU,QAAQ,mBAAmB,QAAQ;GAC7C,WAAW;GACX,OAAO,GAAG,OAAO,MAAM,IAAI;GAC3B;GACA,YAAY,QAAQ,gBAAgB;IAAE;IAAU;IAAM;IAAQ;IAAU;GAAe,CAAC;EAC1F,CAAC;CACH,SAAS,KAAK;EACZ,IAAI,CAAC,CAAC,KAAK,wBAAwB;GAAE;GAAM;GAAQ,OAAO,OAAO,GAAG;EAAE,CAAC;CACzE;AACF;;;;AAKA,eAAsB,sBAAsB,MAAc,QAA+B;CACvF,IAAI;EACF,MAAM,MAAM,MAAM,mBAAmB,MAAM,MAAM;EACjD,KAAK,MAAM,WAAW,KACpB,MAAM,MAAc,OAAO;CAE/B,SAAS,KAAK;EACZ,IAAI,CAAC,CAAC,KAAK,uBAAuB;GAAE;GAAM;GAAQ,OAAO,OAAO,GAAG;EAAE,CAAC;CACxE;AACF;;;;;;;;;AAUA,eAAsB,cAAc,YAA8B,QAAgB,SAAoB,CAAC,GAAG,MAAY,QAAQ,GAAkB;CAC9I,MAAM,EAAE,MAAM,WAAW;CACzB,IAAI,CAAC,OAAO,iBAAiB;EAE3B,MAAM,sBAAsB,MAAM,MAAM;EACxC;CACF;CACA,MAAM,OAAO,MAAM,SAAS,YAAY,MAAM,CAAC,CAAC,KAAK,MAAM;CAC3D,IAAI,SAAS,MAAM;EACjB,MAAM,sBAAsB,MAAM,MAAM;EACxC;CACF;CAIA,MAAM,oBAAoB,YAAY,MAAM,QAAQ,MAAM;CAC1D,IAAI,WAAW,QAAQ,IAAI,GAAG;EAC5B,MAAM,sBAAsB,MAAM,MAAM;EACxC;CACF;CAMA,IAAI,OAAO,cAAc;EACvB,MAAM,aAAa,KAAK,OAAO;EAC/B,MAAM,MAAM,aAAa,YAAY,KAAK,OAAO,eAAe;EAEhE,IAAI,QAAQ,QAAQ,EADJ,eAAe,KAAA,KAAa,eAAe,QAAQ,eAAe,KAEhF,IAAI,CAAC,CAAC,KAAK,8CAA8C;GAAE;GAAM;GAAQ,cAAc,OAAO;EAAa,CAAC;EAE9G,IAAI,QAAQ,MAAM;GAChB,MAAM,sBAAsB,MAAM,MAAM;GACxC;EACF;CACF;CAIA,IAAI,CAAC,YAAY,OAAO,YAAY,IAAI,GAAG;EACzC,MAAM,sBAAsB,MAAM,MAAM;EACxC;CACF;CACA,MAAM,uBAAuB,MAAM,QAAQ,QAAQ,oBAAoB,QAAQ,MAAM,MAAM,GAAG,sBAAsB,QAAQ,IAAI,CAAC;AACnI;;;;;;AAOA,eAAsB,kBAAkB,YAA8B,SAAoB,CAAC,GAAG,MAAY,QAAQ,GAAkB;CAClI,MAAM,EAAE,MAAM,WAAW;CACzB,IAAI,CAAC,OAAO,iBAAiB;CAC7B,IAAI;CACJ,IAAI;EACF,QAAQ,MAAM,SAAS,YAAY,MAAM,CAAC,CAAC,KAAK;CAClD,SAAS,KAAK;EACZ,IAAI,CAAC,CAAC,KAAK,yBAAyB;GAAE;GAAM,OAAO,OAAO,GAAG;EAAE,CAAC;EAChE;CACF;CACA,MAAM,EAAE,eAAe;CACvB,KAAK,MAAM,QAAQ,OAAO;EACxB,MAAM,MAAM,KAAK;EACjB,IAAI,OAAO,QAAQ,YAAY,IAAI,WAAW,GAAG;EACjD,MAAM,cAAc,YAAY,KAAK,QAAQ,GAAG;CAClD;AACF;;;;;AAMA,eAAsB,wBAAwB,OAAyB,CAAC,GAAkB;CACxF,MAAM,UAAU,eAAe;CAC/B,IAAI;CACJ,IAAI;EACF,UAAU,MAAM,QAAQ;CAC1B,SAAS,KAAK;EACZ,IAAI,CAAC,CAAC,KAAK,qBAAqB,EAAE,OAAO,OAAO,GAAG,EAAE,CAAC;EACtD;CACF;CACA,KAAK,MAAM,SAAS,SAAS;EAC3B,MAAM,MAAM,QAAQ,UAAU,MAAM,UAAU;EAC9C,IAAI,CAAC,KAAK;EACV,MAAM,SAAS,wBAAwB,IAAI,QAAQ;EACnD,IAAI,CAAC,QAAQ;EACb,MAAM,EAAE,MAAM,WAAW;EACzB,IAAI;GACF,MAAM,aAAa,MAAM,eAAe,MAAM,IAAI;GAClD,IAAI,CAAC,cAAc,CAAC,WAAW,OAAO,iBAAiB;IACrD,MAAM,MAAc,MAAM,EAAE;IAC5B;GACF;GACA,MAAM,OAAO,MAAM,SAAS,YAAY,IAAI,CAAC,CAAC,KAAK,MAAM;GACzD,IAAI,SAAS,QAAQ,WAAW,WAAW,QAAQ,IAAI,KAAK,CAAC,YAAY,WAAW,OAAO,YAAY,IAAI,GACzG,MAAM,MAAc,MAAM,EAAE;EAEhC,SAAS,KAAK;GACZ,IAAI,CAAC,CAAC,KAAK,sBAAsB;IAAE;IAAM;IAAQ,OAAO,OAAO,GAAG;GAAE,CAAC;EACvE;CACF;AACF;;AAGA,SAAgB,kCAAwC;CACtD,YAAY,MAAM;AACpB;;;AC1QA,IAAM,gBAAgB;AACtB,IAAM,gBAAgB,KAAK;AAC3B,IAAM,0BAA0B,KAAK;AAQrC,IAAM,2BAA2B;AAqBjC,IAAM,2BAAW,IAAI,IAA+B;AACpD,IAAI,mBAA0D;AAC9D,IAAI,eAAsD;AAC1D,IAAI,UAAU;;;;;;;AAOd,IAAI,qBAAqB;AACzB,IAAI,eAAe;AACnB,IAAI,sBAA4C;;;;;;AAMhD,IAAI,gBAAkC,CAAC;AAQvC,IAAM,4BAAY,IAAI,IAA2B;;;;AAKjD,IAAM,kCAAkB,IAAI,IAA2B;;;;AAevD,eAAsB,wBAAwB,OAAiC,CAAC,GAAkB;CAChG,IAAI,SAAS;CAIb,gBAAgB,KAAK,iBAAiB,CAAC;CACvC,IAAI;EAKF,MAAM,wBAAwB,aAAa;EAC3C,MAAM,aAAa;EACnB,MAAM,aAAa,KAAK,0BAA0B,KAAA,IAAY,0BAA0B,KAAK;EAC7F,IAAI,eAAe,MAAM;GACvB,mBAAmB,kBAAkB;IACnC,aAAa,CAAC,CAAC,OAAO,QAAiB;KACrC,IAAI,CAAC,CAAC,KAAK,8BAA8B,EAAE,OAAO,OAAO,GAAG,EAAE,CAAC;IACjE,CAAC;GACH,GAAG,UAAU;GAEb,iBAAiB,MAAM;EACzB;EACA,MAAM,YAAY,KAAK,0BAA0B,KAAA,IAAY,2BAA2B,KAAK;EAC7F,IAAI,cAAc,MAAM;GACtB,eAAe,kBAAkB;IAI/B,IAAI,oBAAoB;IACxB,qBAAqB;IACrB,MAAM,QAAQ;IACd,sBAAsB,iBAAiB,CAAC,CACrC,OAAO,QAAiB;KACvB,IAAI,CAAC,CAAC,KAAK,+BAA+B,EAAE,OAAO,OAAO,GAAG,EAAE,CAAC;IAClE,CAAC,CAAC,CACD,cAAc;KAEb,IAAI,UAAU,cAAc;KAC5B,qBAAqB;KACrB,sBAAsB;IACxB,CAAC;GACL,GAAG,SAAS;GACZ,aAAa,MAAM;EACrB;EACA,UAAU;CACZ,SAAS,KAAK;EACZ,gBAAgB,CAAC;EACjB,MAAM;CACR;AACF;;;;AAKA,eAAsB,yBAAwC;CAC5D,IAAI,kBAAkB;EACpB,cAAc,gBAAgB;EAC9B,mBAAmB;CACrB;CACA,IAAI,cAAc;EAChB,cAAc,YAAY;EAC1B,eAAe;CACjB;CAIA,MAAM;CACN,sBAAsB;CAGtB,gBAAgB;CAChB,qBAAqB;CACrB,KAAK,MAAM,WAAW,SAAS,OAAO,GACpC,IAAI;EACF,QAAQ,YAAY;CACtB,QAAQ,CAER;CAEF,SAAS,MAAM;CACf,UAAU,MAAM;CAChB,gBAAgB,MAAM;CACtB,gBAAgB,CAAC;CACjB,UAAU;AACZ;;AAGA,eAAsB,0BAA4C;CAChE,OAAO,aAAa;AACtB;;;AAIA,eAAsB,4BAA4B,KAA2B;CAC3E,MAAM,iBAAiB,GAAG;AAC5B;;;;;;AAOA,eAAe,iBAAiB,MAAY,QAAQ,GAAkB;CACpE,KAAK,MAAM,SAAS,SAAS,OAAO,GAAG;EACrC,IAAI;EACJ,IAAI;GACF,SAAS,KAAK,MAAM,MAAM,UAAU;EACtC,SAAS,KAAK;GACZ,IAAI,CAAC,CAAC,KAAK,mCAAmC;IAAE,MAAM,MAAM;IAAM,OAAO,OAAO,GAAG;GAAE,CAAC;GACtF;EACF;EAMA,IAAI,CAAC,OAAO,gBAAgB,CAAC,OAAO,OAAO;EAC3C,MAAM,kBAAkB,MAAM,YAAY,eAAe,GAAG;CAC9D;AACF;;;;;;AAOA,eAAe,eAAiC;CAC9C,IAAI;CACJ,IAAI;EACF,cAAc,MAAM,oBAAoB,aAAa;CACvD,SAAS,KAAK;EACZ,IAAI,CAAC,CAAC,KAAK,2BAA2B,EAAE,OAAO,OAAO,GAAG,EAAE,CAAC;EAC5D,OAAO;CACT;CAEA,MAAM,kBAAkB,qBAAqB,IADvB,IAAI,YAAY,KAAK,eAAe,WAAW,IAAI,CAC5B,CAAS;CACtD,MAAM,gBAAgB,MAAM,wBAAwB,WAAW;CAC/D,MAAM,eAAe,MAAM,iBAAiB,WAAW;CACvD,IAAI,CAAC,mBAAmB,CAAC,iBAAiB,CAAC,cAAc,OAAO;CAChE,MAAM,wBAAwB,aAAa;CAC3C,OAAO;AACT;AAEA,SAAS,qBAAqB,WAAiC;CAC7D,IAAI,UAAU;CACd,KAAK,MAAM,QAAQ,CAAC,GAAG,SAAS,KAAK,CAAC,GAAG;EACvC,IAAI,UAAU,IAAI,IAAI,GAAG;EACzB,MAAM,UAAU,SAAS,IAAI,IAAI;EACjC,IAAI,SACF,IAAI;GACF,QAAQ,YAAY;EACtB,QAAQ,CAER;EAEF,SAAS,OAAO,IAAI;EACpB,UAAU;EACV,IAAI,CAAC,CAAC,KAAK,mBAAmB,EAAE,KAAK,CAAC;CACxC;CACA,OAAO;AACT;;;;;AAMA,SAAS,mBAAmB,cAAsB,MAA2C;CAC3F,IAAI;CACJ,IAAI;EACF,WAAW,KAAK,MAAM,YAAY;CACpC,QAAQ;EACN,OAAO;CACT;CACA,OAAO,SAAS,YAAY,SAAS,KAAK,YAAY,QAAQ,SAAS,aAAa,KAAK,YAAY,SAAS,SAAS,SAAS,KAAK,SAAS;AAChJ;;;AAIA,eAAe,wBAAwB,aAA4D;CACjG,IAAI,UAAU;CACd,KAAK,MAAM,cAAc,aAAa;EACpC,MAAM,WAAW,SAAS,IAAI,WAAW,IAAI;EAC7C,IAAI,CAAC,UAAU;EACf,MAAM,WAAW,KAAK,UAAU,WAAW,MAAM;EACjD,IAAI,SAAS,eAAe,UAAU;EACtC,IAAI,mBAAmB,SAAS,YAAY,WAAW,MAAM,GAAG;GAK9D,IAAI,CAAC,CAAC,KAAK,4CAA4C,EAAE,MAAM,WAAW,KAAK,CAAC;GAChF,IAAI;IACF,SAAS,YAAY;GACvB,QAAQ,CAER;GACA,SAAS,OAAO,WAAW,IAAI;GAC/B,IAAI,WAAW,OAAO,eAAe,KAAA,GAAW,wBAAwB;IAAE,MAAM,WAAW;IAAM,IAAI;GAAS,CAAC;GAC/G,UAAU;GACV;EACF;EACA,SAAS,aAAa;EACtB,SAAS,aAAa;EACtB,IAAI,CAAC,CAAC,KAAK,0CAA0C,EAAE,MAAM,WAAW,KAAK,CAAC;EAO9E,MAAM,kBAAkB,YAAY,aAAa;EACjD,IAAI,WAAW,OAAO,eAAe,KAAA,GAGnC,wBAAwB;GAAE,MAAM,WAAW;GAAM,IAAI;EAAS,CAAC;EAEjE,UAAU;CACZ;CACA,OAAO;AACT;;;;;;;AAQA,eAAe,iBAAiB,aAA4D;CAC1F,IAAI,UAAU;CACd,KAAK,MAAM,cAAc,aAAa;EACpC,IAAI,SAAS,IAAI,WAAW,IAAI,GAAG;EACnC,IAAI,MAAM,gBAAgB,UAAU,GAAG,UAAU;CACnD;CACA,OAAO;AACT;;;;;;;;;;AAWA,eAAe,gBAAgB,YAAgD;CAC7E,MAAM,EAAE,SAAS;CACjB,IAAI;EAGF,MAAM,kBAAkB,YAAY,aAAa;EACjD,MAAM,QAAQ,SAAS,YAAY,aAAa;EAChD,MAAM,cAAc,MAAM,QACtB,MAAM,MAAM,OAAO,WAAW;GAC5B,kBAAuB,MAAM,MAAM,CAAC,CAAC,OAAO,QAAiB;IAC3D,IAAI,CAAC,CAAC,KAAK,gCAAgC;KAAE;KAAM,OAAO,OAAO,GAAG;IAAE,CAAC;GACzE,CAAC;EACH,CAAC,UACK,CAAC;EAMX,IAAI,gBAAgB,MAAM;GACxB,IAAI,CAAC,CAAC,KAAK,wDAAwD,EAAE,KAAK,CAAC;GAC3E,OAAO;EACT;EACA,SAAS,IAAI,MAAM;GAAE;GAAM,SAAS,WAAW;GAAS;GAAa,YAAY,KAAK,UAAU,WAAW,MAAM;GAAG;EAAW,CAAC;EAChI,IAAI,CAAC,CAAC,KAAK,8BAA8B;GAAE;GAAM,MAAM,MAAM,UAAU,KAAA;EAAU,CAAC;EAClF,OAAO;CACT,SAAS,KAAK;EACZ,IAAI,CAAC,CAAC,KAAK,mCAAmC;GAAE;GAAM,OAAO,OAAO,GAAG;EAAE,CAAC;EAC1E,OAAO;CACT;AACF;;;;;;;;AASA,eAAe,kBAAkB,MAAc,QAAoC;CACjF,IAAI,OAAO,SAAS,cAAc;EAChC,MAAM,4BAA4B,IAAI;EACtC;CACF;CAOA,MAAM,UAAU,SAAS,IAAI,IAAI,CAAC,EAAE;CACpC,IAAI,CAAC,SAAS;CACd,MAAM,sBAAsB,SAAS,OAAO,MAAM;AACpD;;;;AAKA,SAAS,4BAA4B,MAA6B;CAChE,OAAO,gBACL,iBACA,MACA,YAAY;EACV,MAAM,aAAa,MAAM,eAAe,MAAM,aAAa;EAC3D,IAAI,CAAC,YAAY;EACjB,MAAM,kBAAkB,YAAY,aAAa;EAGjD,MAAM,wBAAwB,aAAa;CAC7C,SAKM;EACJ,YAAY;GAAE;GAAM,IAAI;EAAS,CAAC;EAClC,OAAO,QAAQ,QAAQ;CACzB,CACF;AACF;;;;AAKA,SAAgB,6BAA6B,MAAc,QAAoC;CAC7F,OAAO,kBAAkB,MAAM,MAAM;AACvC;;AAGA,SAAgB,uCAAuC,MAA6B;CAClF,OAAO,4BAA4B,IAAI;AACzC;AAEA,SAAgB,iCAAiC,YAA8B,QAA+B;CAC5G,OAAO,sBAAsB,YAAY,MAAM;AACjD;AAEA,SAAS,sBAAsB,YAA8B,QAA+B;CAC1F,OAAO,gBACL,WACA,GAAG,WAAW,KAAK,MAAM,gBACnB,cAAc,YAAY,QAAQ,aAAa,SAC/C,kBAAkB,YAAY,MAAM,CAC5C;AACF;;;;;;;;;AAUA,SAAS,gBAAgB,OAAmC,KAAa,MAA2B,WAAgD;CAClJ,MAAM,WAAW,MAAM,IAAI,GAAG;CAC9B,IAAI,UAAU;EACZ,SAAS,UAAU;EACnB,OAAO,SAAS;CAClB;CACA,MAAM,OAAsB;EAAE,SAAS,QAAQ,QAAQ;EAAG,SAAS;CAAM;CACzE,KAAK,WAAW,YAAY;EAC1B,IAAI;GACF,IAAI,YAAY;GAChB,OAAO,WAAW;IAChB,KAAK,UAAU;IACf,MAAM,KAAK;IACX,YAAY,KAAK;GACnB;EACF,UAAU;GACR,MAAM,OAAO,GAAG;GAKhB,IAAI,WAAW,MAAM,UAAU;EACjC;CACF,EAAA,CAAG;CACH,MAAM,IAAI,KAAK,IAAI;CACnB,OAAO,KAAK;AACd;;;;;;AAOA,eAAe,kBAAkB,YAA8B,QAA+B;CAC5F,MAAM,WAAY,MAAM,eAAe,WAAW,SAAS,MAAM,IAAK,WAAW;CACjF,YAAY;EAAE,MAAM,WAAW;EAAM,KAAK,CAAC,MAAM;EAAG,IAAI;CAAS,CAAC;AACpE;AAEA,eAAe,eAAe,SAAiB,QAAkC;CAC/E,IAAI;EACF,MAAM,OAAO,aAAa,SAAS,MAAM,CAAC;EAC1C,OAAO;CACT,QAAQ;EACN,OAAO;CACT;AACF;;;AAIA,SAAS,YAAY,SAA2E;CAC9F,IAAI;EACF,wBAAwB,OAAO;CACjC,SAAS,KAAK;EACZ,IAAI,CAAC,CAAC,KAAK,oCAAoC;GAAE,MAAM,QAAQ;GAAM,OAAO,OAAO,GAAG;EAAE,CAAC;CAC3F;AACF"}
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../../src/collection-watchers/config.ts","../../src/collection-watchers/clock.ts","../../src/collection-watchers/reconciler.ts","../../src/collection-watchers/watcher.ts"],"sourcesContent":["// Host-injected configuration for the collection-completion watchers.\n// The reconciler logic + watcher plumbing are host-agnostic; the\n// notification TAXONOMY (which plugin namespace, how a record priority\n// maps to a bell severity) and the in-app ROUTING (the deep-link a bell\n// row navigates to) are host-specific, so the host supplies them via an\n// adapter. MulmoClaude wires its legacy notification machinery; a future\n// MulmoTerminal wires its own routes + pluginData shape.\n\nimport type { MinimalLogger } from \"@mulmoclaude/common\";\nimport type { NotifierSeverity } from \"../notifier\";\n\n/** Two-level urgency a pending record can carry, derived from the\n * schema's `notifyWhen` spec. The host maps this onto its own severity\n * scale via `priorityToSeverity`. */\nexport type CompletionPriority = \"normal\" | \"high\";\n\nexport type CollectionWatcherLogger = Pick<MinimalLogger, \"info\" | \"warn\">;\n\n/** The host-specific notification surface the reconciler binds to. The\n * reconciler owns the internal `legacyId` key (it encodes slug+itemId\n * and round-trips it through `pluginData`); the adapter only wraps /\n * unwraps it into whatever shape the host's bell expects. */\nexport interface CollectionNotificationAdapter {\n /** Plugin namespace these bell entries publish under (MulmoClaude: \"todo\"). */\n pluginPkg: string;\n /** Map a record's completion priority onto the host's bell severity. */\n priorityToSeverity: (priority: CompletionPriority) => NotifierSeverity;\n /** Build the in-app deep-link the bell row routes to on click. */\n buildNavigateTarget: (slug: string, itemId: string) => string;\n /** Wrap the reconciler's internal key + priority into the host's\n * `pluginData` shape. Stored verbatim on the entry; recovered via\n * `readEntry`. */\n buildPluginData: (input: { legacyId: string; slug: string; itemId: string; priority: CompletionPriority; navigateTarget: string }) => unknown;\n /** Recognise a bell entry produced by this reconciler and recover its\n * internal key + stored priority. Returns null for entries that didn't\n * originate here, so `listAll()` scans skip foreign entries. */\n readEntry: (pluginData: unknown) => { legacyId: string; priority: CompletionPriority } | null;\n}\n\nconst NOOP_LOG: CollectionWatcherLogger = { info: () => {}, warn: () => {} };\n\nlet adapter: CollectionNotificationAdapter | null = null;\nlet activeLogger: CollectionWatcherLogger = NOOP_LOG;\n\n/** Wire the host adapter + logger. Call once at startup, before\n * `startCollectionWatchers` or any direct reconcile call. */\nexport function configureCollectionWatchers(config: { adapter: CollectionNotificationAdapter; log?: CollectionWatcherLogger }): void {\n ({ adapter } = config);\n activeLogger = config.log ?? NOOP_LOG;\n}\n\nexport function requireAdapter(): CollectionNotificationAdapter {\n if (!adapter) throw new Error(\"collection-watchers: configureCollectionWatchers() not called\");\n return adapter;\n}\n\nexport function log(): CollectionWatcherLogger {\n return activeLogger;\n}\n\n/** Test-only: clear the host wiring. */\nexport function resetCollectionWatchersConfig(): void {\n adapter = null;\n activeLogger = NOOP_LOG;\n}\n\nexport function errMsg(err: unknown): string {\n return err instanceof Error ? err.message : String(err);\n}\n","// Evaluation-only injectable clock. When MULMOCLAUDE_FAKE_NOW is set to\n// a parseable date/datetime string, every reconcile pass derives \"now\"\n// from it instead of the wall clock — letting evaluation runs and tests\n// advance time deterministically (a `triggerField` coming due, a `spawn`\n// successor's own trigger firing later) without waiting for real days to\n// pass. Unset or unparseable (i.e. normal operation) → real wall clock.\n// Read per call, so a long-lived server picks up changes without restart.\nexport function evalNow(): Date {\n const raw = process.env.MULMOCLAUDE_FAKE_NOW;\n if (!raw) return new Date();\n const parsed = new Date(raw);\n return Number.isNaN(parsed.getTime()) ? new Date() : parsed;\n}\n","// Bell-notification reconciler for collections whose schema declares\n// `completionField`. Driven by `watcher.ts`, which calls into the\n// functions below on file-system events and on boot.\n//\n// The model is **convergent**: a watcher event re-reads the record from\n// disk and the reconciler enforces the invariant\n//\n// bell entry exists for (slug, itemId) ↔\n// schema has completionField ∧\n// file exists ∧\n// `String(item[completionField])` ∉ completionDoneValues\n// (∧ trigger due ∧ notifyWhen matches, when declared)\n//\n// Each reconcile is idempotent (`ensure*` / `clear*` no-op when state\n// already matches). This is why event-type quirks of `fs.watch`\n// (`rename` vs `change`, missed events, atomic-write coalescence) don't\n// matter — every event re-derives the desired state from the file.\n//\n// Lookup uses a deterministic internal `legacyId` derived from\n// `<slug>:<itemId>`, stashed on each entry's `pluginData` via the host\n// adapter, so the clear path and the dedup check both find the entry\n// without a side state file.\n\nimport { clear as notifierClear, listAll, publish as notifierPublish, updateForPlugin as notifierUpdate, type NotifierEntry } from \"../notifier\";\nimport { fieldText, itemIsDone, whenMatches, type CollectionItem, type CollectionSchema } from \"../collection\";\nimport {\n type DiscoveryOptions,\n type IoOptions,\n isTriggerDue,\n maybeSpawnSuccessor,\n loadCollection,\n storeFor,\n type LoadedCollection,\n} from \"../collection/server\";\nimport { type CompletionPriority, errMsg, log, requireAdapter } from \"./config.js\";\nimport { evalNow } from \"./clock.js\";\n\n/** The internal-id prefix every collection-completion bell entry carries.\n * Used both to build new keys and to filter sweep candidates from the\n * active bell. */\nconst LEGACY_ID_PREFIX = \"collection-completion:\";\n\n/** Stable key encoding slug + item, round-tripped through the entry's\n * `pluginData` so we can find it later without a side state file. Slug +\n * itemId are upstream-validated via `safeSlugName`, which forbids the\n * colon separator, so the two-segment parse below is unambiguous. */\nfunction completionLegacyId(slug: string, itemId: string): string {\n return `${LEGACY_ID_PREFIX}${slug}:${itemId}`;\n}\n\n/** Decode a key back into its (slug, itemId) pair, or null if the string\n * didn't originate from this module. Used by the sweep step. */\nfunction parseCompletionLegacyId(legacyId: string): { slug: string; itemId: string } | null {\n if (!legacyId.startsWith(LEGACY_ID_PREFIX)) return null;\n const body = legacyId.slice(LEGACY_ID_PREFIX.length);\n const colon = body.indexOf(\":\");\n if (colon < 0) return null;\n return { slug: body.slice(0, colon), itemId: body.slice(colon + 1) };\n}\n\n/** The human-readable label shown in a completion notification's title.\n * Uses the schema's `displayField` value when declared and non-empty;\n * otherwise falls back to the record's primaryKey (`itemId`). */\nexport function resolveDisplayLabel(schema: CollectionSchema, item: CollectionItem, itemId: string): string {\n const { displayField } = schema;\n if (!displayField) return itemId;\n // Same rule as `itemLabelOf`: an array/object display field falls back to\n // the id rather than labelling the record \"[object Object]\".\n const label = fieldText(item[displayField]).trim();\n return label.length > 0 ? label : itemId;\n}\n\n// The done predicate moved to `../collection/core/completion` (flag-aware,\n// browser-safe, shared with spawn and view filters). Re-exported so the\n// `collection-watchers` barrel keeps its public surface (MulmoTerminal).\nexport { itemIsDone };\n\n/** Every active bell entry whose key matches this (slug, itemId).\n * Returns multiple when defensive cleanup is needed. Scans `listAll()`\n * — cheap because the active set is bounded. */\nasync function findActiveEntries(slug: string, itemId: string): Promise<NotifierEntry[]> {\n const adapter = requireAdapter();\n const legacyId = completionLegacyId(slug, itemId);\n const entries = await listAll();\n return entries.filter((entry) => adapter.readEntry(entry.pluginData)?.legacyId === legacyId);\n}\n\nasync function findActiveEntryIds(slug: string, itemId: string): Promise<string[]> {\n return (await findActiveEntries(slug, itemId)).map((entry) => entry.id);\n}\n\n/** Per-key in-flight lock. Serializes concurrent `ensureItemNotification`\n * calls for the same (slug, itemId) so the `findActiveEntries → publish`\n * check stays atomic across callers — not just across watcher events.\n * `listAll` bypasses the engine's write queue, so without this lock two\n * reconcile paths (a watcher event + a `reconcileAllItems` pass that a\n * readdir-triggered event raced) could both miss each other's in-flight\n * publish and produce duplicate entries. */\ninterface EnsureLock {\n promise: Promise<void>;\n}\nconst ensureLocks = new Map<string, EnsureLock>();\n\n/** Bell priority for a record: the FIRST flagged value in `notifyWhen.in`\n * (most urgent) reads `high`, every other flagged value `normal`.\n * Collections with no `notifyWhen` (notify for every open record) stay\n * `normal`. */\nfunction notifyPriorityForItem(schema: CollectionSchema, item: CollectionItem): CompletionPriority {\n const spec = schema.notifyWhen;\n if (!spec) return \"normal\";\n const value = item[spec.field] === undefined || item[spec.field] === null ? \"\" : String(item[spec.field]);\n return spec.in.indexOf(value) === 0 ? \"high\" : \"normal\";\n}\n\nasync function ensureItemNotification(\n slug: string,\n schema: CollectionSchema,\n itemId: string,\n displayLabel: string,\n priority: CompletionPriority,\n): Promise<void> {\n const legacyId = completionLegacyId(slug, itemId);\n // Drain any in-flight publish for this key BEFORE our check + set. The\n // drain + claim runs synchronously between `ensureLocks.get` and\n // `ensureLocks.set`, so two callers can't both observe an empty slot.\n\n while (true) {\n const inflight = ensureLocks.get(legacyId);\n if (!inflight) break;\n await inflight.promise;\n }\n const lock: EnsureLock = { promise: doEnsureItemNotification(slug, schema, itemId, legacyId, displayLabel, priority) };\n ensureLocks.set(legacyId, lock);\n try {\n await lock.promise;\n } finally {\n // Only clear the slot if it still points at OUR lock — a\n // sufficiently-delayed cleanup must not stomp a later claim.\n if (ensureLocks.get(legacyId) === lock) {\n ensureLocks.delete(legacyId);\n }\n }\n}\n\n/** Converge any already-present bell entries to `priority`, updating in\n * place (preserving id / position / createdAt) so a record whose flagged\n * value changed while it stayed pending re-colours the bell without a\n * clear+republish flicker. No-op when the stored priority already matches. */\nasync function reconcileEntrySeverity(slug: string, itemId: string, entries: NotifierEntry[], priority: CompletionPriority): Promise<void> {\n const adapter = requireAdapter();\n for (const entry of entries) {\n const parsed = adapter.readEntry(entry.pluginData);\n if (!parsed || parsed.priority === priority) continue;\n await notifierUpdate(adapter.pluginPkg, entry.id, {\n severity: adapter.priorityToSeverity(priority),\n pluginData: adapter.buildPluginData({\n legacyId: parsed.legacyId,\n slug,\n itemId,\n priority,\n navigateTarget: adapter.buildNavigateTarget(slug, itemId),\n }),\n });\n }\n}\n\nasync function doEnsureItemNotification(\n slug: string,\n schema: CollectionSchema,\n itemId: string,\n legacyId: string,\n displayLabel: string,\n priority: CompletionPriority,\n): Promise<void> {\n const adapter = requireAdapter();\n try {\n const existing = await findActiveEntries(slug, itemId);\n if (existing.length > 0) {\n await reconcileEntrySeverity(slug, itemId, existing, priority);\n return;\n }\n const navigateTarget = adapter.buildNavigateTarget(slug, itemId);\n // `lifecycle: \"action\"` — these are state-of-the-world entries\n // mirroring an outstanding obligation (the item is pending), not\n // transient pings. Validation requires a non-info severity and a\n // non-empty `navigateTarget` (the slug + itemId deep-link).\n await notifierPublish({\n pluginPkg: adapter.pluginPkg,\n severity: adapter.priorityToSeverity(priority),\n lifecycle: \"action\",\n title: `${schema.title}: ${displayLabel}`,\n navigateTarget,\n pluginData: adapter.buildPluginData({ legacyId, slug, itemId, priority, navigateTarget }),\n });\n } catch (err) {\n log().warn(\"notify ensure failed\", { slug, itemId, error: errMsg(err) });\n }\n}\n\n/** Idempotently clear EVERY bell entry that matches this (slug, itemId).\n * Silent no-op when nothing matches. The \"every\" is defensive: if a\n * duplicate ever slips through, this drains the lot. */\nexport async function clearItemNotification(slug: string, itemId: string): Promise<void> {\n try {\n const ids = await findActiveEntryIds(slug, itemId);\n for (const entryId of ids) {\n await notifierClear(entryId);\n }\n } catch (err) {\n log().warn(\"notify clear failed\", { slug, itemId, error: errMsg(err) });\n }\n}\n\n/** Reconcile one item to the desired bell state. Re-reads the record\n * through the collection's STORE (file, sqlite, …) so the decision is\n * grounded in current truth, not in the event payload. Safe to call when\n * the record is missing (delete path).\n *\n * `ioOpts` flows into the store's workspace-containment checks —\n * production callers (the watcher) pass nothing; tests pass\n * `{ workspaceRoot: <tmpdir> }` so the check accepts a fixture dataDir. */\nexport async function reconcileItem(collection: LoadedCollection, itemId: string, ioOpts: IoOptions = {}, now: Date = evalNow()): Promise<void> {\n const { slug, schema } = collection;\n if (!schema.completionField) {\n // Schema doesn't track completion — drop any stale entry.\n await clearItemNotification(slug, itemId);\n return;\n }\n const item = await storeFor(collection, ioOpts).read(itemId);\n if (item === null) {\n await clearItemNotification(slug, itemId);\n return;\n }\n // Recurrence: predicate-gated + create-if-absent, idempotent and\n // independent of this item's own bell state. Runs before the done-clear\n // below so marking an item done still spawns its successor.\n await maybeSpawnSuccessor(collection, item, itemId, ioOpts);\n if (itemIsDone(schema, item)) {\n await clearItemNotification(slug, itemId);\n return;\n }\n // Time gate: when the schema declares `triggerField`, suppress the bell\n // until the clock reaches that date (minus `triggerLeadDays`).\n // Unparseable date ⇒ fail safe (no bell); warn ONLY when the field carries\n // a non-empty value that won't parse — an empty optional trigger date is a\n // normal state and must not spam a WARN every reconcile tick.\n if (schema.triggerField) {\n const triggerRaw = item[schema.triggerField];\n const due = isTriggerDue(triggerRaw, now, schema.triggerLeadDays);\n const isEmpty = triggerRaw === undefined || triggerRaw === null || triggerRaw === \"\";\n if (due === null && !isEmpty) {\n log().warn(\"trigger date unparseable, suppressing bell\", { slug, itemId, triggerField: schema.triggerField });\n }\n if (due !== true) {\n await clearItemNotification(slug, itemId);\n return;\n }\n }\n // Condition gate: when the schema declares `notifyWhen`, only bell\n // records matching the predicate. Convergent — a record that stops\n // matching has its bell cleared.\n if (!whenMatches(schema.notifyWhen, item)) {\n await clearItemNotification(slug, itemId);\n return;\n }\n await ensureItemNotification(slug, schema, itemId, resolveDisplayLabel(schema, item, itemId), notifyPriorityForItem(schema, item));\n}\n\n/** Boot-time reconcile: walk every record of the collection once (through\n * its store) and reconcile it. Catches up changes that happened while the\n * server was down. Deleted items are covered by\n * `sweepStaleActiveEntries`, not this function (it only sees records\n * that exist). */\nexport async function reconcileAllItems(collection: LoadedCollection, ioOpts: IoOptions = {}, now: Date = evalNow()): Promise<void> {\n const { slug, schema } = collection;\n if (!schema.completionField) return;\n let items: CollectionItem[];\n try {\n items = await storeFor(collection, ioOpts).list();\n } catch (err) {\n log().warn(\"reconcile list failed\", { slug, error: errMsg(err) });\n return;\n }\n const { primaryKey } = schema;\n for (const item of items) {\n const raw = item[primaryKey];\n if (typeof raw !== \"string\" || raw.length === 0) continue;\n await reconcileItem(collection, raw, ioOpts, now);\n }\n}\n\n/** Boot-time sweep over the active bell: drop any entries whose underlying\n * file is gone, whose collection was deleted, whose schema no longer\n * tracks completion, or whose item is now done. Reverse-covers the cases\n * `reconcileAllItems` misses (it only walks files that exist). */\nexport async function sweepStaleActiveEntries(opts: DiscoveryOptions = {}): Promise<void> {\n const adapter = requireAdapter();\n let entries;\n try {\n entries = await listAll();\n } catch (err) {\n log().warn(\"sweep list failed\", { error: errMsg(err) });\n return;\n }\n for (const entry of entries) {\n const own = adapter.readEntry(entry.pluginData);\n if (!own) continue;\n const parsed = parseCompletionLegacyId(own.legacyId);\n if (!parsed) continue;\n const { slug, itemId } = parsed;\n try {\n const collection = await loadCollection(slug, opts);\n if (!collection || !collection.schema.completionField) {\n await notifierClear(entry.id);\n continue;\n }\n const item = await storeFor(collection, opts).read(itemId);\n if (item === null || itemIsDone(collection.schema, item) || !whenMatches(collection.schema.notifyWhen, item)) {\n await notifierClear(entry.id);\n }\n } catch (err) {\n log().warn(\"sweep entry failed\", { slug, itemId, error: errMsg(err) });\n }\n }\n}\n\n/** Test-only: clear the per-key in-flight locks. */\nexport function _resetReconcilerLocksForTesting(): void {\n ensureLocks.clear();\n}\n","// Filesystem watchers that drive collection-completion bell\n// notifications AND the live-refresh change event. One `fs.watch` per\n// discovered collection's `dataDir`, fanned out from a single boot call\n// + a 30-second re-discovery interval that catches newly-created /\n// deleted collections (there is no in-process \"collections changed\"\n// event broadcast).\n//\n// Why a watcher, not just route hooks: the canonical pattern for\n// collection-skills has the agent Write records directly with the Write\n// tool — that path never hits the REST API, so a route-level hook would\n// miss most of the traffic the user generates. The watcher catches every\n// mutation regardless of who wrote the file.\n//\n// That same reasoning is why the watcher publishes change events: a\n// write through `io.ts` publishes its own (immediately, and reliably\n// even where fs.watch is unavailable), but a direct file write has no\n// other producer, so open views would never refresh. Both producers\n// firing for one `io.ts` write is intentional — the payload carries no\n// bodies, so a duplicate costs one redundant refetch, whereas a missed\n// event leaves the UI silently stale.\n//\n// FOUR paths can re-derive a collection's bells, and every backend —\n// including read-only `dataSource` — must be covered by all four. They are\n// listed because they were NOT: this module grew up when only JSON records\n// reconciled, so each path had its own `dataSource` short-circuit, and\n// routing every backend through the store contract left the short-circuits\n// behind. Three of the four were separate live bugs (PR #2243 review).\n//\n// 1. mount — `startWatcherFor`, boot + every remount\n// 2. store change — `handleStoreChange`, the backend reported bytes moved\n// 3. schema change — `reconcileChangedSchemas`, the RULES moved instead\n// 4. clock tick — `tickTimeTriggers`, `triggerField` came due\n//\n// 3 and 4 are the ones that look skippable and are not: a read-only backend's\n// rows never change, but the completion rules applied to them and the wall\n// clock both do, and neither produces a data event to react to. Before adding\n// a `dataSource` (or any per-backend) early-exit here, check it against all\n// four — the surviving ones below are view-refresh publishes, not skips.\n//\n// All decisions live in `reconciler.ts`; this module is pure plumbing:\n// discover, mkdir, fs.watch, forward events into the reconciler. Every\n// reconcile call is idempotent so fs.watch's well-known quirks (`rename`\n// vs `change`, atomic-write coalescence, filename === null on some\n// platforms) don't need special handling.\n\nimport { access } from \"node:fs/promises\";\nimport {\n discoverCollections,\n itemFilePath,\n loadCollection,\n publishCollectionChange,\n storeFor,\n type DiscoveryOptions,\n type LoadedCollection,\n type StoreChange,\n} from \"../collection/server\";\nimport { errMsg, log } from \"./config.js\";\nimport { evalNow } from \"./clock.js\";\nimport { reconcileAllItems, reconcileItem, sweepStaleActiveEntries } from \"./reconciler.js\";\n\n// Collections don't get added / removed rapidly; 30 s is a comfortable\n// upper bound on how long a new schema can sit before its watcher is up.\nconst ONE_SECOND_MS = 1000;\nconst ONE_MINUTE_MS = 60 * ONE_SECOND_MS;\nconst REDISCOVERY_INTERVAL_MS = 30 * ONE_SECOND_MS;\n\n// Wall-clock tick that re-reconciles time-dependent collections (those\n// declaring `triggerField` and/or `spawn`). The fs.watcher only re-runs\n// the reconciler on FILE changes; a `triggerField` bell that should fire\n// \"when the clock reaches date X\" — and a `spawn` whose successor's own\n// trigger later comes due — change no file at that moment, so a periodic\n// re-derivation is required.\nconst TRIGGER_TICK_INTERVAL_MS = ONE_MINUTE_MS;\n\ninterface CollectionWatcher {\n slug: string;\n dataDir: string;\n /** Unsubscribe from the store's change stream. The store owns HOW changes\n * are detected (which paths, which filenames are noise, how an atomic\n * replace is debounced); this module only holds the handle. */\n unsubscribe: () => void;\n /** Last-seen serialized schema, used ONLY as a change-detection\n * fingerprint. When a rediscovery tick observes a different value, the\n * watcher's items are reconciled and the cache is refreshed — this catches\n * schema-only edits (e.g. flipping `completionField` on or off) that don't\n * touch any record file and would otherwise leave bell state stale\n * indefinitely. Never parsed back: `collection.schema` below is the same\n * schema, already typed. */\n schemaJson: string;\n /** The discovered collection this watcher was mounted for — what the\n * reconciler needs to pick the right STORE (file records vs a sqlite\n * `storage` db), and the typed schema every other read here goes through.\n * Refreshed whenever `schemaJson` is. */\n collection: LoadedCollection;\n}\n\nconst watchers = new Map<string, CollectionWatcher>();\nlet rediscoveryTimer: ReturnType<typeof setInterval> | null = null;\nlet triggerTimer: ReturnType<typeof setInterval> | null = null;\nlet started = false;\n/** Guards the clock tick against overlapping itself. Paired with\n * `watcherEpoch`: a teardown while a pass is in flight bumps the epoch, so\n * that pass's `finally` knows it belongs to a dead generation and must not\n * clear a guard the restarted watcher set now owns. `triggerTickInFlight`\n * is what teardown AWAITS — clearing the flag alone would let a restart run\n * a second pass alongside the first. */\nlet triggerTickRunning = false;\nlet watcherEpoch = 0;\nlet triggerTickInFlight: Promise<void> | null = null;\n/** Discovery options threaded into every `discoverCollections` /\n * `loadCollection` / `sweepStaleActiveEntries` call. Production: empty\n * (live workspace). Tests: `{ workspaceRoot, userSkillsDir }` pointing\n * at a fixture tree. Module-level so per-event handlers can read it\n * without threading through every signature. */\nlet discoveryOpts: DiscoveryOptions = {};\n\n/** Per-key single-flight slot (declared here so `stopCollectionWatchers`\n * can clear it during teardown). */\ninterface ReconcileSlot {\n running: Promise<void>;\n pending: boolean;\n}\nconst itemSlots = new Map<string, ReconcileSlot>();\n\n/** Per-slug single-flight for a COLLECTION-granularity reconcile — a burst\n * of changes the store couldn't attribute to a record collapses into one\n * pass plus one trailing re-run, mirroring the per-item slots. */\nconst collectionSlots = new Map<string, ReconcileSlot>();\n\n/** Test-only configuration knobs. Production callers pass nothing and get\n * the live workspace defaults; tests pass a tmpdir-rooted `discoveryOpts`\n * and override the tick cadences (or set them to `null` to disable the\n * auto-ticks so the test drives sync manually). */\nexport interface CollectionWatcherOptions {\n discoveryOpts?: DiscoveryOptions;\n rediscoveryIntervalMs?: number | null;\n triggerTickIntervalMs?: number | null;\n}\n\n/** Boot entry point: sweep stale active entries, then mount watchers for\n * every discovered collection and arm the periodic re-discovery poll.\n * Idempotent — a second call is a no-op. */\nexport async function startCollectionWatchers(opts: CollectionWatcherOptions = {}): Promise<void> {\n if (started) return;\n // `started` only flips on AFTER boot finishes. If sweep or syncWatchers\n // throws mid-boot, reset state on failure so a supervisor / test\n // harness can retry instead of being permanently latched.\n discoveryOpts = opts.discoveryOpts ?? {};\n try {\n // Boot reconcile is split in two: sweep first (drop bell entries whose\n // files / collections / schemas vanished while the server was down),\n // then `syncWatchers` runs the per-collection forward fill. Both paths\n // are idempotent and converge on the same end state.\n await sweepStaleActiveEntries(discoveryOpts);\n await syncWatchers();\n const intervalMs = opts.rediscoveryIntervalMs === undefined ? REDISCOVERY_INTERVAL_MS : opts.rediscoveryIntervalMs;\n if (intervalMs !== null) {\n rediscoveryTimer = setInterval(() => {\n syncWatchers().catch((err: unknown) => {\n log().warn(\"watcher rediscovery failed\", { error: errMsg(err) });\n });\n }, intervalMs);\n // `unref` so a clean process exit isn't blocked waiting for the tick.\n rediscoveryTimer.unref();\n }\n const triggerMs = opts.triggerTickIntervalMs === undefined ? TRIGGER_TICK_INTERVAL_MS : opts.triggerTickIntervalMs;\n if (triggerMs !== null) {\n triggerTimer = setInterval(() => {\n // Skip rather than overlap. The pass is idempotent and the next one\n // is a minute away, so dropping a tick is harmless — whereas letting\n // firings pile up on a slow pass is not.\n if (triggerTickRunning) return;\n triggerTickRunning = true;\n const epoch = watcherEpoch;\n triggerTickInFlight = tickTimeTriggers()\n .catch((err: unknown) => {\n log().warn(\"watcher trigger tick failed\", { error: errMsg(err) });\n })\n .finally(() => {\n // Only the generation that set the guard may clear it.\n if (epoch !== watcherEpoch) return;\n triggerTickRunning = false;\n triggerTickInFlight = null;\n });\n }, triggerMs);\n triggerTimer.unref();\n }\n started = true;\n } catch (err) {\n discoveryOpts = {};\n throw err;\n }\n}\n\n/** Tear down every watcher and stop the intervals. Used by tests;\n * production never calls this (process exit reclaims the fds). Resets\n * `started` so a subsequent `startCollectionWatchers` re-mounts. */\nexport async function stopCollectionWatchers(): Promise<void> {\n if (rediscoveryTimer) {\n clearInterval(rediscoveryTimer);\n rediscoveryTimer = null;\n }\n if (triggerTimer) {\n clearInterval(triggerTimer);\n triggerTimer = null;\n }\n // Wait for a clock pass that is still running: the interval is disarmed\n // above, but a pass already in flight keeps touching the notifier and the\n // slot maps, and a restart would otherwise run a second one beside it.\n await triggerTickInFlight;\n triggerTickInFlight = null;\n // Bump AFTER the await: any later `finally` from that pass is now a dead\n // generation and becomes a no-op, so it can't undo this teardown.\n watcherEpoch += 1;\n triggerTickRunning = false;\n for (const watcher of watchers.values()) {\n try {\n watcher.unsubscribe();\n } catch {\n /* unsubscribe is best-effort */\n }\n }\n watchers.clear();\n itemSlots.clear();\n collectionSlots.clear();\n discoveryOpts = {};\n started = false;\n}\n\n/** Test-only: manually trigger one rediscovery + reconcile pass. */\nexport async function _syncWatchersForTesting(): Promise<boolean> {\n return syncWatchers();\n}\n\n/** Test-only: drive one wall-clock tick synchronously, with an optional\n * injected clock. */\nexport async function _tickTimeTriggersForTesting(now?: Date): Promise<void> {\n await tickTimeTriggers(now);\n}\n\n/** Re-reconcile every watched collection that depends on the clock — i.e.\n * declares `triggerField` (a bell that fires at a date) and/or `spawn`\n * (recurrence whose successors come due over time). Collections with\n * neither are skipped. Idempotent. Reads the watcher's cached\n * `collection.schema` to avoid a per-tick disk read. */\nasync function tickTimeTriggers(now: Date = evalNow()): Promise<void> {\n for (const entry of watchers.values()) {\n const { schema } = entry.collection;\n // dataSource is NOT excluded. Its rows are read-only, but `triggerField`\n // is not among the keys zod forbids on it, and a trigger date fires from\n // the CLOCK — the one state change that arrives without the file moving.\n // Skipping it here left CSV rows that were pending-but-not-yet-due unable\n // to ever bell unless the file happened to be rewritten.\n if (!schema.triggerField && !schema.spawn) continue;\n await reconcileAllItems(entry.collection, discoveryOpts, now);\n }\n}\n\n/** Reconcile the watcher set against the currently-discovered\n * collections. Adds watchers for new slugs (with a boot reconcile of\n * their items), drops watchers for vanished slugs, and re-reconciles\n * items for collections whose schema changed. Runs a final sweep when\n * this tick changed the watcher set or any schema. */\nasync function syncWatchers(): Promise<boolean> {\n let collections;\n try {\n collections = await discoverCollections(discoveryOpts);\n } catch (err) {\n log().warn(\"watcher discover failed\", { error: errMsg(err) });\n return false;\n }\n const liveSlugs = new Set(collections.map((collection) => collection.slug));\n const vanishedMutated = stopVanishedWatchers(liveSlugs);\n const schemaMutated = await reconcileChangedSchemas(collections);\n const addedMutated = await startNewWatchers(collections);\n if (!vanishedMutated && !schemaMutated && !addedMutated) return false;\n await sweepStaleActiveEntries(discoveryOpts);\n return true;\n}\n\nfunction stopVanishedWatchers(liveSlugs: Set<string>): boolean {\n let mutated = false;\n for (const slug of [...watchers.keys()]) {\n if (liveSlugs.has(slug)) continue;\n const watcher = watchers.get(slug);\n if (watcher) {\n try {\n watcher.unsubscribe();\n } catch {\n /* best-effort */\n }\n }\n watchers.delete(slug);\n mutated = true;\n log().info(\"watcher stopped\", { slug });\n }\n return mutated;\n}\n\n/** True when a schema edit moved the collection's storage — a different\n * `dataSource.path`, a different `dataPath`, or a flip between the two\n * modes. The mounted fs.watch is bound to the OLD location, so it must\n * be remounted, not just re-reconciled. */\nfunction storagePathChanged(previous: LoadedCollection[\"schema\"], next: LoadedCollection[\"schema\"]): boolean {\n return previous.dataSource?.path !== next.dataSource?.path || previous.dataPath !== next.dataPath || previous.storage?.path !== next.storage?.path;\n}\n\n/** Re-reconcile already-watched collections whose schema changed since\n * the last tick. New collections fall through to `startNewWatchers`. */\nasync function reconcileChangedSchemas(collections: readonly LoadedCollection[]): Promise<boolean> {\n let mutated = false;\n for (const collection of collections) {\n const existing = watchers.get(collection.slug);\n if (!existing) continue;\n const nextJson = JSON.stringify(collection.schema);\n if (existing.schemaJson === nextJson) continue;\n if (storagePathChanged(existing.collection.schema, collection.schema)) {\n // Drop the stale mount; `startNewWatchers` (which runs right after\n // this pass in syncWatchers) remounts on the new location. A\n // dataSource collection also gets a change ping so open views\n // refetch against the new file immediately.\n log().info(\"watcher storage path changed, remounting\", { slug: collection.slug });\n try {\n existing.unsubscribe();\n } catch {\n /* best-effort */\n }\n watchers.delete(collection.slug);\n if (collection.schema.dataSource !== undefined) publishCollectionChange({ slug: collection.slug, op: \"upsert\" });\n mutated = true;\n continue;\n }\n existing.schemaJson = nextJson;\n existing.collection = collection;\n log().info(\"watcher schema changed, re-reconciling\", { slug: collection.slug });\n // Completion rules live in the SCHEMA, so a schema-only edit can change\n // which items are pending without any record changing. This runs for\n // every backend including dataSource: those rows are read-only, but the\n // rules applied to them are not. Re-deriving no-ops unless the schema\n // declares `completionField`; a completionField that was REMOVED is the\n // sweep's job, which `mutated` schedules at the end of the tick.\n await reconcileAllItems(collection, discoveryOpts);\n if (collection.schema.dataSource !== undefined) {\n // Read-only rows can't have changed, but a schema edit changes what the\n // views render (fields, displayField, …), so ping them.\n publishCollectionChange({ slug: collection.slug, op: \"upsert\" });\n }\n mutated = true;\n }\n return mutated;\n}\n\n/** Mount a watcher for every collection that doesn't have one yet. Returns\n * whether the watcher SET actually changed — the starters report whether\n * they mounted, because ATTEMPTING is not mounting. A start that throws\n * (logged and swallowed inside the starter) leaves `watchers` untouched, so\n * the collection is retried next tick; counting that as a mutation made\n * `syncWatchers` sweep on every tick for as long as the failure persisted. */\nasync function startNewWatchers(collections: readonly LoadedCollection[]): Promise<boolean> {\n let mutated = false;\n for (const collection of collections) {\n if (watchers.has(collection.slug)) continue;\n if (await startWatcherFor(collection)) mutated = true;\n }\n return mutated;\n}\n\n/** Mount one collection's change subscription, whatever its backend.\n *\n * The store decides how to detect a change and at what granularity; this\n * decides what to do about one. That split is the point: adding a backend\n * means implementing `watch` on its store, not another branch here.\n *\n * A store without `watch` cannot report external changes at all — it is\n * still registered (so bells reconcile at boot and on the clock tick), just\n * without live updates. */\nasync function startWatcherFor(collection: LoadedCollection): Promise<boolean> {\n const { slug } = collection;\n try {\n // Boot reconcile BEFORE subscribing: an item that went pending while the\n // server was down needs its bell even if no event ever fires.\n await reconcileAllItems(collection, discoveryOpts);\n const store = storeFor(collection, discoveryOpts);\n const unsubscribe = store.watch\n ? await store.watch((change) => {\n void handleStoreChange(slug, change).catch((err: unknown) => {\n log().warn(\"store change handling failed\", { slug, error: errMsg(err) });\n });\n })\n : () => {};\n // `null` means the backend HAS a watch but could not arm it this time.\n // Registering anyway would mark the slug mounted forever: `startNewWatchers`\n // skips slugs already in `watchers`, so nothing would re-arm it and the\n // collection would serve stale data until a restart. Leave it out and the\n // next sync tick retries — the boot reconcile above is idempotent.\n if (unsubscribe === null) {\n log().warn(\"collection watcher could not arm, retrying next sync\", { slug });\n return false;\n }\n watchers.set(slug, { slug, dataDir: collection.dataDir, unsubscribe, schemaJson: JSON.stringify(collection.schema), collection });\n log().info(\"collection watcher started\", { slug, live: store.watch !== undefined });\n return true;\n } catch (err) {\n log().warn(\"collection watcher start failed\", { slug, error: errMsg(err) });\n return false;\n }\n}\n\n/** React to one reported change. Backend-agnostic by construction — it sees\n * only the granularity the store reported.\n *\n * `item`: reconcile just that record, then publish it. `collection`: the\n * store couldn't say which record, so re-derive everything and pair it with\n * a sweep — a record deleted remotely leaves a bell that a walk over the\n * SURVIVING records can never clear. */\nasync function handleStoreChange(slug: string, change: StoreChange): Promise<void> {\n if (change.kind === \"collection\") {\n await scheduleCollectionReconcile(slug);\n return;\n }\n // Resolve the collection at EVENT time, not at mount time. A schema-only\n // edit (one that leaves the storage location alone, so nothing remounts)\n // refreshes `watchers`' entry in place — a callback closed over the mount-\n // time snapshot would keep reconciling against the old `completionField` /\n // `notifyWhen` / `triggerField` and undo what the schema-change pass had\n // just converged on.\n const current = watchers.get(slug)?.collection;\n if (!current) return; // unmounted between the event and now\n await scheduleItemReconcile(current, change.itemId);\n}\n\n/** Full re-derivation for a collection-granularity change, single-flighted\n * per slug so a burst of writes collapses into one pass plus one trailing\n * re-run. */\nfunction scheduleCollectionReconcile(slug: string): Promise<void> {\n return runSingleFlight(\n collectionSlots,\n slug,\n async () => {\n const collection = await loadCollection(slug, discoveryOpts);\n if (!collection) return;\n await reconcileAllItems(collection, discoveryOpts);\n // A record deleted underneath us leaves a bell that a walk over the\n // SURVIVING records can never clear — the sweep is the other half.\n await sweepStaleActiveEntries(discoveryOpts);\n },\n // Publish from `onSettled`, i.e. even when the pass above threw: the\n // data changed regardless of whether we managed to re-derive bells from\n // it, and a missed event leaves every open view silently stale. The slot\n // is the coalescing unit, so one burst still yields one publish.\n () => {\n safePublish({ slug, op: \"upsert\" });\n return Promise.resolve();\n },\n );\n}\n\n/** Test-only: feed one store-reported change through the same path a live\n * subscription uses, so a test can pin how a change is reacted to without\n * depending on fs.watch timing. */\nexport function _handleStoreChangeForTesting(slug: string, change: StoreChange): Promise<void> {\n return handleStoreChange(slug, change);\n}\n\n/** Test-only: drive one collection-granularity reconcile directly. */\nexport function _scheduleCollectionReconcileForTesting(slug: string): Promise<void> {\n return scheduleCollectionReconcile(slug);\n}\n\nexport function _scheduleItemReconcileForTesting(collection: LoadedCollection, itemId: string): Promise<void> {\n return scheduleItemReconcile(collection, itemId);\n}\n\nfunction scheduleItemReconcile(collection: LoadedCollection, itemId: string): Promise<void> {\n return runSingleFlight(\n itemSlots,\n `${collection.slug}\\x00${itemId}`,\n () => reconcileItem(collection, itemId, discoveryOpts),\n () => publishItemChange(collection, itemId),\n );\n}\n\n/** The shared single-flight loop behind both schedulers. Re-runs `pass`\n * while events keep arriving — the trailing re-run captures any state\n * change that landed during a prior pass. After each pass we read\n * `pending` and zero it before the next iteration, so an event that\n * fires *during* the last pass's await still triggers one more pass\n * before the slot is freed. `onSettled`, if given, runs once in the\n * `finally` after the slot is freed — the item scheduler uses it to\n * publish exactly one live-refresh event per burst. */\nfunction runSingleFlight(slots: Map<string, ReconcileSlot>, key: string, pass: () => Promise<void>, onSettled?: () => Promise<void>): Promise<void> {\n const existing = slots.get(key);\n if (existing) {\n existing.pending = true;\n return existing.running;\n }\n const slot: ReconcileSlot = { running: Promise.resolve(), pending: false };\n slot.running = (async () => {\n try {\n let keepGoing = true;\n while (keepGoing) {\n slot.pending = false;\n await pass();\n keepGoing = slot.pending;\n }\n } finally {\n slots.delete(key);\n // `onSettled` runs once after the slot is freed — the slot is the\n // coalescing primitive, so one burst yields one call. It's in the\n // `finally` because a failed reconcile still means a file changed,\n // and open views must be told to refetch either way.\n if (onSettled) await onSettled();\n }\n })();\n slots.set(key, slot);\n return slot.running;\n}\n\n/** Emit the live-refresh event for one record. `op` is derived from\n * whether the file is still there — `fs.watch` reports neither the kind\n * of change nor, reliably, which of `rename`/`change` means what. Only\n * file-backed collections reach here; a `storage` (db) collection has no\n * per-record file and publishes wholesale in `scheduleStorageReconcile`. */\nasync function publishItemChange(collection: LoadedCollection, itemId: string): Promise<void> {\n const changeOp = (await itemFileExists(collection.dataDir, itemId)) ? \"upsert\" : \"delete\";\n safePublish({ slug: collection.slug, ids: [itemId], op: changeOp });\n}\n\nasync function itemFileExists(dataDir: string, itemId: string): Promise<boolean> {\n try {\n await access(itemFilePath(dataDir, itemId));\n return true;\n } catch {\n return false;\n }\n}\n\n/** The publisher is host-supplied, so treat it as untrusted: a throw here\n * runs inside a `finally` and would mask the reconcile's own error. */\nfunction safePublish(payload: { slug: string; ids?: string[]; op?: \"upsert\" | \"delete\" }): void {\n try {\n publishCollectionChange(payload);\n } catch (err) {\n log().warn(\"collection change publish failed\", { slug: payload.slug, error: errMsg(err) });\n }\n}\n"],"mappings":"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"}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { a as isErrorWithCode,
|
|
1
|
+
import { a as isErrorWithCode, l as isUnknownArray, r as hasNumberProp, s as isRecord } from "./dist-D8zokgGo.js";
|
|
2
2
|
import { t as writeFileAtomic } from "./atomic-DPpdrJzO.js";
|
|
3
|
-
import { A as fieldTextOrNull, C as FEED_SCHEDULES, D as isReadOnlySchema, O as storageKindFor, S as COMPUTED_TYPES, g as SAFE_SLUG_PATTERN, h as SAFE_RECORD_ID_PATTERN, k as fieldText, v as isSafeRecordId, w as INGEST_KINDS, x as AGENT_INGEST_KIND, y as isSafeSlug } from "./calendarGrid-
|
|
4
|
-
import { n as projectRecordFields } from "./project-
|
|
3
|
+
import { A as fieldTextOrNull, C as FEED_SCHEDULES, D as isReadOnlySchema, O as storageKindFor, S as COMPUTED_TYPES, g as SAFE_SLUG_PATTERN, h as SAFE_RECORD_ID_PATTERN, k as fieldText, v as isSafeRecordId, w as INGEST_KINDS, x as AGENT_INGEST_KIND, y as isSafeSlug } from "./calendarGrid-Csy2rpjp.js";
|
|
4
|
+
import { n as projectRecordFields } from "./project-B0j_Vf_Q.js";
|
|
5
5
|
import { n as isSafeCustomViewI18nPath, r as isSafeCustomViewPath, t as isSafeActionTemplatePath } from "./templatePath-k_WNbL_Q.js";
|
|
6
6
|
import { realpathSync, watch } from "node:fs";
|
|
7
7
|
import path from "node:path";
|
|
@@ -142,7 +142,7 @@ function realpathClosestAncestor(absPath) {
|
|
|
142
142
|
while (cursor !== path.dirname(cursor)) try {
|
|
143
143
|
return realpathSync(cursor);
|
|
144
144
|
} catch (err) {
|
|
145
|
-
if (err.code === "ENOENT") {
|
|
145
|
+
if (isErrorWithCode(err) && err.code === "ENOENT") {
|
|
146
146
|
cursor = path.dirname(cursor);
|
|
147
147
|
continue;
|
|
148
148
|
}
|
|
@@ -246,8 +246,7 @@ async function isRegularFile(filePath) {
|
|
|
246
246
|
* null when it isn't a JSON object (array / scalar / null). */
|
|
247
247
|
function parseRecordJson(raw) {
|
|
248
248
|
const parsed = JSON.parse(raw);
|
|
249
|
-
|
|
250
|
-
return null;
|
|
249
|
+
return isRecord(parsed) ? parsed : null;
|
|
251
250
|
}
|
|
252
251
|
async function tryReadRecord(filePath) {
|
|
253
252
|
if (!await isRegularFile(filePath)) return null;
|
|
@@ -272,7 +271,7 @@ async function listItems(dataDir, opts = {}) {
|
|
|
272
271
|
try {
|
|
273
272
|
entries = await readdir(dataDir);
|
|
274
273
|
} catch (err) {
|
|
275
|
-
if (err.code === "ENOENT") return [];
|
|
274
|
+
if (isErrorWithCode(err) && err.code === "ENOENT") return [];
|
|
276
275
|
throw err;
|
|
277
276
|
}
|
|
278
277
|
const results = [];
|
|
@@ -302,7 +301,7 @@ async function readItem(dataDir, itemId, opts = {}) {
|
|
|
302
301
|
try {
|
|
303
302
|
return parseRecordJson(await readFile(filePath, "utf-8"));
|
|
304
303
|
} catch (err) {
|
|
305
|
-
if (err.code === "ENOENT") return null;
|
|
304
|
+
if (isErrorWithCode(err) && err.code === "ENOENT") return null;
|
|
306
305
|
throw err;
|
|
307
306
|
}
|
|
308
307
|
}
|
|
@@ -372,7 +371,7 @@ async function writeItem(dataDir, itemId, item, opts = {}) {
|
|
|
372
371
|
try {
|
|
373
372
|
handle = await open(filePath, "wx");
|
|
374
373
|
} catch (err) {
|
|
375
|
-
if (err.code === "EEXIST") return {
|
|
374
|
+
if (isErrorWithCode(err) && err.code === "EEXIST") return {
|
|
376
375
|
kind: "conflict",
|
|
377
376
|
itemId: safeId
|
|
378
377
|
};
|
|
@@ -418,7 +417,7 @@ async function deleteItem(dataDir, itemId, opts = {}) {
|
|
|
418
417
|
itemId: safeId
|
|
419
418
|
};
|
|
420
419
|
} catch (err) {
|
|
421
|
-
if (err.code === "ENOENT") return {
|
|
420
|
+
if (isErrorWithCode(err) && err.code === "ENOENT") return {
|
|
422
421
|
kind: "not-found",
|
|
423
422
|
itemId: safeId
|
|
424
423
|
};
|
|
@@ -588,7 +587,7 @@ function whereFragment(cond) {
|
|
|
588
587
|
const column = quoteIdent(cond.field);
|
|
589
588
|
const asText = `CAST(${column} AS VARCHAR)`;
|
|
590
589
|
if (cond.op === "in") {
|
|
591
|
-
const values = cond
|
|
590
|
+
const values = arrayValue(cond);
|
|
592
591
|
return {
|
|
593
592
|
sql: `${values.every((value) => typeof value === "string") ? asText : column} IN (${values.map(() => "?").join(", ")})`,
|
|
594
593
|
params: values
|
|
@@ -596,7 +595,7 @@ function whereFragment(cond) {
|
|
|
596
595
|
}
|
|
597
596
|
if (cond.op === "contains") return {
|
|
598
597
|
sql: `contains(${asText}, ?)`,
|
|
599
|
-
params: [String(cond
|
|
598
|
+
params: [String(scalarValue(cond))]
|
|
600
599
|
};
|
|
601
600
|
const operator = {
|
|
602
601
|
eq: "=",
|
|
@@ -608,9 +607,24 @@ function whereFragment(cond) {
|
|
|
608
607
|
}[cond.op];
|
|
609
608
|
return {
|
|
610
609
|
sql: `${typeof cond.value === "string" && (cond.op === "eq" || cond.op === "ne") ? asText : column} ${operator} ?`,
|
|
611
|
-
params: [cond
|
|
610
|
+
params: [scalarValue(cond)]
|
|
612
611
|
};
|
|
613
612
|
}
|
|
613
|
+
/** Mirror of `scalarValue` for the one op that takes a set: a scalar under
|
|
614
|
+
* `in` also means the query skipped `CollectionQueryZ`. Left unchecked it
|
|
615
|
+
* failed as `values.every is not a function`, naming neither the field nor
|
|
616
|
+
* the op. */
|
|
617
|
+
function arrayValue(cond) {
|
|
618
|
+
if (!Array.isArray(cond.value)) throw new Error(`where condition on '${cond.field}' uses op 'in', which requires an array value, not a scalar`);
|
|
619
|
+
return cond.value;
|
|
620
|
+
}
|
|
621
|
+
/** `CollectionQueryZ` refines "`in` ⇔ array value", so an array reaching a
|
|
622
|
+
* scalar op means the query was compiled without being validated first —
|
|
623
|
+
* binding it would send an array to a single `?`. */
|
|
624
|
+
function scalarValue(cond) {
|
|
625
|
+
if (Array.isArray(cond.value)) throw new Error(`where condition on '${cond.field}' uses op '${cond.op}', which requires a scalar value, not an array`);
|
|
626
|
+
return cond.value;
|
|
627
|
+
}
|
|
614
628
|
/** Compile a validated query against `fromSql` (a table-function call
|
|
615
629
|
* whose FIRST placeholder is the source path — the executor binds it).
|
|
616
630
|
* Returns the SQL and the where-value parameters that follow the path.
|
|
@@ -668,9 +682,9 @@ function encodeCsvRecordId(rawKey) {
|
|
|
668
682
|
* `encodeCsvRecordId` for encoded ids; anything else is already the raw
|
|
669
683
|
* value. Pure + exported for unit tests. */
|
|
670
684
|
function decodeCsvRecordId(itemId) {
|
|
671
|
-
const
|
|
672
|
-
if (
|
|
673
|
-
return Buffer.from(
|
|
685
|
+
const hex = ENCODED_ID_PATTERN.exec(itemId)?.[1];
|
|
686
|
+
if (hex === void 0) return itemId;
|
|
687
|
+
return Buffer.from(hex, "hex").toString("utf-8");
|
|
674
688
|
}
|
|
675
689
|
/** Normalize one DuckDB JS value into a JSON-safe record value: BigInt →
|
|
676
690
|
* number (string beyond the safe range), DATE/TIMESTAMP → ISO string
|
|
@@ -832,7 +846,7 @@ async function safeCsvStat(absPath, workspaceRoot) {
|
|
|
832
846
|
try {
|
|
833
847
|
info = await lstat(absPath);
|
|
834
848
|
} catch (err) {
|
|
835
|
-
if (err.code === "ENOENT") return null;
|
|
849
|
+
if (isErrorWithCode(err) && err.code === "ENOENT") return null;
|
|
836
850
|
throw err;
|
|
837
851
|
}
|
|
838
852
|
if (!info.isFile()) {
|
|
@@ -1046,7 +1060,7 @@ function projectItemFields(items, fields, primaryKey) {
|
|
|
1046
1060
|
* tests. `limit: 0` is a valid "count only" page. */
|
|
1047
1061
|
function pageFromFullRead(items, opts, primaryKey, truncated) {
|
|
1048
1062
|
const offset = Math.max(0, opts.offset ?? 0);
|
|
1049
|
-
const end = opts.limit === void 0 ?
|
|
1063
|
+
const end = opts.limit === void 0 ? items.length : offset + Math.max(0, opts.limit);
|
|
1050
1064
|
return {
|
|
1051
1065
|
items: projectItemFields(items.slice(offset, end), opts.fields, primaryKey),
|
|
1052
1066
|
total: items.length,
|
|
@@ -2171,7 +2185,7 @@ var SpawnZ = z.object({
|
|
|
2171
2185
|
* items natively and ignore it — so no kind-specific requirement here. */
|
|
2172
2186
|
var DeclarativeIngestZ = z.object({
|
|
2173
2187
|
kind: z.enum(INGEST_KINDS),
|
|
2174
|
-
url: z.
|
|
2188
|
+
url: z.url(),
|
|
2175
2189
|
schedule: z.enum(FEED_SCHEDULES),
|
|
2176
2190
|
atHour: z.number().int().min(0).max(23).optional(),
|
|
2177
2191
|
itemsAt: z.string().trim().min(1).optional(),
|
|
@@ -2426,23 +2440,38 @@ function ownPrototypeKey(value) {
|
|
|
2426
2440
|
for (const key of PROTOTYPE_KEYS) if (Object.hasOwn(value, key)) return key;
|
|
2427
2441
|
return null;
|
|
2428
2442
|
}
|
|
2443
|
+
/** Own enumerable entries of an object (arrays keyed by index), none for
|
|
2444
|
+
* anything else — the raw input is unvalidated, so `fields` may be junk. */
|
|
2445
|
+
function ownEntries(value) {
|
|
2446
|
+
if (isUnknownArray(value)) return value.map((entry, index) => [String(index), entry]);
|
|
2447
|
+
return isRecord(value) ? Object.entries(value) : [];
|
|
2448
|
+
}
|
|
2449
|
+
/** The name-defining sub-record a raw field spec (`of`) or action (`params`)
|
|
2450
|
+
* carries, or undefined when the holder isn't an object at all. */
|
|
2451
|
+
function nameDefiningSubRecord(holder, key) {
|
|
2452
|
+
return isRecord(holder) ? holder[key] : void 0;
|
|
2453
|
+
}
|
|
2454
|
+
/** Dotted path of the first prototype-sensitive `params` name across both
|
|
2455
|
+
* action lists, or null. */
|
|
2456
|
+
function prototypeActionParamPath(input) {
|
|
2457
|
+
for (const [listName, list] of [["actions", input.actions], ["collectionActions", input.collectionActions]]) for (const action of isUnknownArray(list) ? list : []) {
|
|
2458
|
+
const badParam = ownPrototypeKey(nameDefiningSubRecord(action, "params"));
|
|
2459
|
+
if (badParam !== null) return `${listName}.params.${badParam}`;
|
|
2460
|
+
}
|
|
2461
|
+
return null;
|
|
2462
|
+
}
|
|
2429
2463
|
/** Dotted path of the first prototype-sensitive field name in the raw
|
|
2430
2464
|
* schema input — top-level `fields`, each table field's `of`, and each
|
|
2431
2465
|
* action's `params` (the three records that DEFINE names) — or null. */
|
|
2432
2466
|
function prototypeFieldKeyPath(input) {
|
|
2433
|
-
if (input
|
|
2434
|
-
const
|
|
2435
|
-
const bad = ownPrototypeKey(schema.fields);
|
|
2467
|
+
if (!isRecord(input)) return null;
|
|
2468
|
+
const bad = ownPrototypeKey(input.fields);
|
|
2436
2469
|
if (bad !== null) return `fields.${bad}`;
|
|
2437
|
-
for (const [key, spec] of
|
|
2438
|
-
const badSub = ownPrototypeKey(spec
|
|
2470
|
+
for (const [key, spec] of ownEntries(input.fields)) {
|
|
2471
|
+
const badSub = ownPrototypeKey(nameDefiningSubRecord(spec, "of"));
|
|
2439
2472
|
if (badSub !== null) return `fields.${key}.of.${badSub}`;
|
|
2440
2473
|
}
|
|
2441
|
-
|
|
2442
|
-
const badParam = ownPrototypeKey(action?.params);
|
|
2443
|
-
if (badParam !== null) return `${listName}.params.${badParam}`;
|
|
2444
|
-
}
|
|
2445
|
-
return null;
|
|
2474
|
+
return prototypeActionParamPath(input);
|
|
2446
2475
|
}
|
|
2447
2476
|
var CollectionSchemaZ = z.preprocess((input, ctx) => {
|
|
2448
2477
|
const bad = prototypeFieldKeyPath(input);
|
|
@@ -2458,11 +2487,10 @@ var CollectionSchemaZ = z.preprocess((input, ctx) => {
|
|
|
2458
2487
|
//#endregion
|
|
2459
2488
|
//#region src/collection/server/discovery.ts
|
|
2460
2489
|
function applyFeedSchemaDefaults(parsed, slug) {
|
|
2461
|
-
if (!
|
|
2462
|
-
const
|
|
2463
|
-
const icon = typeof obj.icon === "string" && obj.icon.trim().length > 0 ? obj.icon : "dynamic_feed";
|
|
2490
|
+
if (!isRecord(parsed)) return parsed;
|
|
2491
|
+
const icon = typeof parsed.icon === "string" && parsed.icon.trim().length > 0 ? parsed.icon : "dynamic_feed";
|
|
2464
2492
|
return {
|
|
2465
|
-
...
|
|
2493
|
+
...parsed,
|
|
2466
2494
|
icon,
|
|
2467
2495
|
dataPath: `data/feeds/${slug}`
|
|
2468
2496
|
};
|
|
@@ -2565,7 +2593,7 @@ async function loadOneCollection(skillsRoot, slug, source, workspaceRoot) {
|
|
|
2565
2593
|
if (!(await stat(schemaPath)).isFile()) return null;
|
|
2566
2594
|
raw = await readFile(schemaPath, "utf-8");
|
|
2567
2595
|
} catch (err) {
|
|
2568
|
-
if (err.code !== "ENOENT") log.warn("collections", "failed to read schema.json, skipping", {
|
|
2596
|
+
if (!isErrorWithCode(err) || err.code !== "ENOENT") log.warn("collections", "failed to read schema.json, skipping", {
|
|
2569
2597
|
slug: safeName,
|
|
2570
2598
|
path: schemaPath,
|
|
2571
2599
|
error: String(err)
|
|
@@ -2619,7 +2647,7 @@ async function collectFromDir(skillsRoot, source, workspaceRoot) {
|
|
|
2619
2647
|
try {
|
|
2620
2648
|
entries = await readdir(skillsRoot);
|
|
2621
2649
|
} catch (err) {
|
|
2622
|
-
if (err.code === "ENOENT") return [];
|
|
2650
|
+
if (isErrorWithCode(err) && err.code === "ENOENT") return [];
|
|
2623
2651
|
log.warn("collections", "failed to list skills dir, returning empty", {
|
|
2624
2652
|
root: skillsRoot,
|
|
2625
2653
|
error: String(err)
|
|
@@ -2695,4 +2723,4 @@ function toDetail(collection) {
|
|
|
2695
2723
|
//#endregion
|
|
2696
2724
|
export { skillsStagingDir as $, deleteItem as A, itemFilePath as B, compileCsvQuery as C, MAX_QUERY_ROWS as D, DEFAULT_QUERY_ROWS as E, resolveCreateItemId as F, archiveDir as G, resolveTemplatePath as H, writeItem as I, getWorkspaceRoot as J, collectionsRegistriesConfigPath as K, SCHEMA_FILE as L, isRegularFile as M, listItems as N, BackendUnavailableError as O, readItem as P, setCollectionChangePublisher as Q, isContainedInRoot as R, queryCsv as S, CollectionQueryZ as T, safeRecordId as U, resolveDataDir as V, safeSlugName as W, log as X, isPresetSlug as Y, publishCollectionChange as Z, csvRowToItem as _, toDetail as a, encodeCsvRecordId as b, resolveMutateSet as c, storeFor as d, createForwardingLogger as et, checkpointSqliteDatabase as f, cacheDir as g, MAX_CSV_ROWS as h, resolvePrimaryField as i, generateItemId as j, isBackendUnavailable as k, collectionWritable as l, projectItemFields as m, discoverCollections as n, toSummary as o, pageFromFullRead as p, configureCollectionHost as q, loadCollection as r, CollectionSchemaZ as s, acceptParsedSchema as t, createHostSlot as tt, readOnlyRefusal as u, decodeCsvRecordId as v, compileJsonlQuery as w, normalizeCsvValue as x, dedupeByRecordId as y, isContainedInWorkspace as z };
|
|
2697
2725
|
|
|
2698
|
-
//# sourceMappingURL=discovery-
|
|
2726
|
+
//# sourceMappingURL=discovery-BSedPW8A.js.map
|