@happyvertical/smrt-core 0.40.70 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (193) hide show
  1. package/AGENTS.md +3 -3
  2. package/agents/change-feed.md +1 -1
  3. package/agents/schema-paths.md +561 -32
  4. package/dist/browser.js +5 -4
  5. package/dist/cascade.d.ts +120 -0
  6. package/dist/cascade.d.ts.map +1 -0
  7. package/dist/cascade.js +430 -0
  8. package/dist/cascade.js.map +1 -0
  9. package/dist/change-feed.d.ts +34 -2
  10. package/dist/change-feed.d.ts.map +1 -1
  11. package/dist/change-feed.js +54 -13
  12. package/dist/change-feed.js.map +1 -1
  13. package/dist/change-signals.d.ts.map +1 -1
  14. package/dist/change-signals.js +8 -1
  15. package/dist/change-signals.js.map +1 -1
  16. package/dist/class.d.ts +36 -3
  17. package/dist/class.d.ts.map +1 -1
  18. package/dist/class.js +69 -14
  19. package/dist/class.js.map +1 -1
  20. package/dist/collection-cache.js +0 -0
  21. package/dist/collection-cache.js.map +1 -1
  22. package/dist/collection.d.ts +29 -0
  23. package/dist/collection.d.ts.map +1 -1
  24. package/dist/collection.js +68 -16
  25. package/dist/collection.js.map +1 -1
  26. package/dist/config.d.ts +10 -0
  27. package/dist/config.d.ts.map +1 -1
  28. package/dist/config.js.map +1 -1
  29. package/dist/decorators/index.d.ts +63 -5
  30. package/dist/decorators/index.d.ts.map +1 -1
  31. package/dist/decorators/index.js +37 -5
  32. package/dist/decorators/index.js.map +1 -1
  33. package/dist/dispatch/collections/Dispatches.d.ts.map +1 -1
  34. package/dist/dispatch/collections/Dispatches.js +21 -6
  35. package/dist/dispatch/collections/Dispatches.js.map +1 -1
  36. package/dist/dispatch/models/Dispatch.d.ts +1 -1
  37. package/dist/dispatch/models/Dispatch.d.ts.map +1 -1
  38. package/dist/dispatch/models/Dispatch.js +3 -2
  39. package/dist/dispatch/models/Dispatch.js.map +1 -1
  40. package/dist/dispatch/models/DispatchSubscription.d.ts +1 -1
  41. package/dist/dispatch/models/DispatchSubscription.d.ts.map +1 -1
  42. package/dist/dispatch/models/DispatchSubscription.js +2 -1
  43. package/dist/dispatch/models/DispatchSubscription.js.map +1 -1
  44. package/dist/dispatch/types.d.ts +5 -0
  45. package/dist/dispatch/types.d.ts.map +1 -1
  46. package/dist/embedded-write-queue.d.ts +46 -0
  47. package/dist/embedded-write-queue.d.ts.map +1 -0
  48. package/dist/embedded-write-queue.js +66 -0
  49. package/dist/embedded-write-queue.js.map +1 -0
  50. package/dist/embeddings/storage.d.ts +7 -0
  51. package/dist/embeddings/storage.d.ts.map +1 -1
  52. package/dist/embeddings/storage.js +31 -13
  53. package/dist/embeddings/storage.js.map +1 -1
  54. package/dist/errors.d.ts +1 -1
  55. package/dist/hierarchical.js +1 -1
  56. package/dist/index.d.ts +2 -1
  57. package/dist/index.d.ts.map +1 -1
  58. package/dist/index.js +8 -6
  59. package/dist/interceptors.d.ts +21 -0
  60. package/dist/interceptors.d.ts.map +1 -1
  61. package/dist/interceptors.js +27 -1
  62. package/dist/interceptors.js.map +1 -1
  63. package/dist/learning/memory.d.ts.map +1 -1
  64. package/dist/learning/memory.js +5 -4
  65. package/dist/learning/memory.js.map +1 -1
  66. package/dist/manifest/generator.d.ts.map +1 -1
  67. package/dist/manifest/generator.js +4 -7
  68. package/dist/manifest/generator.js.map +1 -1
  69. package/dist/manifest/static-manifest.js +10 -10
  70. package/dist/manifest/static-manifest.js.map +1 -1
  71. package/dist/manifest/store.js +1 -1
  72. package/dist/manifest/store.js.map +1 -1
  73. package/dist/manifest.json +19 -19
  74. package/dist/migrations/differ.d.ts +9 -1
  75. package/dist/migrations/differ.d.ts.map +1 -1
  76. package/dist/migrations/differ.js +12 -7
  77. package/dist/migrations/differ.js.map +1 -1
  78. package/dist/migrations/index.d.ts +2 -0
  79. package/dist/migrations/index.d.ts.map +1 -1
  80. package/dist/migrations/index.js +3 -1
  81. package/dist/migrations/integer-width.d.ts +99 -0
  82. package/dist/migrations/integer-width.d.ts.map +1 -0
  83. package/dist/migrations/integer-width.js +237 -0
  84. package/dist/migrations/integer-width.js.map +1 -0
  85. package/dist/migrations/minor-units.d.ts +162 -0
  86. package/dist/migrations/minor-units.d.ts.map +1 -0
  87. package/dist/migrations/minor-units.js +383 -0
  88. package/dist/migrations/minor-units.js.map +1 -0
  89. package/dist/migrations/tracker.d.ts.map +1 -1
  90. package/dist/migrations/tracker.js +28 -20
  91. package/dist/migrations/tracker.js.map +1 -1
  92. package/dist/migrations.js +3 -1
  93. package/dist/object.d.ts +98 -5
  94. package/dist/object.d.ts.map +1 -1
  95. package/dist/object.js +138 -30
  96. package/dist/object.js.map +1 -1
  97. package/dist/query-bounds.d.ts +5 -1
  98. package/dist/query-bounds.d.ts.map +1 -1
  99. package/dist/query-bounds.js +5 -1
  100. package/dist/query-bounds.js.map +1 -1
  101. package/dist/registry/class-registration.d.ts.map +1 -1
  102. package/dist/registry/class-registration.js +3 -1
  103. package/dist/registry/class-registration.js.map +1 -1
  104. package/dist/registry/manifest-field-merge.d.ts +12 -0
  105. package/dist/registry/manifest-field-merge.d.ts.map +1 -1
  106. package/dist/registry/manifest-field-merge.js +14 -2
  107. package/dist/registry/manifest-field-merge.js.map +1 -1
  108. package/dist/registry/schema-builder.d.ts +22 -1
  109. package/dist/registry/schema-builder.d.ts.map +1 -1
  110. package/dist/registry/schema-builder.js +205 -165
  111. package/dist/registry/schema-builder.js.map +1 -1
  112. package/dist/registry/types.d.ts +3 -2
  113. package/dist/registry/types.d.ts.map +1 -1
  114. package/dist/registry.d.ts +41 -46
  115. package/dist/registry.d.ts.map +1 -1
  116. package/dist/registry.js +61 -83
  117. package/dist/registry.js.map +1 -1
  118. package/dist/scanner/manifest-generator.d.ts +45 -0
  119. package/dist/scanner/manifest-generator.d.ts.map +1 -1
  120. package/dist/scanner/manifest-generator.js +92 -28
  121. package/dist/scanner/manifest-generator.js.map +1 -1
  122. package/dist/schema/conflict-target.d.ts +104 -0
  123. package/dist/schema/conflict-target.d.ts.map +1 -0
  124. package/dist/schema/conflict-target.js +129 -0
  125. package/dist/schema/conflict-target.js.map +1 -0
  126. package/dist/schema/ddl/duckdb-strategy.d.ts.map +1 -1
  127. package/dist/schema/ddl/duckdb-strategy.js +3 -1
  128. package/dist/schema/ddl/duckdb-strategy.js.map +1 -1
  129. package/dist/schema/ddl/postgres-strategy.d.ts.map +1 -1
  130. package/dist/schema/ddl/postgres-strategy.js +14 -1
  131. package/dist/schema/ddl/postgres-strategy.js.map +1 -1
  132. package/dist/schema/generator.d.ts +245 -41
  133. package/dist/schema/generator.d.ts.map +1 -1
  134. package/dist/schema/generator.js +397 -241
  135. package/dist/schema/generator.js.map +1 -1
  136. package/dist/schema/index-utils.d.ts +120 -0
  137. package/dist/schema/index-utils.d.ts.map +1 -1
  138. package/dist/schema/index-utils.js +242 -1
  139. package/dist/schema/index-utils.js.map +1 -1
  140. package/dist/schema/index.d.ts +2 -3
  141. package/dist/schema/index.d.ts.map +1 -1
  142. package/dist/schema/index.js +2 -3
  143. package/dist/schema/live-parity.d.ts +3 -1
  144. package/dist/schema/live-parity.d.ts.map +1 -1
  145. package/dist/schema/live-parity.js +23 -2
  146. package/dist/schema/live-parity.js.map +1 -1
  147. package/dist/schema/system-table-shapes.d.ts +13 -0
  148. package/dist/schema/system-table-shapes.d.ts.map +1 -1
  149. package/dist/schema/system-table-shapes.js +14 -1
  150. package/dist/schema/system-table-shapes.js.map +1 -1
  151. package/dist/schema/types.d.ts +17 -10
  152. package/dist/schema/types.d.ts.map +1 -1
  153. package/dist/schema/utils.d.ts +1 -1
  154. package/dist/schema/utils.d.ts.map +1 -1
  155. package/dist/schema/utils.js +3 -3
  156. package/dist/schema/utils.js.map +1 -1
  157. package/dist/schema.js +2 -3
  158. package/dist/smrt-knowledge.json +9 -9
  159. package/dist/system/compatibility.d.ts +42 -0
  160. package/dist/system/compatibility.d.ts.map +1 -1
  161. package/dist/system/compatibility.js +182 -9
  162. package/dist/system/compatibility.js.map +1 -1
  163. package/dist/system/index.d.ts +1 -0
  164. package/dist/system/index.d.ts.map +1 -1
  165. package/dist/system/index.js +3 -2
  166. package/dist/system/retention.d.ts +237 -0
  167. package/dist/system/retention.d.ts.map +1 -0
  168. package/dist/system/retention.js +497 -0
  169. package/dist/system/retention.js.map +1 -0
  170. package/dist/system/schema.d.ts +102 -16
  171. package/dist/system/schema.d.ts.map +1 -1
  172. package/dist/system/schema.js +85 -47
  173. package/dist/system/schema.js.map +1 -1
  174. package/dist/system/types.d.ts +0 -2
  175. package/dist/system/types.d.ts.map +1 -1
  176. package/dist/testing/database.d.ts.map +1 -1
  177. package/dist/testing/database.js.map +1 -1
  178. package/dist/utils/safe-integer.d.ts +19 -0
  179. package/dist/utils/safe-integer.d.ts.map +1 -0
  180. package/dist/utils/safe-integer.js +31 -0
  181. package/dist/utils/safe-integer.js.map +1 -0
  182. package/dist/utils.d.ts +2 -1
  183. package/dist/utils.d.ts.map +1 -1
  184. package/dist/utils.js +6 -3
  185. package/dist/utils.js.map +1 -1
  186. package/dist/vite-plugin/index.d.ts.map +1 -1
  187. package/dist/vite-plugin/index.js +4 -62
  188. package/dist/vite-plugin/index.js.map +1 -1
  189. package/package.json +4 -4
  190. package/dist/schema/override-system.d.ts +0 -43
  191. package/dist/schema/override-system.d.ts.map +0 -1
  192. package/dist/schema/override-system.js +0 -205
  193. package/dist/schema/override-system.js.map +0 -1
@@ -1,4 +1,5 @@
1
1
  import { PROCESS_ID, getNotifications, resolveDbCacheKey } from "./collection-cache.js";
2
+ import { toSafeInteger } from "./utils/safe-integer.js";
2
3
  import { createLogger } from "@happyvertical/logger";
3
4
  //#region src/change-signals.ts
4
5
  /**
@@ -296,12 +297,18 @@ function toSignal(record) {
296
297
  const { table, operation, rowId, tenantId, seq } = record;
297
298
  if (typeof table !== "string" || !table) return void 0;
298
299
  if (operation !== "create" && operation !== "update" && operation !== "delete") return;
300
+ let normalizedSeq;
301
+ try {
302
+ normalizedSeq = toSafeInteger(seq ?? 0, "Change-signal sequence");
303
+ } catch {
304
+ return;
305
+ }
299
306
  return {
300
307
  table,
301
308
  operation,
302
309
  rowId: typeof rowId === "string" ? rowId : null,
303
310
  tenantId: typeof tenantId === "string" ? tenantId : null,
304
- seq: typeof seq === "number" ? seq : Number(seq ?? 0) || 0
311
+ seq: normalizedSeq
305
312
  };
306
313
  }
307
314
  function safeParse(value) {
@@ -1 +1 @@
1
- {"version":3,"file":"change-signals.js","names":[],"sources":["../src/change-signals.ts"],"sourcesContent":["/**\n * Change-signal bus — the live push spine for the generated `_events` SSE\n * route (issue #1763, parent PRD #1755).\n *\n * The change feed (#1758) is a durable, cursor-addressable log; this bus is\n * its ephemeral companion. Every framework `save()`/`delete()` that appends a\n * feed row also publishes a coarse {@link ChangeSignal} here, which fans out\n * synchronously to in-process subscribers (the SSE controllers of connected\n * `_events` clients) and, when the database adapter exposes a notification\n * capability, to peer replicas over the same channel. Absence of that\n * capability degrades gracefully — no cross-replica push, never an error, and\n * it never blocks the write.\n *\n * ## What a signal carries (and deliberately does not)\n *\n * A signal is `{ table, operation, rowId, tenantId, seq }` — never any row\n * payload. Authorization stays entirely on the read path: a subscriber learns\n * *that* something changed and its cursor (`seq`), then re-reads through the\n * authorized collection routes to catch up. This is why the bus can broadcast\n * a tenant's writes to peer replicas without leaking data across a trust\n * boundary. The `seq` is the same monotonic cursor dimension the change feed\n * allocates, so a reconnecting client can resume via `getChangesSince`.\n *\n * ## Delivery model\n *\n * - Local delivery is a synchronous fan-out ({@link deliverLocally}) into each\n * listener callback, wrapped in a try/catch **per listener** so one throwing\n * listener (e.g. a closed SSE controller's `enqueue`) never blocks the\n * others. There is no per-subscriber queue — backpressure is delegated to\n * each platform `ReadableStream`.\n * - Cross-replica delivery reuses the same {@link deliverLocally} helper on\n * receipt, so locally-published and peer-received signals travel one code\n * path. Notifications this process published are skipped by `source` id\n * (echo-avoidance), exactly as the collection cache does.\n *\n * This mirrors `collection-cache.ts`'s notify/listen structure; study that\n * module for the shared cross-process conventions (`resolveDbCacheKey`,\n * `getNotifications`, the lazy listener and finally-retract).\n *\n * ## Known gaps\n *\n * - **Subscriber cap lives at the route boundary**: the bus tracks subscribers,\n * but generated `_events` routes decide whether to reject a new connection\n * before opening a stream (#1860).\n * - **Raw-SQL writes are invisible**: signals originate from the framework\n * write path (same accepted gap as the #1758 feed and #1498 cache). A\n * `bumpChangeFeed` escape-hatch write appends a feed row but does not\n * publish a signal.\n * - **Caller-managed transactions are best-effort**: the append + signal fire\n * from `afterSave`/`afterDelete`, i.e. *before* a caller-wrapped transaction\n * commits (the autocommit default path — save()/delete() as independent\n * statements — is exact). Inside such a transaction, a signal may fire for a\n * change that a later rollback undoes, and the rolled-back seq is then reused\n * by the next append — so a client trusting a pre-commit `Last-Event-ID`\n * could skip the reuse via catch-up. This inherits the change feed's\n * documented transaction caveat (see `change-feed.ts`); no generic\n * post-commit hook exists to close it. Clients reconcile via full catch-up /\n * resync, so convergence still holds — live delivery is just best-effort for\n * transaction-wrapped writes.\n *\n * @see https://github.com/happyvertical/smrt/issues/1763\n * @packageDocumentation\n */\n\nimport { createLogger } from '@happyvertical/logger';\nimport type { DatabaseInterface } from '@happyvertical/sql';\nimport {\n getNotifications,\n PROCESS_ID,\n resolveDbCacheKey,\n} from './collection-cache.js';\n\nconst logger = createLogger({ level: 'info' });\n\n/**\n * Operation carried by a change signal. Kept as a local string union — this\n * module deliberately does NOT import `./change-feed.js` (which imports this\n * one via the writer) so there is no import cycle. It must stay in sync with\n * `ChangeOperation` in `change-feed.ts`.\n */\nexport type ChangeSignalOperation = 'create' | 'update' | 'delete';\n\n/**\n * A coarse change notification. Carries no row payload by design.\n */\nexport interface ChangeSignal {\n // NEVER add row-payload fields — #1763 AC: authorization stays on the read path\n /** Physical table the change happened in. */\n table: string;\n /** What happened (`'delete'` doubles as a tombstone signal). */\n operation: ChangeSignalOperation;\n /** Primary key of the changed row, or `null` for table-level changes. */\n rowId: string | null;\n /** Tenant the changed row belongs to, or `null` for global rows. */\n tenantId: string | null;\n /**\n * The change feed sequence allocated for this change — the cursor a\n * reconnecting client resumes from via `getChangesSince`.\n */\n seq: number;\n}\n\n/** A subscriber invoked synchronously for every locally-visible signal. */\nexport type ChangeSignalListener = (signal: ChangeSignal) => void;\n\n/**\n * Notification channel for cross-replica change-signal broadcasts. A distinct\n * channel from the collection cache's — the two buses carry different payloads\n * and evolve independently.\n */\nexport const CHANGE_SIGNAL_CHANNEL = 'smrt_change_signals';\n\n/**\n * dbKey → local listeners. Keyed via `resolveDbCacheKey` so `:memory:` and\n * URL-less handles are scoped per instance (never cross-deliver between two\n * independent in-memory databases).\n */\nconst localListeners = new Map<string, Set<ChangeSignalListener>>();\n\n/**\n * dbKey -> pending subscriber slots claimed by `_events` before a stream has\n * reached its `start()` callback. This closes the check-then-subscribe race for\n * concurrent connection opens: capacity considers active listeners plus these\n * in-flight claims.\n */\nconst reservedListenerSlots = new Map<string, number>();\n\ninterface ListenerHandle {\n iterator: AsyncIterator<unknown> | null;\n stopped: boolean;\n}\n\n/** dbKey → background cross-replica listener handle. */\nconst crossReplicaListeners = new Map<string, ListenerHandle>();\n\n/** dbKeys we already warned about for a missing notification capability. */\nconst warnedNoNotifications = new Set<string>();\n\n/**\n * Subscribe to change signals for a database.\n *\n * Registers `listener` for the database's signal scope and, on the first\n * subscriber for that scope, lazily starts the cross-replica listener (a no-op\n * when the adapter has no notification capability). Returns an unsubscribe\n * function that removes the listener and, when the scope's last subscriber\n * leaves, retracts the cross-replica listener so its refcount reaches 0.\n *\n * Delivery is synchronous: `listener` is invoked from the write path (or the\n * cross-replica loop) inside a per-listener try/catch, so it must not assume\n * an active request or tenant context — capture what it needs at subscribe\n * time.\n */\nexport function subscribeToChangeSignals(\n db: DatabaseInterface,\n listener: ChangeSignalListener,\n): () => void {\n const dbKey = resolveDbCacheKey(db);\n\n let set = localListeners.get(dbKey);\n if (!set) {\n set = new Set();\n localListeners.set(dbKey, set);\n }\n set.add(listener);\n\n // Lazily start the cross-replica listener on first interest for this scope.\n ensureChangeSignalListener(db);\n\n let unsubscribed = false;\n return () => {\n if (unsubscribed) return;\n unsubscribed = true;\n const current = localListeners.get(dbKey);\n if (!current) return;\n current.delete(listener);\n if (current.size === 0) {\n localListeners.delete(dbKey);\n // Last local subscriber gone — retract the cross-replica listener so its\n // refcount reaches 0 (mirrors collection-cache's finally-retract).\n retractChangeSignalListener(dbKey);\n }\n };\n}\n\n/**\n * Atomically reserve one local subscriber slot for a database scope.\n *\n * The `_events` route calls this before returning a streaming response so\n * concurrent opens cannot all pass a stale count and exceed the configured cap.\n * The returned release function is idempotent; callers must release it when the\n * stream either converts the reservation into a real subscription or tears down\n * before subscribing. `maxSubscribers === null` means unlimited and returns a\n * no-op reservation.\n */\nexport function tryReserveChangeSignalSubscriberSlot(\n db: DatabaseInterface,\n maxSubscribers: number | null,\n): (() => void) | null {\n if (maxSubscribers === null) {\n return () => {};\n }\n\n const dbKey = resolveDbCacheKey(db);\n if (changeSignalSubscriberCountForKey(dbKey) >= maxSubscribers) {\n return null;\n }\n\n reservedListenerSlots.set(dbKey, (reservedListenerSlots.get(dbKey) ?? 0) + 1);\n\n let released = false;\n return () => {\n if (released) return;\n released = true;\n const current = reservedListenerSlots.get(dbKey) ?? 0;\n if (current <= 1) {\n reservedListenerSlots.delete(dbKey);\n } else {\n reservedListenerSlots.set(dbKey, current - 1);\n }\n };\n}\n\n/**\n * Publish a change signal: synchronous local fan-out, then fire-and-forget\n * cross-replica broadcast. Never throws to the caller — a signal problem must\n * never fail the user's write.\n */\nexport function publishChangeSignal(\n db: DatabaseInterface,\n signal: ChangeSignal,\n): void {\n const dbKey = resolveDbCacheKey(db);\n deliverLocally(dbKey, signal);\n // Fire-and-forget: broadcast failures are swallowed inside broadcast.\n void broadcastChangeSignal(db, signal);\n}\n\n/**\n * Synchronous local fan-out to every subscriber for a scope. Each listener is\n * wrapped in its own try/catch so one throwing listener (a closed SSE\n * controller) never blocks the rest. Locally-published and peer-received\n * signals both flow through here — the single delivery path.\n */\nfunction deliverLocally(dbKey: string, signal: ChangeSignal): void {\n const set = localListeners.get(dbKey);\n if (!set || set.size === 0) return;\n // Snapshot so a listener that unsubscribes during delivery can't mutate the\n // set mid-iteration.\n for (const listener of [...set]) {\n try {\n listener(signal);\n } catch (error) {\n logger.warn('Change signal: a subscriber threw during local delivery', {\n error: error instanceof Error ? error.message : String(error),\n });\n }\n }\n}\n\n/**\n * Broadcast a signal to peer replicas over the adapter's notification\n * capability. Resolves normally (never throws) whether or not a capability\n * exists — a missing capability warns once per scope and is not an error.\n *\n * Fire-and-forget from the write path: a broadcast failure must never fail the\n * write that triggered it.\n */\nexport async function broadcastChangeSignal(\n db: DatabaseInterface,\n signal: ChangeSignal,\n): Promise<void> {\n const notifications = getNotifications(db);\n const dbKey = resolveDbCacheKey(db);\n if (!notifications) {\n warnOnceNoNotifications(dbKey);\n return;\n }\n try {\n await notifications.notify(CHANGE_SIGNAL_CHANNEL, {\n ...signal,\n source: PROCESS_ID,\n });\n } catch (error) {\n logger.warn(\n `Change signal: failed to broadcast a signal for '${signal.table}'`,\n { error: error instanceof Error ? error.message : String(error) },\n );\n }\n}\n\n/**\n * Ensure a background listener consumes cross-replica broadcasts for this\n * database and delivers them locally. Started lazily by the first subscriber\n * for a scope; a no-op (with a one-time warning) when the adapter exposes no\n * notification capability. Notifications this process published are skipped by\n * `source` id (echo-avoidance): the local fan-out already delivered them.\n */\nfunction ensureChangeSignalListener(db: DatabaseInterface): void {\n const dbKey = resolveDbCacheKey(db);\n if (crossReplicaListeners.has(dbKey)) return;\n\n const notifications = getNotifications(db);\n if (!notifications) {\n warnOnceNoNotifications(dbKey);\n return;\n }\n\n const handle: ListenerHandle = { iterator: null, stopped: false };\n crossReplicaListeners.set(dbKey, handle);\n\n void (async () => {\n try {\n const iterable = notifications.listen(CHANGE_SIGNAL_CHANNEL);\n const iterator = iterable[Symbol.asyncIterator]();\n handle.iterator = iterator;\n\n while (!handle.stopped) {\n const { value, done } = await iterator.next();\n if (done || handle.stopped) break;\n\n const notification = value as { payload?: unknown };\n const payload: unknown =\n typeof notification?.payload === 'string'\n ? safeParse(notification.payload)\n : notification?.payload;\n\n if (!payload || typeof payload !== 'object') continue;\n const record = payload as Record<string, unknown>;\n // Echo-avoidance: skip signals this process published (already\n // delivered locally on the write path).\n if (record.source === PROCESS_ID) continue;\n\n const signal = toSignal(record);\n if (!signal) continue;\n deliverLocally(dbKey, signal);\n }\n } catch (error) {\n if (!handle.stopped) {\n logger.warn(\n 'Change signal: cross-replica listener terminated unexpectedly; ' +\n 'peer signals are no longer delivered for this database (local ' +\n 'delivery and cursor catch-up still work)',\n { error: error instanceof Error ? error.message : String(error) },\n );\n }\n } finally {\n // Only retract our own handle — a concurrent restart may have installed\n // a replacement; deleting unconditionally would orphan it.\n if (crossReplicaListeners.get(dbKey) === handle) {\n crossReplicaListeners.delete(dbKey);\n }\n }\n })();\n}\n\n/** Retract the cross-replica listener for a scope (last subscriber left). */\nfunction retractChangeSignalListener(dbKey: string): void {\n const handle = crossReplicaListeners.get(dbKey);\n if (!handle) return;\n handle.stopped = true;\n void handle.iterator?.return?.(undefined);\n crossReplicaListeners.delete(dbKey);\n}\n\n/**\n * Stop all cross-replica change-signal listeners. Used by tests and during\n * shutdown; safe to call when none are active.\n */\nexport function stopChangeSignalListeners(): void {\n for (const handle of crossReplicaListeners.values()) {\n handle.stopped = true;\n void handle.iterator?.return?.(undefined);\n }\n crossReplicaListeners.clear();\n warnedNoNotifications.clear();\n}\n\n/**\n * Clear all local subscribers, stop cross-replica listeners, and reset the\n * no-capability warning dedup. Call in test setup for isolation between files.\n */\nexport function resetChangeSignals(): void {\n localListeners.clear();\n reservedListenerSlots.clear();\n stopChangeSignalListeners();\n}\n\n/**\n * Number of active or reserved local subscribers for a database scope.\n *\n * Used by the `_events` route boundary to enforce the per-process subscriber\n * cap (#1860), and by integration tests to assert teardown — a leaked SSE\n * subscription or stale reservation would keep this above 0 after a client\n * disconnects.\n */\nexport function changeSignalSubscriberCount(db: DatabaseInterface): number {\n return changeSignalSubscriberCountForKey(resolveDbCacheKey(db));\n}\n\nfunction changeSignalSubscriberCountForKey(dbKey: string): number {\n return (\n (localListeners.get(dbKey)?.size ?? 0) +\n (reservedListenerSlots.get(dbKey) ?? 0)\n );\n}\n\nfunction warnOnceNoNotifications(dbKey: string): void {\n if (warnedNoNotifications.has(dbKey)) return;\n warnedNoNotifications.add(dbKey);\n logger.warn(\n 'Change signal: the database adapter exposes no notification capability, ' +\n 'so change signals are delivered in-process only. Cross-replica live ' +\n 'updates require an adapter with notifications (e.g. Postgres ' +\n 'LISTEN/NOTIFY); subscribers on other replicas fall back to cursor ' +\n 'polling.',\n );\n}\n\n/**\n * Coerce a received notification payload into a {@link ChangeSignal}, or\n * `undefined` if it is malformed. Guards the cross-replica path against\n * garbage on the channel.\n */\nfunction toSignal(record: Record<string, unknown>): ChangeSignal | undefined {\n const { table, operation, rowId, tenantId, seq } = record;\n if (typeof table !== 'string' || !table) return undefined;\n if (\n operation !== 'create' &&\n operation !== 'update' &&\n operation !== 'delete'\n ) {\n return undefined;\n }\n return {\n table,\n operation,\n rowId: typeof rowId === 'string' ? rowId : null,\n tenantId: typeof tenantId === 'string' ? tenantId : null,\n seq: typeof seq === 'number' ? seq : Number(seq ?? 0) || 0,\n };\n}\n\nfunction safeParse(value: string): unknown {\n try {\n return JSON.parse(value);\n } catch {\n return undefined;\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"change-signals.js","names":[],"sources":["../src/change-signals.ts"],"sourcesContent":["/**\n * Change-signal bus — the live push spine for the generated `_events` SSE\n * route (issue #1763, parent PRD #1755).\n *\n * The change feed (#1758) is a durable, cursor-addressable log; this bus is\n * its ephemeral companion. Every framework `save()`/`delete()` that appends a\n * feed row also publishes a coarse {@link ChangeSignal} here, which fans out\n * synchronously to in-process subscribers (the SSE controllers of connected\n * `_events` clients) and, when the database adapter exposes a notification\n * capability, to peer replicas over the same channel. Absence of that\n * capability degrades gracefully — no cross-replica push, never an error, and\n * it never blocks the write.\n *\n * ## What a signal carries (and deliberately does not)\n *\n * A signal is `{ table, operation, rowId, tenantId, seq }` — never any row\n * payload. Authorization stays entirely on the read path: a subscriber learns\n * *that* something changed and its cursor (`seq`), then re-reads through the\n * authorized collection routes to catch up. This is why the bus can broadcast\n * a tenant's writes to peer replicas without leaking data across a trust\n * boundary. The `seq` is the same monotonic cursor dimension the change feed\n * allocates, so a reconnecting client can resume via `getChangesSince`.\n *\n * ## Delivery model\n *\n * - Local delivery is a synchronous fan-out ({@link deliverLocally}) into each\n * listener callback, wrapped in a try/catch **per listener** so one throwing\n * listener (e.g. a closed SSE controller's `enqueue`) never blocks the\n * others. There is no per-subscriber queue — backpressure is delegated to\n * each platform `ReadableStream`.\n * - Cross-replica delivery reuses the same {@link deliverLocally} helper on\n * receipt, so locally-published and peer-received signals travel one code\n * path. Notifications this process published are skipped by `source` id\n * (echo-avoidance), exactly as the collection cache does.\n *\n * This mirrors `collection-cache.ts`'s notify/listen structure; study that\n * module for the shared cross-process conventions (`resolveDbCacheKey`,\n * `getNotifications`, the lazy listener and finally-retract).\n *\n * ## Known gaps\n *\n * - **Subscriber cap lives at the route boundary**: the bus tracks subscribers,\n * but generated `_events` routes decide whether to reject a new connection\n * before opening a stream (#1860).\n * - **Raw-SQL writes are invisible**: signals originate from the framework\n * write path (same accepted gap as the #1758 feed and #1498 cache). A\n * `bumpChangeFeed` escape-hatch write appends a feed row but does not\n * publish a signal.\n * - **Caller-managed transactions are best-effort**: the append + signal fire\n * from `afterSave`/`afterDelete`, i.e. *before* a caller-wrapped transaction\n * commits (the autocommit default path — save()/delete() as independent\n * statements — is exact). Inside such a transaction, a signal may fire for a\n * change that a later rollback undoes, and the rolled-back seq is then reused\n * by the next append — so a client trusting a pre-commit `Last-Event-ID`\n * could skip the reuse via catch-up. This inherits the change feed's\n * documented transaction caveat (see `change-feed.ts`); no generic\n * post-commit hook exists to close it. Clients reconcile via full catch-up /\n * resync, so convergence still holds — live delivery is just best-effort for\n * transaction-wrapped writes.\n *\n * @see https://github.com/happyvertical/smrt/issues/1763\n * @packageDocumentation\n */\n\nimport { createLogger } from '@happyvertical/logger';\nimport type { DatabaseInterface } from '@happyvertical/sql';\nimport {\n getNotifications,\n PROCESS_ID,\n resolveDbCacheKey,\n} from './collection-cache.js';\nimport { toSafeInteger } from './utils/safe-integer.js';\n\nconst logger = createLogger({ level: 'info' });\n\n/**\n * Operation carried by a change signal. Kept as a local string union — this\n * module deliberately does NOT import `./change-feed.js` (which imports this\n * one via the writer) so there is no import cycle. It must stay in sync with\n * `ChangeOperation` in `change-feed.ts`.\n */\nexport type ChangeSignalOperation = 'create' | 'update' | 'delete';\n\n/**\n * A coarse change notification. Carries no row payload by design.\n */\nexport interface ChangeSignal {\n // NEVER add row-payload fields — #1763 AC: authorization stays on the read path\n /** Physical table the change happened in. */\n table: string;\n /** What happened (`'delete'` doubles as a tombstone signal). */\n operation: ChangeSignalOperation;\n /** Primary key of the changed row, or `null` for table-level changes. */\n rowId: string | null;\n /** Tenant the changed row belongs to, or `null` for global rows. */\n tenantId: string | null;\n /**\n * The change feed sequence allocated for this change — the cursor a\n * reconnecting client resumes from via `getChangesSince`.\n */\n seq: number;\n}\n\n/** A subscriber invoked synchronously for every locally-visible signal. */\nexport type ChangeSignalListener = (signal: ChangeSignal) => void;\n\n/**\n * Notification channel for cross-replica change-signal broadcasts. A distinct\n * channel from the collection cache's — the two buses carry different payloads\n * and evolve independently.\n */\nexport const CHANGE_SIGNAL_CHANNEL = 'smrt_change_signals';\n\n/**\n * dbKey → local listeners. Keyed via `resolveDbCacheKey` so `:memory:` and\n * URL-less handles are scoped per instance (never cross-deliver between two\n * independent in-memory databases).\n */\nconst localListeners = new Map<string, Set<ChangeSignalListener>>();\n\n/**\n * dbKey -> pending subscriber slots claimed by `_events` before a stream has\n * reached its `start()` callback. This closes the check-then-subscribe race for\n * concurrent connection opens: capacity considers active listeners plus these\n * in-flight claims.\n */\nconst reservedListenerSlots = new Map<string, number>();\n\ninterface ListenerHandle {\n iterator: AsyncIterator<unknown> | null;\n stopped: boolean;\n}\n\n/** dbKey → background cross-replica listener handle. */\nconst crossReplicaListeners = new Map<string, ListenerHandle>();\n\n/** dbKeys we already warned about for a missing notification capability. */\nconst warnedNoNotifications = new Set<string>();\n\n/**\n * Subscribe to change signals for a database.\n *\n * Registers `listener` for the database's signal scope and, on the first\n * subscriber for that scope, lazily starts the cross-replica listener (a no-op\n * when the adapter has no notification capability). Returns an unsubscribe\n * function that removes the listener and, when the scope's last subscriber\n * leaves, retracts the cross-replica listener so its refcount reaches 0.\n *\n * Delivery is synchronous: `listener` is invoked from the write path (or the\n * cross-replica loop) inside a per-listener try/catch, so it must not assume\n * an active request or tenant context — capture what it needs at subscribe\n * time.\n */\nexport function subscribeToChangeSignals(\n db: DatabaseInterface,\n listener: ChangeSignalListener,\n): () => void {\n const dbKey = resolveDbCacheKey(db);\n\n let set = localListeners.get(dbKey);\n if (!set) {\n set = new Set();\n localListeners.set(dbKey, set);\n }\n set.add(listener);\n\n // Lazily start the cross-replica listener on first interest for this scope.\n ensureChangeSignalListener(db);\n\n let unsubscribed = false;\n return () => {\n if (unsubscribed) return;\n unsubscribed = true;\n const current = localListeners.get(dbKey);\n if (!current) return;\n current.delete(listener);\n if (current.size === 0) {\n localListeners.delete(dbKey);\n // Last local subscriber gone — retract the cross-replica listener so its\n // refcount reaches 0 (mirrors collection-cache's finally-retract).\n retractChangeSignalListener(dbKey);\n }\n };\n}\n\n/**\n * Atomically reserve one local subscriber slot for a database scope.\n *\n * The `_events` route calls this before returning a streaming response so\n * concurrent opens cannot all pass a stale count and exceed the configured cap.\n * The returned release function is idempotent; callers must release it when the\n * stream either converts the reservation into a real subscription or tears down\n * before subscribing. `maxSubscribers === null` means unlimited and returns a\n * no-op reservation.\n */\nexport function tryReserveChangeSignalSubscriberSlot(\n db: DatabaseInterface,\n maxSubscribers: number | null,\n): (() => void) | null {\n if (maxSubscribers === null) {\n return () => {};\n }\n\n const dbKey = resolveDbCacheKey(db);\n if (changeSignalSubscriberCountForKey(dbKey) >= maxSubscribers) {\n return null;\n }\n\n reservedListenerSlots.set(dbKey, (reservedListenerSlots.get(dbKey) ?? 0) + 1);\n\n let released = false;\n return () => {\n if (released) return;\n released = true;\n const current = reservedListenerSlots.get(dbKey) ?? 0;\n if (current <= 1) {\n reservedListenerSlots.delete(dbKey);\n } else {\n reservedListenerSlots.set(dbKey, current - 1);\n }\n };\n}\n\n/**\n * Publish a change signal: synchronous local fan-out, then fire-and-forget\n * cross-replica broadcast. Never throws to the caller — a signal problem must\n * never fail the user's write.\n */\nexport function publishChangeSignal(\n db: DatabaseInterface,\n signal: ChangeSignal,\n): void {\n const dbKey = resolveDbCacheKey(db);\n deliverLocally(dbKey, signal);\n // Fire-and-forget: broadcast failures are swallowed inside broadcast.\n void broadcastChangeSignal(db, signal);\n}\n\n/**\n * Synchronous local fan-out to every subscriber for a scope. Each listener is\n * wrapped in its own try/catch so one throwing listener (a closed SSE\n * controller) never blocks the rest. Locally-published and peer-received\n * signals both flow through here — the single delivery path.\n */\nfunction deliverLocally(dbKey: string, signal: ChangeSignal): void {\n const set = localListeners.get(dbKey);\n if (!set || set.size === 0) return;\n // Snapshot so a listener that unsubscribes during delivery can't mutate the\n // set mid-iteration.\n for (const listener of [...set]) {\n try {\n listener(signal);\n } catch (error) {\n logger.warn('Change signal: a subscriber threw during local delivery', {\n error: error instanceof Error ? error.message : String(error),\n });\n }\n }\n}\n\n/**\n * Broadcast a signal to peer replicas over the adapter's notification\n * capability. Resolves normally (never throws) whether or not a capability\n * exists — a missing capability warns once per scope and is not an error.\n *\n * Fire-and-forget from the write path: a broadcast failure must never fail the\n * write that triggered it.\n */\nexport async function broadcastChangeSignal(\n db: DatabaseInterface,\n signal: ChangeSignal,\n): Promise<void> {\n const notifications = getNotifications(db);\n const dbKey = resolveDbCacheKey(db);\n if (!notifications) {\n warnOnceNoNotifications(dbKey);\n return;\n }\n try {\n await notifications.notify(CHANGE_SIGNAL_CHANNEL, {\n ...signal,\n source: PROCESS_ID,\n });\n } catch (error) {\n logger.warn(\n `Change signal: failed to broadcast a signal for '${signal.table}'`,\n { error: error instanceof Error ? error.message : String(error) },\n );\n }\n}\n\n/**\n * Ensure a background listener consumes cross-replica broadcasts for this\n * database and delivers them locally. Started lazily by the first subscriber\n * for a scope; a no-op (with a one-time warning) when the adapter exposes no\n * notification capability. Notifications this process published are skipped by\n * `source` id (echo-avoidance): the local fan-out already delivered them.\n */\nfunction ensureChangeSignalListener(db: DatabaseInterface): void {\n const dbKey = resolveDbCacheKey(db);\n if (crossReplicaListeners.has(dbKey)) return;\n\n const notifications = getNotifications(db);\n if (!notifications) {\n warnOnceNoNotifications(dbKey);\n return;\n }\n\n const handle: ListenerHandle = { iterator: null, stopped: false };\n crossReplicaListeners.set(dbKey, handle);\n\n void (async () => {\n try {\n const iterable = notifications.listen(CHANGE_SIGNAL_CHANNEL);\n const iterator = iterable[Symbol.asyncIterator]();\n handle.iterator = iterator;\n\n while (!handle.stopped) {\n const { value, done } = await iterator.next();\n if (done || handle.stopped) break;\n\n const notification = value as { payload?: unknown };\n const payload: unknown =\n typeof notification?.payload === 'string'\n ? safeParse(notification.payload)\n : notification?.payload;\n\n if (!payload || typeof payload !== 'object') continue;\n const record = payload as Record<string, unknown>;\n // Echo-avoidance: skip signals this process published (already\n // delivered locally on the write path).\n if (record.source === PROCESS_ID) continue;\n\n const signal = toSignal(record);\n if (!signal) continue;\n deliverLocally(dbKey, signal);\n }\n } catch (error) {\n if (!handle.stopped) {\n logger.warn(\n 'Change signal: cross-replica listener terminated unexpectedly; ' +\n 'peer signals are no longer delivered for this database (local ' +\n 'delivery and cursor catch-up still work)',\n { error: error instanceof Error ? error.message : String(error) },\n );\n }\n } finally {\n // Only retract our own handle — a concurrent restart may have installed\n // a replacement; deleting unconditionally would orphan it.\n if (crossReplicaListeners.get(dbKey) === handle) {\n crossReplicaListeners.delete(dbKey);\n }\n }\n })();\n}\n\n/** Retract the cross-replica listener for a scope (last subscriber left). */\nfunction retractChangeSignalListener(dbKey: string): void {\n const handle = crossReplicaListeners.get(dbKey);\n if (!handle) return;\n handle.stopped = true;\n void handle.iterator?.return?.(undefined);\n crossReplicaListeners.delete(dbKey);\n}\n\n/**\n * Stop all cross-replica change-signal listeners. Used by tests and during\n * shutdown; safe to call when none are active.\n */\nexport function stopChangeSignalListeners(): void {\n for (const handle of crossReplicaListeners.values()) {\n handle.stopped = true;\n void handle.iterator?.return?.(undefined);\n }\n crossReplicaListeners.clear();\n warnedNoNotifications.clear();\n}\n\n/**\n * Clear all local subscribers, stop cross-replica listeners, and reset the\n * no-capability warning dedup. Call in test setup for isolation between files.\n */\nexport function resetChangeSignals(): void {\n localListeners.clear();\n reservedListenerSlots.clear();\n stopChangeSignalListeners();\n}\n\n/**\n * Number of active or reserved local subscribers for a database scope.\n *\n * Used by the `_events` route boundary to enforce the per-process subscriber\n * cap (#1860), and by integration tests to assert teardown — a leaked SSE\n * subscription or stale reservation would keep this above 0 after a client\n * disconnects.\n */\nexport function changeSignalSubscriberCount(db: DatabaseInterface): number {\n return changeSignalSubscriberCountForKey(resolveDbCacheKey(db));\n}\n\nfunction changeSignalSubscriberCountForKey(dbKey: string): number {\n return (\n (localListeners.get(dbKey)?.size ?? 0) +\n (reservedListenerSlots.get(dbKey) ?? 0)\n );\n}\n\nfunction warnOnceNoNotifications(dbKey: string): void {\n if (warnedNoNotifications.has(dbKey)) return;\n warnedNoNotifications.add(dbKey);\n logger.warn(\n 'Change signal: the database adapter exposes no notification capability, ' +\n 'so change signals are delivered in-process only. Cross-replica live ' +\n 'updates require an adapter with notifications (e.g. Postgres ' +\n 'LISTEN/NOTIFY); subscribers on other replicas fall back to cursor ' +\n 'polling.',\n );\n}\n\n/**\n * Coerce a received notification payload into a {@link ChangeSignal}, or\n * `undefined` if it is malformed. Guards the cross-replica path against\n * garbage on the channel.\n */\nfunction toSignal(record: Record<string, unknown>): ChangeSignal | undefined {\n const { table, operation, rowId, tenantId, seq } = record;\n if (typeof table !== 'string' || !table) return undefined;\n if (\n operation !== 'create' &&\n operation !== 'update' &&\n operation !== 'delete'\n ) {\n return undefined;\n }\n let normalizedSeq: number;\n try {\n normalizedSeq = toSafeInteger(seq ?? 0, 'Change-signal sequence');\n } catch {\n return undefined;\n }\n return {\n table,\n operation,\n rowId: typeof rowId === 'string' ? rowId : null,\n tenantId: typeof tenantId === 'string' ? tenantId : null,\n seq: normalizedSeq,\n };\n}\n\nfunction safeParse(value: string): unknown {\n try {\n return JSON.parse(value);\n } catch {\n return undefined;\n }\n}\n"],"mappings":"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"}
package/dist/class.d.ts CHANGED
@@ -205,11 +205,20 @@ export declare class SmrtClass {
205
205
  /**
206
206
  * Ensure SMRT system tables exist in the database
207
207
  *
208
- * System tables use _smrt_ prefix and store framework metadata:
208
+ * System tables use the _smrt_ prefix and store framework metadata:
209
209
  * - _smrt_contexts: Context memory storage for remembered patterns
210
210
  * - _smrt_migrations: Schema version tracking
211
- * - _smrt_registry: Object registry persistence
212
- * - _smrt_signals: Signal history/audit log
211
+ * - _smrt_schema_migrations / _smrt_backfills: Migration and backfill ledgers
212
+ * - _smrt_embeddings: Embedding vectors for semantic search
213
+ * - _smrt_dispatch / _smrt_dispatch_subscriptions: Inter-agent dispatch queue
214
+ * - _smrt_ai_usage: AI usage telemetry
215
+ * - _smrt_changes: Append-only change feed
216
+ *
217
+ * Note that the _smrt_ prefix alone does NOT mean "system table" — ~25
218
+ * `@smrt()` domain tables (feature flags, prompt overrides, subscription
219
+ * plans, …) carry it too and are created by `db:migrate`, not here. The
220
+ * framework's own list is `SYSTEM_TABLE_NAMES` in
221
+ * `schema/system-table-shapes.ts`, derived from this DDL.
213
222
  *
214
223
  * This method is idempotent and safe to call multiple times.
215
224
  * Tables are only created once per database connection.
@@ -218,6 +227,30 @@ export declare class SmrtClass {
218
227
  private withSystemTableBootstrapLock;
219
228
  private rollbackSystemTableBootstrap;
220
229
  private bootstrapSystemTables;
230
+ /**
231
+ * Run the compatibility pass for the system tables `db:migrate` creates, then
232
+ * stamp its own marker in `_smrt_migrations` (issue #2376).
233
+ *
234
+ * Deliberately runs OUTSIDE the bootstrap lock/transaction, and deliberately
235
+ * cannot fail startup:
236
+ *
237
+ * - `_smrt_jobs` / `_smrt_job_events` are created by `db:migrate` from the
238
+ * jobs manifest, which on a fresh install runs AFTER the framework's first
239
+ * bootstrap. Gating this pass on {@link SMRT_SCHEMA_VERSION} would mean the
240
+ * version gets stamped while the tables are still absent and the pass never
241
+ * runs on that database again. It therefore carries its own marker, which
242
+ * is written only once every deferred table exists and has been upgraded.
243
+ * - Its statements are ALTER TABLE / CREATE INDEX against tables the
244
+ * framework does not own. Inside the PostgreSQL bootstrap transaction one
245
+ * failure (a permissions problem, say) would abort the transaction and roll
246
+ * back system-table creation with it. Outside, a failure is a logged
247
+ * warning and the next process start retries — the pass is idempotent and
248
+ * already tolerates concurrent creators.
249
+ *
250
+ * Until the tables exist this costs two catalog probes per process per
251
+ * database; afterwards the marker short-circuits it.
252
+ */
253
+ private settleDeferredSystemTableCompatibility;
221
254
  private ensureNativeVectorStorage;
222
255
  /**
223
256
  * Access system tables through standard database interface
@@ -1 +1 @@
1
- {"version":3,"file":"class.d.ts","sourceRoot":"","sources":["../src/class.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"class.d.ts","sourceRoot":"","sources":["../src/class.ts"],"names":[],"mappings":"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"}
package/dist/class.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { AiUsageCollector, AiUsagePersistenceHandler } from "./adapters/ai-usage.js";
2
2
  import { estimateAiUsageCost } from "./adapters/cost-rates.js";
3
+ import { toSafeInteger } from "./utils/safe-integer.js";
3
4
  import { detectEngine } from "./schema/ddl/index.js";
4
5
  import { SMRT_SCHEMA_VERSION, getSystemTableDDL } from "./system/schema.js";
5
6
  import { ensurePostgresChangeFeedAppendFunction, registerChangeFeedWriter } from "./change-feed.js";
@@ -7,13 +8,18 @@ import { config } from "./config.js";
7
8
  import { createFilesystemAdapter } from "./filesystem-loader.js";
8
9
  import { applyPostgresRuntimeTimeouts } from "./postgres-timeouts.js";
9
10
  import { SignalBus } from "./signals/bus.js";
10
- import { assertPostgresSystemTimestampsCurrent, ensureLegacySystemTableCompatibility, tableExists } from "./system/compatibility.js";
11
+ import { assertPostgresSystemTimestampsCurrent, ensureBootstrapSystemTableCompatibility, ensureDeferredSystemTableCompatibility, tableExists } from "./system/compatibility.js";
11
12
  import { createLogger } from "@happyvertical/logger";
12
13
  import { getAI } from "@happyvertical/ai";
13
14
  import { getDatabase } from "@happyvertical/sql";
14
15
  //#region src/class.ts
15
16
  var SYSTEM_TABLE_BOOTSTRAP_LOCK_SQL = "SELECT pg_advisory_xact_lock(hashtext('smrt'), hashtext('system-tables'))";
16
17
  /**
18
+ * `_smrt_migrations.version` suffix marking that the deferred (manifest-created)
19
+ * system tables have been through their compatibility pass (issue #2376).
20
+ */
21
+ var DEFERRED_COMPATIBILITY_VERSION_SUFFIX = "+deferred-compat";
22
+ /**
17
23
  * Timeout budget for the PostgreSQL system-table bootstrap transaction.
18
24
  *
19
25
  * The runtime pool's session `lock_timeout`/`statement_timeout` (#2377) are
@@ -69,9 +75,9 @@ function normalizeIncomingAiUsageTokens(value) {
69
75
  };
70
76
  }
71
77
  function hydratePersistedAiUsageTokens(row) {
72
- const promptTokens = typeof row.prompt_tokens === "number" ? row.prompt_tokens : row.prompt_tokens === null || row.prompt_tokens === void 0 ? void 0 : Number(row.prompt_tokens);
73
- const completionTokens = typeof row.completion_tokens === "number" ? row.completion_tokens : row.completion_tokens === null || row.completion_tokens === void 0 ? void 0 : Number(row.completion_tokens);
74
- const totalTokens = typeof row.total_tokens === "number" ? row.total_tokens : row.total_tokens === null || row.total_tokens === void 0 ? void 0 : Number(row.total_tokens);
78
+ const promptTokens = row.prompt_tokens === null || row.prompt_tokens === void 0 ? void 0 : toSafeInteger(row.prompt_tokens, "AI usage prompt tokens");
79
+ const completionTokens = row.completion_tokens === null || row.completion_tokens === void 0 ? void 0 : toSafeInteger(row.completion_tokens, "AI usage completion tokens");
80
+ const totalTokens = row.total_tokens === null || row.total_tokens === void 0 ? void 0 : toSafeInteger(row.total_tokens, "AI usage total tokens");
75
81
  if (promptTokens === void 0 && completionTokens === void 0 && totalTokens === void 0) return;
76
82
  return {
77
83
  promptTokens,
@@ -390,11 +396,20 @@ var SmrtClass = class SmrtClass {
390
396
  /**
391
397
  * Ensure SMRT system tables exist in the database
392
398
  *
393
- * System tables use _smrt_ prefix and store framework metadata:
399
+ * System tables use the _smrt_ prefix and store framework metadata:
394
400
  * - _smrt_contexts: Context memory storage for remembered patterns
395
401
  * - _smrt_migrations: Schema version tracking
396
- * - _smrt_registry: Object registry persistence
397
- * - _smrt_signals: Signal history/audit log
402
+ * - _smrt_schema_migrations / _smrt_backfills: Migration and backfill ledgers
403
+ * - _smrt_embeddings: Embedding vectors for semantic search
404
+ * - _smrt_dispatch / _smrt_dispatch_subscriptions: Inter-agent dispatch queue
405
+ * - _smrt_ai_usage: AI usage telemetry
406
+ * - _smrt_changes: Append-only change feed
407
+ *
408
+ * Note that the _smrt_ prefix alone does NOT mean "system table" — ~25
409
+ * `@smrt()` domain tables (feature flags, prompt overrides, subscription
410
+ * plans, …) carry it too and are created by `db:migrate`, not here. The
411
+ * framework's own list is `SYSTEM_TABLE_NAMES` in
412
+ * `schema/system-table-shapes.ts`, derived from this DDL.
398
413
  *
399
414
  * This method is idempotent and safe to call multiple times.
400
415
  * Tables are only created once per database connection.
@@ -411,6 +426,7 @@ var SmrtClass = class SmrtClass {
411
426
  } else if (SmrtClass._systemTablesInitializedByUrl.has(dbUrl)) return;
412
427
  try {
413
428
  await this.withSystemTableBootstrapLock((db) => this.bootstrapSystemTables(db));
429
+ await this.settleDeferredSystemTableCompatibility(this._db);
414
430
  await this.ensureNativeVectorStorage();
415
431
  if (useInstanceTracking) SmrtClass._systemTablesInitialized.add(this._db);
416
432
  else SmrtClass._systemTablesInitializedByUrl.add(dbUrl);
@@ -455,7 +471,7 @@ var SmrtClass = class SmrtClass {
455
471
  async bootstrapSystemTables(db) {
456
472
  const version = SMRT_SCHEMA_VERSION;
457
473
  if (await this.isSystemSchemaVersionApplied(db, version)) return;
458
- await ensureLegacySystemTableCompatibility(db, this._dbEngineHint);
474
+ await ensureBootstrapSystemTableCompatibility(db, this._dbEngineHint);
459
475
  const engine = detectEngine(getDatabaseUrl(db), this._dbEngineHint);
460
476
  const allStatements = [];
461
477
  for (const multiStatementSQL of getSystemTableDDL(engine)) {
@@ -472,6 +488,45 @@ var SmrtClass = class SmrtClass {
472
488
  ON CONFLICT(version) DO NOTHING
473
489
  `;
474
490
  }
491
+ /**
492
+ * Run the compatibility pass for the system tables `db:migrate` creates, then
493
+ * stamp its own marker in `_smrt_migrations` (issue #2376).
494
+ *
495
+ * Deliberately runs OUTSIDE the bootstrap lock/transaction, and deliberately
496
+ * cannot fail startup:
497
+ *
498
+ * - `_smrt_jobs` / `_smrt_job_events` are created by `db:migrate` from the
499
+ * jobs manifest, which on a fresh install runs AFTER the framework's first
500
+ * bootstrap. Gating this pass on {@link SMRT_SCHEMA_VERSION} would mean the
501
+ * version gets stamped while the tables are still absent and the pass never
502
+ * runs on that database again. It therefore carries its own marker, which
503
+ * is written only once every deferred table exists and has been upgraded.
504
+ * - Its statements are ALTER TABLE / CREATE INDEX against tables the
505
+ * framework does not own. Inside the PostgreSQL bootstrap transaction one
506
+ * failure (a permissions problem, say) would abort the transaction and roll
507
+ * back system-table creation with it. Outside, a failure is a logged
508
+ * warning and the next process start retries — the pass is idempotent and
509
+ * already tolerates concurrent creators.
510
+ *
511
+ * Until the tables exist this costs two catalog probes per process per
512
+ * database; afterwards the marker short-circuits it.
513
+ */
514
+ async settleDeferredSystemTableCompatibility(db) {
515
+ const marker = `${SMRT_SCHEMA_VERSION}${DEFERRED_COMPATIBILITY_VERSION_SUFFIX}`;
516
+ try {
517
+ if (await this.isSystemSchemaVersionApplied(db, marker)) return;
518
+ const { settled } = await ensureDeferredSystemTableCompatibility(db, this._dbEngineHint);
519
+ if (!settled) return;
520
+ const id = crypto.randomUUID();
521
+ await db.execute`
522
+ INSERT INTO _smrt_migrations (id, version, description)
523
+ VALUES (${id}, ${marker}, ${"Deferred SMRT system table compatibility"})
524
+ ON CONFLICT(version) DO NOTHING
525
+ `;
526
+ } catch (error) {
527
+ logger.warn(`[smrt] Deferred system table compatibility did not complete; retrying on the next start: ${error instanceof Error ? error.message : String(error)}`);
528
+ }
529
+ }
475
530
  async ensureNativeVectorStorage() {
476
531
  if (!this._db) return;
477
532
  try {
@@ -631,7 +686,7 @@ var SmrtClass = class SmrtClass {
631
686
  operation: String(row.operation),
632
687
  usage: hydratePersistedAiUsageTokens(row),
633
688
  estimatedCost: row.estimated_cost === null || row.estimated_cost === void 0 ? void 0 : Number(row.estimated_cost),
634
- duration: Number(row.duration ?? 0),
689
+ duration: toSafeInteger(row.duration ?? 0, "AI usage duration"),
635
690
  className: row.class_name === null || row.class_name === void 0 ? void 0 : String(row.class_name),
636
691
  tenantId: row.tenant_id === void 0 ? void 0 : row.tenant_id,
637
692
  tags: parseAiUsageTags(row.tags),
@@ -664,11 +719,11 @@ var SmrtClass = class SmrtClass {
664
719
  for (const row of rows) {
665
720
  const bucket = String(row.bucket);
666
721
  summary[bucket] = {
667
- callCount: Number(row.call_count ?? 0),
668
- promptTokens: Number(row.prompt_tokens ?? 0),
669
- completionTokens: Number(row.completion_tokens ?? 0),
670
- totalTokens: Number(row.total_tokens ?? 0),
671
- totalDuration: Number(row.total_duration ?? 0),
722
+ callCount: toSafeInteger(row.call_count ?? 0, "AI usage call count"),
723
+ promptTokens: toSafeInteger(row.prompt_tokens ?? 0, "AI usage prompt-token total"),
724
+ completionTokens: toSafeInteger(row.completion_tokens ?? 0, "AI usage completion-token total"),
725
+ totalTokens: toSafeInteger(row.total_tokens ?? 0, "AI usage token total"),
726
+ totalDuration: toSafeInteger(row.total_duration ?? 0, "AI usage duration total"),
672
727
  estimatedCost: Number(row.estimated_cost ?? 0),
673
728
  lastUsed: row.last_used ? new Date(String(row.last_used)).getTime() : 0
674
729
  };