@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.
- package/AGENTS.md +3 -3
- package/agents/change-feed.md +1 -1
- package/agents/schema-paths.md +561 -32
- package/dist/browser.js +5 -4
- package/dist/cascade.d.ts +120 -0
- package/dist/cascade.d.ts.map +1 -0
- package/dist/cascade.js +430 -0
- package/dist/cascade.js.map +1 -0
- package/dist/change-feed.d.ts +34 -2
- package/dist/change-feed.d.ts.map +1 -1
- package/dist/change-feed.js +54 -13
- package/dist/change-feed.js.map +1 -1
- package/dist/change-signals.d.ts.map +1 -1
- package/dist/change-signals.js +8 -1
- package/dist/change-signals.js.map +1 -1
- package/dist/class.d.ts +36 -3
- package/dist/class.d.ts.map +1 -1
- package/dist/class.js +69 -14
- package/dist/class.js.map +1 -1
- package/dist/collection-cache.js +0 -0
- package/dist/collection-cache.js.map +1 -1
- package/dist/collection.d.ts +29 -0
- package/dist/collection.d.ts.map +1 -1
- package/dist/collection.js +68 -16
- package/dist/collection.js.map +1 -1
- package/dist/config.d.ts +10 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js.map +1 -1
- package/dist/decorators/index.d.ts +63 -5
- package/dist/decorators/index.d.ts.map +1 -1
- package/dist/decorators/index.js +37 -5
- package/dist/decorators/index.js.map +1 -1
- package/dist/dispatch/collections/Dispatches.d.ts.map +1 -1
- package/dist/dispatch/collections/Dispatches.js +21 -6
- package/dist/dispatch/collections/Dispatches.js.map +1 -1
- package/dist/dispatch/models/Dispatch.d.ts +1 -1
- package/dist/dispatch/models/Dispatch.d.ts.map +1 -1
- package/dist/dispatch/models/Dispatch.js +3 -2
- package/dist/dispatch/models/Dispatch.js.map +1 -1
- package/dist/dispatch/models/DispatchSubscription.d.ts +1 -1
- package/dist/dispatch/models/DispatchSubscription.d.ts.map +1 -1
- package/dist/dispatch/models/DispatchSubscription.js +2 -1
- package/dist/dispatch/models/DispatchSubscription.js.map +1 -1
- package/dist/dispatch/types.d.ts +5 -0
- package/dist/dispatch/types.d.ts.map +1 -1
- package/dist/embedded-write-queue.d.ts +46 -0
- package/dist/embedded-write-queue.d.ts.map +1 -0
- package/dist/embedded-write-queue.js +66 -0
- package/dist/embedded-write-queue.js.map +1 -0
- package/dist/embeddings/storage.d.ts +7 -0
- package/dist/embeddings/storage.d.ts.map +1 -1
- package/dist/embeddings/storage.js +31 -13
- package/dist/embeddings/storage.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/hierarchical.js +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -6
- package/dist/interceptors.d.ts +21 -0
- package/dist/interceptors.d.ts.map +1 -1
- package/dist/interceptors.js +27 -1
- package/dist/interceptors.js.map +1 -1
- package/dist/learning/memory.d.ts.map +1 -1
- package/dist/learning/memory.js +5 -4
- package/dist/learning/memory.js.map +1 -1
- package/dist/manifest/generator.d.ts.map +1 -1
- package/dist/manifest/generator.js +4 -7
- package/dist/manifest/generator.js.map +1 -1
- package/dist/manifest/static-manifest.js +10 -10
- package/dist/manifest/static-manifest.js.map +1 -1
- package/dist/manifest/store.js +1 -1
- package/dist/manifest/store.js.map +1 -1
- package/dist/manifest.json +19 -19
- package/dist/migrations/differ.d.ts +9 -1
- package/dist/migrations/differ.d.ts.map +1 -1
- package/dist/migrations/differ.js +12 -7
- package/dist/migrations/differ.js.map +1 -1
- package/dist/migrations/index.d.ts +2 -0
- package/dist/migrations/index.d.ts.map +1 -1
- package/dist/migrations/index.js +3 -1
- package/dist/migrations/integer-width.d.ts +99 -0
- package/dist/migrations/integer-width.d.ts.map +1 -0
- package/dist/migrations/integer-width.js +237 -0
- package/dist/migrations/integer-width.js.map +1 -0
- package/dist/migrations/minor-units.d.ts +162 -0
- package/dist/migrations/minor-units.d.ts.map +1 -0
- package/dist/migrations/minor-units.js +383 -0
- package/dist/migrations/minor-units.js.map +1 -0
- package/dist/migrations/tracker.d.ts.map +1 -1
- package/dist/migrations/tracker.js +28 -20
- package/dist/migrations/tracker.js.map +1 -1
- package/dist/migrations.js +3 -1
- package/dist/object.d.ts +98 -5
- package/dist/object.d.ts.map +1 -1
- package/dist/object.js +138 -30
- package/dist/object.js.map +1 -1
- package/dist/query-bounds.d.ts +5 -1
- package/dist/query-bounds.d.ts.map +1 -1
- package/dist/query-bounds.js +5 -1
- package/dist/query-bounds.js.map +1 -1
- package/dist/registry/class-registration.d.ts.map +1 -1
- package/dist/registry/class-registration.js +3 -1
- package/dist/registry/class-registration.js.map +1 -1
- package/dist/registry/manifest-field-merge.d.ts +12 -0
- package/dist/registry/manifest-field-merge.d.ts.map +1 -1
- package/dist/registry/manifest-field-merge.js +14 -2
- package/dist/registry/manifest-field-merge.js.map +1 -1
- package/dist/registry/schema-builder.d.ts +22 -1
- package/dist/registry/schema-builder.d.ts.map +1 -1
- package/dist/registry/schema-builder.js +205 -165
- package/dist/registry/schema-builder.js.map +1 -1
- package/dist/registry/types.d.ts +3 -2
- package/dist/registry/types.d.ts.map +1 -1
- package/dist/registry.d.ts +41 -46
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +61 -83
- package/dist/registry.js.map +1 -1
- package/dist/scanner/manifest-generator.d.ts +45 -0
- package/dist/scanner/manifest-generator.d.ts.map +1 -1
- package/dist/scanner/manifest-generator.js +92 -28
- package/dist/scanner/manifest-generator.js.map +1 -1
- package/dist/schema/conflict-target.d.ts +104 -0
- package/dist/schema/conflict-target.d.ts.map +1 -0
- package/dist/schema/conflict-target.js +129 -0
- package/dist/schema/conflict-target.js.map +1 -0
- package/dist/schema/ddl/duckdb-strategy.d.ts.map +1 -1
- package/dist/schema/ddl/duckdb-strategy.js +3 -1
- package/dist/schema/ddl/duckdb-strategy.js.map +1 -1
- package/dist/schema/ddl/postgres-strategy.d.ts.map +1 -1
- package/dist/schema/ddl/postgres-strategy.js +14 -1
- package/dist/schema/ddl/postgres-strategy.js.map +1 -1
- package/dist/schema/generator.d.ts +245 -41
- package/dist/schema/generator.d.ts.map +1 -1
- package/dist/schema/generator.js +397 -241
- package/dist/schema/generator.js.map +1 -1
- package/dist/schema/index-utils.d.ts +120 -0
- package/dist/schema/index-utils.d.ts.map +1 -1
- package/dist/schema/index-utils.js +242 -1
- package/dist/schema/index-utils.js.map +1 -1
- package/dist/schema/index.d.ts +2 -3
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema/index.js +2 -3
- package/dist/schema/live-parity.d.ts +3 -1
- package/dist/schema/live-parity.d.ts.map +1 -1
- package/dist/schema/live-parity.js +23 -2
- package/dist/schema/live-parity.js.map +1 -1
- package/dist/schema/system-table-shapes.d.ts +13 -0
- package/dist/schema/system-table-shapes.d.ts.map +1 -1
- package/dist/schema/system-table-shapes.js +14 -1
- package/dist/schema/system-table-shapes.js.map +1 -1
- package/dist/schema/types.d.ts +17 -10
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/schema/utils.d.ts +1 -1
- package/dist/schema/utils.d.ts.map +1 -1
- package/dist/schema/utils.js +3 -3
- package/dist/schema/utils.js.map +1 -1
- package/dist/schema.js +2 -3
- package/dist/smrt-knowledge.json +9 -9
- package/dist/system/compatibility.d.ts +42 -0
- package/dist/system/compatibility.d.ts.map +1 -1
- package/dist/system/compatibility.js +182 -9
- package/dist/system/compatibility.js.map +1 -1
- package/dist/system/index.d.ts +1 -0
- package/dist/system/index.d.ts.map +1 -1
- package/dist/system/index.js +3 -2
- package/dist/system/retention.d.ts +237 -0
- package/dist/system/retention.d.ts.map +1 -0
- package/dist/system/retention.js +497 -0
- package/dist/system/retention.js.map +1 -0
- package/dist/system/schema.d.ts +102 -16
- package/dist/system/schema.d.ts.map +1 -1
- package/dist/system/schema.js +85 -47
- package/dist/system/schema.js.map +1 -1
- package/dist/system/types.d.ts +0 -2
- package/dist/system/types.d.ts.map +1 -1
- package/dist/testing/database.d.ts.map +1 -1
- package/dist/testing/database.js.map +1 -1
- package/dist/utils/safe-integer.d.ts +19 -0
- package/dist/utils/safe-integer.d.ts.map +1 -0
- package/dist/utils/safe-integer.js +31 -0
- package/dist/utils/safe-integer.js.map +1 -0
- package/dist/utils.d.ts +2 -1
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +6 -3
- package/dist/utils.js.map +1 -1
- package/dist/vite-plugin/index.d.ts.map +1 -1
- package/dist/vite-plugin/index.js +4 -62
- package/dist/vite-plugin/index.js.map +1 -1
- package/package.json +4 -4
- package/dist/schema/override-system.d.ts +0 -43
- package/dist/schema/override-system.d.ts.map +0 -1
- package/dist/schema/override-system.js +0 -205
- package/dist/schema/override-system.js.map +0 -1
package/dist/change-signals.js
CHANGED
|
@@ -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:
|
|
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
|
-
* -
|
|
212
|
-
* -
|
|
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
|
package/dist/class.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"class.d.ts","sourceRoot":"","sources":["../src/class.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AACzD,OAAO,EAAE,KAAK,QAAQ,EAAS,MAAM,mBAAmB,CAAC;AACzD,OAAO,KAAK,EACV,iBAAiB,EACjB,wBAAwB,EACzB,MAAM,sBAAsB,CAAC;AAC9B,OAAO,EAAgB,KAAK,YAAY,EAAE,MAAM,uBAAuB,CAAC;AACxE,OAAO,KAAK,EAGV,kBAAkB,EAClB,eAAe,EACf,YAAY,EACZ,qBAAqB,EACrB,aAAa,EAEb,iBAAiB,EAClB,MAAM,2BAA2B,CAAC;AACnC,OAAO,EACL,KAAK,iBAAiB,EAGvB,MAAM,oBAAoB,CAAC;AAU5B,OAAO,KAAK,EAEV,aAAa,EAEb,aAAa,EACb,YAAY,EACb,MAAM,aAAa,CAAC;AAErB,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAIpD,OAAO,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;
|
|
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,
|
|
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 =
|
|
73
|
-
const completionTokens =
|
|
74
|
-
const totalTokens =
|
|
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
|
-
* -
|
|
397
|
-
* -
|
|
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
|
|
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:
|
|
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:
|
|
668
|
-
promptTokens:
|
|
669
|
-
completionTokens:
|
|
670
|
-
totalTokens:
|
|
671
|
-
totalDuration:
|
|
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
|
};
|