@happyvertical/smrt-core 0.49.8 → 0.51.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 +1 -0
- package/dist/dispatch/bus.d.ts.map +1 -1
- package/dist/dispatch/bus.js +15 -12
- package/dist/dispatch/bus.js.map +1 -1
- package/dist/dispatch/collections/DispatchSubscriptions.d.ts +7 -2
- package/dist/dispatch/collections/DispatchSubscriptions.d.ts.map +1 -1
- package/dist/dispatch/collections/DispatchSubscriptions.js +9 -8
- package/dist/dispatch/collections/DispatchSubscriptions.js.map +1 -1
- package/dist/dispatch/collections/Dispatches.d.ts +12 -2
- package/dist/dispatch/collections/Dispatches.d.ts.map +1 -1
- package/dist/dispatch/collections/Dispatches.js +14 -8
- package/dist/dispatch/collections/Dispatches.js.map +1 -1
- package/dist/dispatch/types.d.ts +16 -1
- package/dist/dispatch/types.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/knowledge-graph.d.ts +95 -0
- package/dist/knowledge-graph.d.ts.map +1 -0
- package/dist/knowledge-graph.js +268 -0
- package/dist/knowledge-graph.js.map +1 -0
- package/dist/knowledge.d.ts +1 -0
- package/dist/knowledge.d.ts.map +1 -1
- package/dist/knowledge.js +57 -4
- package/dist/knowledge.js.map +1 -1
- package/dist/manifest/static-manifest.js +1 -1
- package/dist/manifest/static-manifest.js.map +1 -1
- package/dist/manifest/store.js +1 -1
- package/dist/manifest.json +1 -1
- package/dist/migrations/backfill-tracker.d.ts +7 -0
- package/dist/migrations/backfill-tracker.d.ts.map +1 -1
- package/dist/migrations/backfill-tracker.js +20 -1
- package/dist/migrations/backfill-tracker.js.map +1 -1
- package/dist/migrations/differ.d.ts +50 -18
- package/dist/migrations/differ.d.ts.map +1 -1
- package/dist/migrations/differ.js +136 -50
- package/dist/migrations/differ.js.map +1 -1
- package/dist/registry/types.d.ts +21 -1
- package/dist/registry/types.d.ts.map +1 -1
- package/dist/schema/live-parity.d.ts.map +1 -1
- package/dist/schema/live-parity.js +133 -36
- package/dist/schema/live-parity.js.map +1 -1
- package/dist/smrt-knowledge.json +12 -7
- package/dist/system/bootstrap.d.ts +23 -0
- package/dist/system/bootstrap.d.ts.map +1 -1
- package/dist/system/bootstrap.js +52 -17
- package/dist/system/bootstrap.js.map +1 -1
- package/dist/vite-plugin/index.d.ts +3 -1
- package/dist/vite-plugin/index.d.ts.map +1 -1
- package/dist/vite-plugin/index.js +9 -2
- package/dist/vite-plugin/index.js.map +1 -1
- package/dist/vite-plugin/sveltekit-generator.d.ts +26 -5
- package/dist/vite-plugin/sveltekit-generator.d.ts.map +1 -1
- package/dist/vite-plugin/sveltekit-generator.js +48 -14
- package/dist/vite-plugin/sveltekit-generator.js.map +1 -1
- package/package.json +10 -10
package/AGENTS.md
CHANGED
|
@@ -19,6 +19,7 @@ and repository rules.
|
|
|
19
19
|
| `src/change-signals.ts` | Signal bus, replica fan-out, SSE | [agents/change-signals.md](agents/change-signals.md) |
|
|
20
20
|
| `src/generators/`, `src/vite-plugin/web-collections.ts` | REST/CLI/MCP generation, manifest hashes, ETags | [agents/generators.md](agents/generators.md) |
|
|
21
21
|
| `src/vite-plugin/`, `src/consumer-plugin/`, `src/knowledge.ts` | Decorator UI hints, knowledge projection, generation snapshots | [agents/build-knowledge.md](agents/build-knowledge.md) |
|
|
22
|
+
| `src/knowledge-graph.ts` | Cross-package knowledge graph merge and freshness check (#2863) — see [standards](../../docs/content/standards.md) | — |
|
|
22
23
|
| `src/object.ts`, `src/collection.ts`, `src/learning/memory.ts` | Context memory and semantic search | [agents/memory.md](agents/memory.md) |
|
|
23
24
|
| `src/system/diagnostics.ts` | SELECT-only `_smrt_*` diagnostics reader behind smrt-dev-mcp runtime tools (#1824) | [agents/system-diagnostics.md](agents/system-diagnostics.md) |
|
|
24
25
|
| `src/system/registry-snapshot.ts` | Sanitized plain-JSON projection of the booted `ObjectRegistry` for the smrt-dev-mcp runtime dev-plane (#1831); never constructors, validators, values, or absolute paths | [agents/registry-snapshot.md](agents/registry-snapshot.md) |
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"bus.d.ts","sourceRoot":"","sources":["../../src/dispatch/bus.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAGH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;
|
|
1
|
+
{"version":3,"file":"bus.d.ts","sourceRoot":"","sources":["../../src/dispatch/bus.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AAGH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAiB5D,OAAO,EAAE,QAAQ,EAAE,MAAM,sBAAsB,CAAC;AAChD,OAAO,EAAE,oBAAoB,EAAE,MAAM,kCAAkC,CAAC;AAKxE,OAAO,KAAK,EACV,kBAAkB,EAClB,sBAAsB,EACtB,qBAAqB,EACrB,mBAAmB,EACnB,eAAe,EACf,mBAAmB,EAEnB,sBAAsB,EACtB,oBAAoB,EACpB,wBAAwB,EACzB,MAAM,YAAY,CAAC;AAapB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AACH,qBAAa,WAAW;IACtB,OAAO,CAAC,EAAE,CAAoB;IAC9B,OAAO,CAAC,QAAQ,CAA+C;IAC/D,OAAO,CAAC,WAAW,CAAkB;IAErC;;;;;OAKG;IACH,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,mBAAmB,CAAiB;IAE5D,wEAAwE;IACxE,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,iBAAiB,CAAO;IAEhD;;;;;;;;;;;OAWG;IACH,OAAO,CAAC,MAAM,CAAC,cAAc;IAa7B;;OAEG;gBACS,EAAE,EAAE,iBAAiB;IAIjC;;OAEG;IACG,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC;IAkFjC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAoDG;IACG,IAAI,CACR,IAAI,EAAE,MAAM,EACZ,OAAO,EAAE,OAAO,EAChB,OAAO,GAAE,mBAAwB,GAChC,OAAO,CAAC,QAAQ,CAAC;IA0EpB;;;;;;;;;;;;;;;;;;;;;;;;;OAyBG;IACH,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,eAAe,GAAG,IAAI;IAenD;;;;;;OAMG;IACH,GAAG,CAAC,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,eAAe,GAAG,OAAO;IAcvD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OAsCG;IACG,SAAS,CAAC,OAAO,EAAE,wBAAwB,GAAG,OAAO,CAAC,IAAI,CAAC;IAmCjE;;;;;OAKG;IACG,WAAW,CAAC,UAAU,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAYxE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;OA+BG;IACG,OAAO,CACX,UAAU,EAAE,MAAM,EAClB,OAAO,EAAE,eAAe,EACxB,OAAO,GAAE,sBAA2B,GACnC,OAAO,CAAC,MAAM,CAAC;IAyIlB;;;;;;;OAOG;IACG,KAAK,CAAC,OAAO,GAAE,oBAAyB,GAAG,OAAO,CAAC,MAAM,CAAC;IAqBhE;;;;;OAKG;IACG,OAAO,CACX,OAAO,GAAE,sBAA2B,GACnC,OAAO,CAAC,qBAAqB,CAAC;IAYjC;;;;;;;;;;;;;;OAcG;IACG,IAAI,CAAC,OAAO,GAAE,mBAAwB,GAAG,OAAO,CAAC,QAAQ,EAAE,CAAC;IAUlE;;;;;;;OAOG;IACG,GAAG,CAAC,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC;IAS/C;;OAEG;IACG,iBAAiB,CACrB,UAAU,CAAC,EAAE,MAAM,GAClB,OAAO,CAAC,oBAAoB,EAAE,CAAC;IAkBlC;;OAEG;IACH,OAAO,CAAC,cAAc;CAqCvB;AAED;;;;;GAKG;AACH,wBAAsB,iBAAiB,CACrC,OAAO,GAAE,kBAAuB,GAC/B,OAAO,CAAC,WAAW,CAAC,CAwCtB"}
|
package/dist/dispatch/bus.js
CHANGED
|
@@ -2,6 +2,7 @@ import { resolveDispatchTenantId, resolveDispatchTenantScope } from "./tenant-re
|
|
|
2
2
|
import { CREATE_SMRT_DISPATCH_SUBSCRIPTIONS_TABLE, CREATE_SMRT_DISPATCH_TABLE, getSystemTableDDLForEngine } from "../system/schema.js";
|
|
3
3
|
import { applyPostgresRuntimeTimeouts } from "../postgres-timeouts.js";
|
|
4
4
|
import { assertPostgresSystemTimestampsCurrent, ensureDispatchSubscriptionsSystemTableCompatibility, ensureDispatchSystemTableCompatibility, getDatabaseEngine } from "../system/compatibility.js";
|
|
5
|
+
import { runSerializedAgainstSystemTableBootstrap } from "../system/bootstrap.js";
|
|
5
6
|
import { Dispatch } from "./models/Dispatch.js";
|
|
6
7
|
import { DispatchCollection } from "./collections/Dispatches.js";
|
|
7
8
|
import { DispatchSubscription } from "./models/DispatchSubscription.js";
|
|
@@ -119,18 +120,20 @@ var DispatchBus = class DispatchBus {
|
|
|
119
120
|
async initialize() {
|
|
120
121
|
if (this.initialized) return;
|
|
121
122
|
const engine = getDatabaseEngine(this.db);
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
123
|
+
await runSerializedAgainstSystemTableBootstrap(this.db, engine, async (tx) => {
|
|
124
|
+
if (!await DispatchCollection.tableExists(tx, engine)) {
|
|
125
|
+
const statements = getSystemTableDDLForEngine(CREATE_SMRT_DISPATCH_TABLE, engine).split(";").filter((s) => s.trim());
|
|
126
|
+
for (const stmt of statements) await tx.query(stmt);
|
|
127
|
+
}
|
|
128
|
+
await assertPostgresSystemTimestampsCurrent(tx, engine);
|
|
129
|
+
await ensureDispatchSystemTableCompatibility(tx, engine);
|
|
130
|
+
if (!await DispatchSubscriptionCollection.tableExists(tx, engine)) {
|
|
131
|
+
const statements = getSystemTableDDLForEngine(CREATE_SMRT_DISPATCH_SUBSCRIPTIONS_TABLE, engine).split(";").filter((s) => s.trim());
|
|
132
|
+
for (const stmt of statements) await tx.query(stmt);
|
|
133
|
+
}
|
|
134
|
+
await assertPostgresSystemTimestampsCurrent(tx, engine);
|
|
135
|
+
await ensureDispatchSubscriptionsSystemTableCompatibility(tx, engine);
|
|
136
|
+
});
|
|
134
137
|
this.initialized = true;
|
|
135
138
|
}
|
|
136
139
|
/**
|
package/dist/dispatch/bus.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"bus.js","names":[],"sources":["../../src/dispatch/bus.ts"],"sourcesContent":["/**\n * DispatchBus - Central hub for inter-agent communication\n *\n * The DispatchBus provides both in-memory handlers and persistent subscriptions\n * for asynchronous agent-to-agent messaging.\n *\n * @example\n * ```typescript\n * const bus = await createDispatchBus({ db: { type: 'sqlite', url: 'app.db' } });\n *\n * // In-memory handler (immediate)\n * bus.on('campaign.completed', async (payload, metadata) => {\n * console.log(`Campaign ${payload.campaignId} completed`);\n * });\n *\n * // Persistent subscription (processed later)\n * await bus.subscribe({ signalType: 'campaign.*', subscriber: 'fiscus' });\n *\n * // Emit a dispatch\n * await bus.emit('campaign.completed', { campaignId: '123' }, { source: 'suasor' });\n *\n * // Process pending dispatches\n * await bus.process('fiscus');\n * ```\n */\n\nimport { createLogger } from '@happyvertical/logger';\nimport type { DatabaseInterface } from '@happyvertical/sql';\nimport { getDatabase } from '@happyvertical/sql';\nimport { applyPostgresRuntimeTimeouts } from '../postgres-timeouts.js';\nimport {\n assertPostgresSystemTimestampsCurrent,\n ensureDispatchSubscriptionsSystemTableCompatibility,\n ensureDispatchSystemTableCompatibility,\n getDatabaseEngine,\n} from '../system/compatibility.js';\nimport {\n CREATE_SMRT_DISPATCH_SUBSCRIPTIONS_TABLE,\n CREATE_SMRT_DISPATCH_TABLE,\n getSystemTableDDLForEngine,\n} from '../system/schema.js';\nimport { DispatchCollection } from './collections/Dispatches.js';\nimport { DispatchSubscriptionCollection } from './collections/DispatchSubscriptions.js';\nimport { Dispatch } from './models/Dispatch.js';\nimport { DispatchSubscription } from './models/DispatchSubscription.js';\nimport {\n resolveDispatchTenantId,\n resolveDispatchTenantScope,\n} from './tenant-resolver.js';\nimport type {\n DispatchBusOptions,\n DispatchCleanupOptions,\n DispatchCleanupResult,\n DispatchEmitOptions,\n DispatchHandler,\n DispatchListOptions,\n DispatchMetadata,\n DispatchProcessOptions,\n DispatchRetryOptions,\n DispatchSubscribeOptions,\n} from './types.js';\n\nconst logger = createLogger({ level: 'info' });\n\n/**\n * Registered in-memory handler\n */\ninterface RegisteredHandler {\n pattern: string;\n handler: DispatchHandler;\n subscription: DispatchSubscription;\n}\n\n/**\n * Central hub for inter-agent messaging with both in-memory and persistent delivery.\n *\n * The `DispatchBus` combines two complementary delivery models:\n *\n * - **In-memory handlers** (`on(pattern, handler)`) — called synchronously (fire-and-forget)\n * when a dispatch is emitted. Fast, but lost on process restart.\n * - **Persistent subscriptions** (`subscribe({ signalType, subscriber })`) — stored in\n * `_smrt_dispatch_subscriptions` and processed later by calling `process(subscriber, handler)`.\n * Survive restarts; suitable for background workers and scheduled agents.\n *\n * Signal types support single-segment wildcards: `'campaign.*'` matches `'campaign.completed'`\n * and `'campaign.failed'`, but not `'campaign.phase.two'`.\n *\n * Dispatch lifecycle: `pending → processing → completed` (or `failed` on handler error).\n * Use `retry()` to reset failed dispatches back to `pending`.\n *\n * Create instances via `createDispatchBus()` — do not use `new DispatchBus()` directly\n * in application code.\n *\n * @example\n * ```typescript\n * const bus = await createDispatchBus({ db: myDb });\n *\n * // In-memory: immediate, fire-and-forget\n * bus.on('invoice.paid', async (payload) => {\n * console.log('Invoice paid:', payload.invoiceId);\n * });\n *\n * // Persistent: processed by 'notifications' agent on next run\n * await bus.subscribe({ signalType: 'invoice.*', subscriber: 'notifications' });\n *\n * // Emit (notifies in-memory handlers immediately, stores persistent dispatch)\n * await bus.emit('invoice.paid', { invoiceId: 'inv-001' }, { source: 'billing' });\n *\n * // Later, in the notifications agent:\n * await bus.process('notifications', async (payload) => {\n * await sendEmail(payload.invoiceId);\n * });\n * ```\n */\nexport class DispatchBus {\n private db: DatabaseInterface;\n private handlers: Map<string, RegisteredHandler[]> = new Map();\n private initialized: boolean = false;\n\n /**\n * Reserved subscriber name used internally for in-memory `on()` handlers.\n * Callers may not register persistent subscriptions under this name, nor\n * assert it as an emit `source`, so they cannot impersonate the in-memory\n * pseudo-subscriber (S5 #1398).\n */\n private static readonly RESERVED_SUBSCRIBER = '_in_memory_';\n\n /** Maximum stored length of a caller-asserted dispatch source label. */\n private static readonly MAX_SOURCE_LENGTH = 256;\n\n /**\n * Normalize a caller-asserted `source` into untrusted metadata.\n *\n * `source` is a declared label, not an authenticated identity, so it must not\n * be trusted for authorization. This caps its length and **rejects** the\n * reserved internal sentinel (`_in_memory_`) by throwing — the contract is\n * that the sentinel is not an accepted source, so a caller cannot quietly\n * impersonate the in-memory subscriber pseudo-source (S5 #1398). Empty/missing\n * values default to `'unknown'` (unchanged behavior).\n *\n * @throws Error if `source` is the reserved in-memory sentinel.\n */\n private static sanitizeSource(source: string | undefined): string {\n const raw = (source ?? '').trim();\n if (!raw) {\n return 'unknown';\n }\n if (raw === DispatchBus.RESERVED_SUBSCRIBER) {\n throw new Error(\n `DispatchBus.emit: \"${DispatchBus.RESERVED_SUBSCRIBER}\" is a reserved source and cannot be asserted`,\n );\n }\n return raw.slice(0, DispatchBus.MAX_SOURCE_LENGTH);\n }\n\n /**\n * Create a new DispatchBus (use createDispatchBus factory instead)\n */\n constructor(db: DatabaseInterface) {\n this.db = db;\n }\n\n /**\n * Initialize the dispatch tables\n */\n async initialize(): Promise<void> {\n if (this.initialized) return;\n const engine = getDatabaseEngine(this.db);\n\n // Create dispatch tables if they don't exist\n const dispatchExists = await DispatchCollection.tableExists(this.db);\n if (!dispatchExists) {\n // Split the DDL into separate statements and execute each\n const statements = getSystemTableDDLForEngine(\n CREATE_SMRT_DISPATCH_TABLE,\n engine,\n )\n .split(';')\n .filter((s) => s.trim());\n for (const stmt of statements) {\n await this.db.query(stmt);\n }\n }\n await assertPostgresSystemTimestampsCurrent(this.db);\n await ensureDispatchSystemTableCompatibility(this.db);\n\n const subsExists = await DispatchSubscriptionCollection.tableExists(\n this.db,\n );\n if (!subsExists) {\n const statements = getSystemTableDDLForEngine(\n CREATE_SMRT_DISPATCH_SUBSCRIPTIONS_TABLE,\n engine,\n )\n .split(';')\n .filter((s) => s.trim());\n for (const stmt of statements) {\n await this.db.query(stmt);\n }\n }\n await assertPostgresSystemTimestampsCurrent(this.db);\n await ensureDispatchSubscriptionsSystemTableCompatibility(this.db);\n\n this.initialized = true;\n }\n\n /**\n * Emits a dispatch message.\n *\n * Two things happen atomically (from the caller's perspective):\n * 1. One or more `Dispatch` rows are inserted with `status: 'pending'` into `_smrt_dispatch`.\n * 2. All matching in-memory handlers (registered via `on()`) are invoked\n * fire-and-forget (errors are caught and logged, not thrown back to the caller).\n *\n * Persistent subscriptions registered via `subscribe()` will see this dispatch\n * the next time `process(subscriber, handler)` is called for their subscriber name.\n *\n * **Delivery modes** affect how dispatches are created:\n * - `compete` subscribers share a single dispatch (`target_subscriber = NULL`).\n * First subscriber to `process()` claims it (at-most-once delivery).\n * - `fanout` subscribers each get their own dispatch copy (`target_subscriber` set\n * to the subscriber name), so each processes independently.\n *\n * If no subscriptions exist, one dispatch is created for future processing.\n *\n * **Security (S5 #1398):**\n * - `tenant_id` is derived **server-side** from the active tenant context and\n * is never read from caller options, so it cannot be spoofed. Subscribers in\n * a different tenant cannot see or claim this dispatch (see {@link process}).\n * When there is no active tenant context (system/global), `tenant_id` is\n * `NULL` and the dispatch is visible to all scopes — preserving pre-tenancy\n * behavior.\n * - `options.source` is **caller-asserted, untrusted metadata** — it is a\n * declared label only and must not be relied on as an authenticated emitter\n * identity. It is length-capped and the reserved internal sentinel\n * (`_in_memory_`) is rejected so a caller cannot impersonate the in-memory\n * subscriber pseudo-source.\n *\n * @param type - Signal type string, e.g. `'campaign.completed'` or `'invoice.paid'`\n * @param payload - Any JSON-serializable data to attach to the dispatch\n * @param options.source - Declared (untrusted) name of the emitting agent/component (default `'unknown'`)\n * @param options.sourceId - Optional ID of the specific emitting entity\n * @param options.metadata - Optional additional JSON metadata for the dispatch record\n * @returns A persisted `Dispatch` instance (the compete dispatch, or the first fanout copy if fanout-only)\n *\n * @example\n * ```typescript\n * const dispatch = await bus.emit(\n * 'campaign.completed',\n * { campaignId: 'cmp-001', impressions: 10_000 },\n * { source: 'suasor', sourceId: agentId },\n * );\n * console.log(dispatch.id); // UUID of the created dispatch record\n * ```\n *\n * @see {@link on} for in-memory handlers\n * @see {@link subscribe} for persistent subscriptions\n * @see {@link process} to consume pending dispatches\n */\n async emit(\n type: string,\n payload: unknown,\n options: DispatchEmitOptions = {},\n ): Promise<Dispatch> {\n await this.initialize();\n\n // Derive the tenant scope server-side from the active context. This is the\n // trust anchor for cross-tenant isolation (S5 #1398) — it is never taken\n // from caller options, so it cannot be spoofed. `undefined`/`null` means\n // \"no tenant context\" → tenant_id stays NULL (global dispatch).\n const tenantId = resolveDispatchTenantId() ?? null;\n\n // Treat the caller-asserted source as untrusted metadata.\n const source = DispatchBus.sanitizeSource(options.source);\n\n // Query subscriptions to determine if fan-out copies are needed.\n // This runs on every emit() — uses SQL-level exact matching with\n // in-memory fallback only for wildcard subscriptions to minimize I/O.\n // Scoped to the active tenant so emit only fans out to this tenant's\n // (and, for global emits, global) subscriptions (S5 #1398).\n const matchingSubs = await DispatchSubscriptionCollection.findBySignalType(\n this.db,\n type,\n resolveDispatchTenantScope(),\n );\n\n // Separate into compete and fanout subscribers\n const fanoutSubs = matchingSubs.filter((s) => s.delivery === 'fanout');\n const competeSubs = matchingSubs.filter((s) => s.delivery !== 'fanout');\n\n // Create the base dispatch record\n const dispatch = new Dispatch({\n type,\n source,\n source_id: options.sourceId || null,\n payload: JSON.stringify(payload || {}),\n metadata: JSON.stringify(options.metadata || {}),\n status: 'pending',\n correlation_id: options.correlationId || null,\n tenant_id: tenantId,\n });\n\n // Track first persisted dispatch to return to caller\n let returnDispatch = dispatch;\n\n // For fanout subscribers: create a per-subscriber dispatch copy\n for (const sub of fanoutSubs) {\n const fanoutDispatch = new Dispatch({\n type,\n source,\n source_id: options.sourceId || null,\n payload: JSON.stringify(payload || {}),\n metadata: JSON.stringify(options.metadata || {}),\n status: 'pending',\n target_subscriber: sub.subscriber,\n correlation_id: options.correlationId || null,\n tenant_id: tenantId,\n });\n await DispatchCollection.insert(this.db, fanoutDispatch);\n // If no compete dispatch will be persisted, return the first fanout copy\n if (competeSubs.length === 0 && matchingSubs.length > 0) {\n returnDispatch = fanoutDispatch;\n }\n }\n\n // For compete subscribers (or if no subscriptions found): insert the original\n // dispatch with target_subscriber = NULL so any compete subscriber can claim it\n if (competeSubs.length > 0 || matchingSubs.length === 0) {\n await DispatchCollection.insert(this.db, dispatch);\n }\n\n // Notify in-memory handlers immediately (fire-and-forget)\n this.notifyHandlers(type, payload, dispatch.getMetadata());\n\n return returnDispatch;\n }\n\n /**\n * Registers an in-memory handler for a signal type pattern.\n *\n * In-memory handlers are called immediately (fire-and-forget) during `emit()`.\n * They are stored only in memory and lost on process restart — use `subscribe()`\n * for durable, restart-safe subscriptions.\n *\n * Pattern matching supports a single-segment wildcard `*`:\n * - `'campaign.*'` matches `'campaign.completed'` and `'campaign.failed'`\n * - `'campaign.*'` does **not** match `'campaign.phase.two'`\n *\n * Multiple handlers may be registered for the same pattern — all will be called.\n *\n * @param pattern - Signal type pattern, optionally with a trailing `.*` wildcard\n * @param handler - Async or sync function to call with `(payload, metadata)`\n *\n * @example\n * ```typescript\n * bus.on('invoice.*', async (payload, metadata) => {\n * console.log(`Invoice event from ${metadata.source}:`, payload);\n * });\n * ```\n *\n * @see {@link off} to remove a handler\n * @see {@link subscribe} for persistent (restart-safe) subscriptions\n */\n on(pattern: string, handler: DispatchHandler): void {\n const subscription = new DispatchSubscription({\n signal_type: pattern,\n subscriber: '_in_memory_',\n handler: 'callback',\n });\n\n const registered: RegisteredHandler = { pattern, handler, subscription };\n\n if (!this.handlers.has(pattern)) {\n this.handlers.set(pattern, []);\n }\n this.handlers.get(pattern)?.push(registered);\n }\n\n /**\n * Remove an in-memory handler\n *\n * @param pattern - Signal type pattern\n * @param handler - Handler function to remove\n * @returns True if handler was found and removed\n */\n off(pattern: string, handler: DispatchHandler): boolean {\n const handlers = this.handlers.get(pattern);\n if (!handlers) return false;\n\n const index = handlers.findIndex((h) => h.handler === handler);\n if (index === -1) return false;\n\n handlers.splice(index, 1);\n if (handlers.length === 0) {\n this.handlers.delete(pattern);\n }\n return true;\n }\n\n /**\n * Creates or updates a persistent subscription in the database.\n *\n * Persistent subscriptions survive process restarts. When `process(subscriber, handler)`\n * is called later, all pending dispatches matching this subscriber's signal patterns\n * will be delivered.\n *\n * Calling `subscribe()` with the same `signalType`/`subscriber` pair is idempotent\n * (upsert) — it is safe to call on every agent startup.\n *\n * Set `delivery: 'fanout'` to give each subscriber their own dispatch copy.\n * Default is `'compete'` (at-most-once, first subscriber to process claims it).\n *\n * @param options.signalType - Signal type pattern to subscribe to (wildcards supported)\n * @param options.subscriber - Name that identifies this subscriber (e.g. agent class name)\n * @param options.handler - Optional method name on the subscriber to call (default `'handleDispatch'`)\n * @param options.delivery - `'compete'` (default) for at-most-once, `'fanout'` for per-subscriber copies\n * @param options.enabled - If `false`, subscription is created but disabled (default `true`)\n *\n * @example\n * ```typescript\n * // Subscribe on agent startup (idempotent)\n * await bus.subscribe({ signalType: 'campaign.*', subscriber: 'FiscusAgent' });\n *\n * // Fan-out: each subscriber gets their own dispatch copy\n * await bus.subscribe({\n * signalType: 'campaign.*',\n * subscriber: 'AuditorAgent',\n * delivery: 'fanout',\n * });\n *\n * // Later, process matching pending dispatches\n * await bus.process('FiscusAgent', async (payload, metadata) => { ... });\n * ```\n *\n * @see {@link process} to consume pending dispatches for this subscriber\n * @see {@link unsubscribe} to remove the subscription\n * @see {@link on} for non-persistent in-memory handling\n */\n async subscribe(options: DispatchSubscribeOptions): Promise<void> {\n await this.initialize();\n\n // Gate the subscriber namespace (S5 #1398): require a concrete subscriber\n // name and reject the reserved internal sentinel so callers cannot\n // register a persistent subscription that masquerades as the in-memory\n // pseudo-subscriber.\n const subscriber = (options.subscriber ?? '').trim();\n if (!subscriber) {\n throw new Error('DispatchBus.subscribe requires a non-empty subscriber');\n }\n if (subscriber === DispatchBus.RESERVED_SUBSCRIBER) {\n throw new Error(\n `DispatchBus.subscribe: \"${DispatchBus.RESERVED_SUBSCRIBER}\" is a reserved subscriber name`,\n );\n }\n const signalType = (options.signalType ?? '').trim();\n if (!signalType) {\n throw new Error('DispatchBus.subscribe requires a non-empty signalType');\n }\n\n const subscription = new DispatchSubscription({\n signal_type: signalType,\n subscriber,\n handler: options.handler || 'handleDispatch',\n delivery: options.delivery || 'compete',\n enabled: options.enabled !== false ? 1 : 0,\n // Server-derived tenant scope (never from caller options). NULL when there\n // is no active tenant context (global subscription).\n tenant_id: resolveDispatchTenantId() ?? null,\n });\n\n await DispatchSubscriptionCollection.upsert(this.db, subscription);\n }\n\n /**\n * Remove a persistent subscription\n *\n * @param signalType - Signal type pattern\n * @param subscriber - Subscriber name\n */\n async unsubscribe(signalType: string, subscriber: string): Promise<void> {\n await this.initialize();\n // Scoped to the active tenant so one tenant cannot remove another's\n // subscription (S5 #1398).\n await DispatchSubscriptionCollection.deleteByKey(\n this.db,\n signalType,\n subscriber,\n resolveDispatchTenantScope(),\n );\n }\n\n /**\n * Processes pending dispatches for a named subscriber.\n *\n * For each matching `pending` dispatch:\n * 1. Sets status to `processing`\n * 2. Calls `handler(payload, metadata)`\n * 3. On success: sets status to `completed`\n * 4. On error: sets status to `failed` (stores the error message)\n *\n * Uses a wildcard-aware query strategy: subscriptions with `*` patterns fetch\n * all pending dispatches and filter in memory; exact-match subscriptions use\n * a direct SQL `IN` query for efficiency.\n *\n * @param subscriber - The subscriber name (must match a `subscribe()` call)\n * @param handler - Function to call for each pending dispatch\n * @param options.limit - Maximum dispatches to process in one call (default 100)\n * @param options.signalTypes - Optional filter to process only specific signal types\n * @returns Number of dispatches successfully processed (excludes failed)\n *\n * @example\n * ```typescript\n * const count = await bus.process('FiscusAgent', async (payload, metadata) => {\n * if (metadata.signalType === 'campaign.completed') {\n * await generateInvoice(payload.campaignId);\n * }\n * });\n * console.log(`Processed ${count} dispatches`);\n * ```\n *\n * @see {@link subscribe} to register the persistent subscription first\n * @see {@link retry} to reset failed dispatches back to pending\n */\n async process(\n subscriber: string,\n handler: DispatchHandler,\n options: DispatchProcessOptions = {},\n ): Promise<number> {\n await this.initialize();\n\n // Resolve the active tenant scope server-side (S5 #1398). This distinguishes\n // three states (see resolveDispatchTenantScope):\n // - tenancy off → no filter; system/global + non-tenant deployments behave\n // exactly as before.\n // - active tenant T → only T's dispatches plus global (NULL) dispatches are\n // claimable, so a subscriber in tenant A cannot see/claim tenant B's.\n // - tenancy on but no active tenant → fail-closed to global (NULL) rows\n // only; a missing context never leaks other tenants' dispatches.\n const tenantScope = resolveDispatchTenantScope();\n\n // Get subscriber's subscriptions, scoped to the active tenant so a tenant-A\n // processor only matches tenant-A (and, when global, global) subscriptions.\n const subscriptions = await DispatchSubscriptionCollection.findBySubscriber(\n this.db,\n subscriber,\n true,\n tenantScope,\n );\n\n if (subscriptions.length === 0) {\n return 0;\n }\n\n // Get signal types (including wildcards)\n const signalTypes = subscriptions.map((s) => s.signalType);\n\n // For non-wildcard subscriptions, we can query directly\n // For wildcards, we need to get all pending and filter\n const hasWildcards = signalTypes.some((t) => t.includes('*'));\n\n // Helper: check if a dispatch should be visible to this subscriber\n const isVisibleToSubscriber = (dispatch: Dispatch): boolean => {\n // Tenant isolation (defense in depth — the SQL queries below also filter).\n if (tenantScope.enforced) {\n if (tenantScope.tenantId === null) {\n // Tenancy on, no active tenant → only global (NULL) rows are visible.\n if (dispatch.tenantId !== null) {\n return false;\n }\n } else if (\n dispatch.tenantId !== null &&\n dispatch.tenantId !== tenantScope.tenantId\n ) {\n // Active tenant → that tenant's rows plus global (NULL) rows only.\n return false;\n }\n }\n if (dispatch.targetSubscriber === subscriber) {\n // Targeted at this subscriber (fanout copy) — always visible\n return true;\n }\n if (dispatch.targetSubscriber !== null) {\n // Targeted at a different subscriber — not visible\n return false;\n }\n // Null target (compete dispatch): only visible if this subscriber\n // has a compete subscription matching this dispatch type\n const matchingSub = subscriptions.find((sub) =>\n sub.matches(dispatch.type),\n );\n return matchingSub?.delivery === 'compete';\n };\n\n let pendingDispatches: Dispatch[];\n if (hasWildcards) {\n // Get all pending dispatches and filter by subscription patterns + target\n const allPending = await DispatchCollection.list(this.db, {\n status: 'pending',\n limit: options.limit || 100,\n tenantScope,\n });\n\n pendingDispatches = allPending.filter(\n (dispatch) =>\n subscriptions.some((sub) => sub.matches(dispatch.type)) &&\n isVisibleToSubscriber(dispatch),\n );\n } else {\n // Direct query for exact signal types (with target_subscriber filter)\n const raw = await DispatchCollection.findPending(\n this.db,\n signalTypes,\n options.limit || 100,\n subscriber,\n tenantScope,\n );\n // Post-filter for delivery mode correctness\n pendingDispatches = raw.filter(isVisibleToSubscriber);\n }\n\n // Filter by specific signal types if provided\n if (options.signalTypes && options.signalTypes.length > 0) {\n pendingDispatches = pendingDispatches.filter((d) =>\n options.signalTypes?.includes(d.type),\n );\n }\n\n let processed = 0;\n\n for (const dispatch of pendingDispatches) {\n // Atomically claim the dispatch (S5 #1398). The conditional UPDATE\n // (WHERE id = ? AND status = 'pending' [AND tenant predicate]) closes the\n // TOCTOU window: if a competing worker — or a system-scoped processor —\n // already moved this row out of 'pending', our claim affects no rows and\n // we skip it, so a compete/global dispatch is processed at most once.\n const claimed = await DispatchCollection.claim(\n this.db,\n dispatch,\n tenantScope,\n );\n if (!claimed) {\n continue;\n }\n\n try {\n // Invoke handler\n await handler(dispatch.payload, dispatch.getMetadata());\n\n // Mark as completed\n dispatch.markCompleted(subscriber);\n await DispatchCollection.update(this.db, dispatch);\n processed++;\n } catch (error) {\n // Mark as failed\n const errorMessage =\n error instanceof Error ? error.message : String(error);\n dispatch.markFailed(errorMessage);\n await DispatchCollection.update(this.db, dispatch);\n }\n }\n\n return processed;\n }\n\n /**\n * Retry failed dispatches\n *\n * Resets failed dispatches to pending status so they can be processed again.\n *\n * @param options - Retry options\n * @returns Number of dispatches reset\n */\n async retry(options: DispatchRetryOptions = {}): Promise<number> {\n await this.initialize();\n\n // Tenant isolation (S5 #1398): only the active tenant's failed dispatches\n // (plus global ones) are eligible — a tenant's retry() must not reset\n // another tenant's rows. Scope is derived server-side; callers cannot widen\n // it.\n const retryable = await DispatchCollection.findRetryable(\n this.db,\n options,\n resolveDispatchTenantScope(),\n );\n\n for (const dispatch of retryable) {\n dispatch.resetForRetry();\n await DispatchCollection.update(this.db, dispatch);\n }\n\n return retryable.length;\n }\n\n /**\n * Clean up old dispatches\n *\n * @param options - Cleanup options\n * @returns Number of dispatches deleted\n */\n async cleanup(\n options: DispatchCleanupOptions = {},\n ): Promise<DispatchCleanupResult> {\n await this.initialize();\n // Tenant isolation (S5 #1398): only the active tenant's dispatches (plus\n // global ones) are deleted — a tenant's cleanup() must not delete another\n // tenant's rows. Scope is derived server-side; callers cannot widen it.\n return DispatchCollection.cleanup(\n this.db,\n options,\n resolveDispatchTenantScope(),\n );\n }\n\n /**\n * Lists dispatches, scoped to the active tenant context (S5 #1398).\n *\n * The tenant scope is derived **server-side** from the active context and\n * **overrides any caller-supplied scope** — callers cannot select another\n * tenant or widen visibility to all tenants. Scoping rules:\n *\n * - tenancy off → no tenant filter (pre-tenancy behavior).\n * - active tenant T → that tenant's dispatches plus global (NULL) dispatches.\n * - tenancy on but no active tenant → global (NULL) dispatches only\n * (fail-closed; never all tenants).\n *\n * @param options - Non-tenant list filters (status/type/source/etc.). Any\n * `tenantScope` field is ignored and replaced with the server-derived one.\n */\n async list(options: DispatchListOptions = {}): Promise<Dispatch[]> {\n await this.initialize();\n // Strip any caller-supplied scope and inject the server-derived one.\n const { tenantScope: _ignoredCallerScope, ...safeOptions } = options;\n return DispatchCollection.list(this.db, {\n ...safeOptions,\n tenantScope: resolveDispatchTenantScope(),\n });\n }\n\n /**\n * Gets a dispatch by ID, enforcing the active tenant scope (S5 #1398).\n *\n * Applies the same server-derived tenant predicate as {@link list}: a\n * subscriber in tenant A cannot fetch tenant B's dispatch by id, and when\n * tenancy is on with no active tenant only global (NULL) dispatches are\n * returned. Returns `null` when the dispatch exists but is out of scope.\n */\n async get(id: string): Promise<Dispatch | null> {\n await this.initialize();\n return DispatchCollection.getScoped(\n this.db,\n id,\n resolveDispatchTenantScope(),\n );\n }\n\n /**\n * List all subscriptions\n */\n async listSubscriptions(\n subscriber?: string,\n ): Promise<DispatchSubscription[]> {\n await this.initialize();\n\n // Scoped to the active tenant so listing never leaks another tenant's\n // subscriptions (S5 #1398).\n const tenantScope = resolveDispatchTenantScope();\n\n if (subscriber) {\n return DispatchSubscriptionCollection.findBySubscriber(\n this.db,\n subscriber,\n false,\n tenantScope,\n );\n }\n return DispatchSubscriptionCollection.list(this.db, false, tenantScope);\n }\n\n /**\n * Notify in-memory handlers (fire-and-forget)\n */\n private notifyHandlers(\n type: string,\n payload: unknown,\n metadata: DispatchMetadata,\n ): void {\n // Collect all matching handlers\n const matchingHandlers: RegisteredHandler[] = [];\n\n for (const [, handlers] of this.handlers) {\n for (const registered of handlers) {\n if (registered.subscription.matches(type)) {\n matchingHandlers.push(registered);\n }\n }\n }\n\n // Fire-and-forget - don't await\n for (const registered of matchingHandlers) {\n try {\n const result = registered.handler(payload, metadata);\n // Handle both sync and async handlers\n if (result && typeof result.catch === 'function') {\n void result.catch((error: unknown) => {\n logger.error(\n `DispatchBus: Handler for \"${registered.pattern}\" failed`,\n { error },\n );\n });\n }\n } catch (error) {\n logger.error(\n `DispatchBus: Handler for \"${registered.pattern}\" failed`,\n { error },\n );\n }\n }\n }\n}\n\n/**\n * Create a DispatchBus instance\n *\n * @param options - Bus configuration\n * @returns Initialized DispatchBus\n */\nexport async function createDispatchBus(\n options: DispatchBusOptions = {},\n): Promise<DispatchBus> {\n // Resolve database configuration\n const dbConfig = options.db || options.persistence;\n\n if (!dbConfig) {\n throw new Error('DispatchBus requires a database configuration');\n }\n\n // Get or create database interface\n let db: DatabaseInterface;\n if (typeof dbConfig === 'string') {\n // `DispatchBusOptions.db` does not admit a bare string, but this branch is\n // live code and must not be the one unbounded pool a reader finds while\n // auditing this file (#2377).\n db = await getDatabase(\n applyPostgresRuntimeTimeouts({\n url: dbConfig,\n }) as Parameters<typeof getDatabase>[0],\n );\n } else if ('query' in dbConfig) {\n // Already a DatabaseInterface (has query method)\n db = dbConfig as DatabaseInterface;\n } else if ('type' in dbConfig && 'url' in dbConfig) {\n // Database config object - use getDatabase with type and url.\n // Bounded like every other runtime pool so a dispatch-bus connection is\n // not the one unbounded PostgreSQL client in the process (#2377).\n db = await getDatabase(\n applyPostgresRuntimeTimeouts({\n type: dbConfig.type as 'sqlite' | 'postgres' | 'duckdb',\n url: dbConfig.url,\n }) as Parameters<typeof getDatabase>[0],\n );\n } else {\n throw new Error('Invalid database configuration for DispatchBus');\n }\n\n const bus = new DispatchBus(db);\n await bus.initialize();\n\n return bus;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"bus.js","names":[],"sources":["../../src/dispatch/bus.ts"],"sourcesContent":["/**\n * DispatchBus - Central hub for inter-agent communication\n *\n * The DispatchBus provides both in-memory handlers and persistent subscriptions\n * for asynchronous agent-to-agent messaging.\n *\n * @example\n * ```typescript\n * const bus = await createDispatchBus({ db: { type: 'sqlite', url: 'app.db' } });\n *\n * // In-memory handler (immediate)\n * bus.on('campaign.completed', async (payload, metadata) => {\n * console.log(`Campaign ${payload.campaignId} completed`);\n * });\n *\n * // Persistent subscription (processed later)\n * await bus.subscribe({ signalType: 'campaign.*', subscriber: 'fiscus' });\n *\n * // Emit a dispatch\n * await bus.emit('campaign.completed', { campaignId: '123' }, { source: 'suasor' });\n *\n * // Process pending dispatches\n * await bus.process('fiscus');\n * ```\n */\n\nimport { createLogger } from '@happyvertical/logger';\nimport type { DatabaseInterface } from '@happyvertical/sql';\nimport { getDatabase } from '@happyvertical/sql';\nimport { applyPostgresRuntimeTimeouts } from '../postgres-timeouts.js';\nimport { runSerializedAgainstSystemTableBootstrap } from '../system/bootstrap.js';\nimport {\n assertPostgresSystemTimestampsCurrent,\n ensureDispatchSubscriptionsSystemTableCompatibility,\n ensureDispatchSystemTableCompatibility,\n getDatabaseEngine,\n} from '../system/compatibility.js';\nimport {\n CREATE_SMRT_DISPATCH_SUBSCRIPTIONS_TABLE,\n CREATE_SMRT_DISPATCH_TABLE,\n getSystemTableDDLForEngine,\n} from '../system/schema.js';\nimport { DispatchCollection } from './collections/Dispatches.js';\nimport { DispatchSubscriptionCollection } from './collections/DispatchSubscriptions.js';\nimport { Dispatch } from './models/Dispatch.js';\nimport { DispatchSubscription } from './models/DispatchSubscription.js';\nimport {\n resolveDispatchTenantId,\n resolveDispatchTenantScope,\n} from './tenant-resolver.js';\nimport type {\n DispatchBusOptions,\n DispatchCleanupOptions,\n DispatchCleanupResult,\n DispatchEmitOptions,\n DispatchHandler,\n DispatchListOptions,\n DispatchMetadata,\n DispatchProcessOptions,\n DispatchRetryOptions,\n DispatchSubscribeOptions,\n} from './types.js';\n\nconst logger = createLogger({ level: 'info' });\n\n/**\n * Registered in-memory handler\n */\ninterface RegisteredHandler {\n pattern: string;\n handler: DispatchHandler;\n subscription: DispatchSubscription;\n}\n\n/**\n * Central hub for inter-agent messaging with both in-memory and persistent delivery.\n *\n * The `DispatchBus` combines two complementary delivery models:\n *\n * - **In-memory handlers** (`on(pattern, handler)`) — called synchronously (fire-and-forget)\n * when a dispatch is emitted. Fast, but lost on process restart.\n * - **Persistent subscriptions** (`subscribe({ signalType, subscriber })`) — stored in\n * `_smrt_dispatch_subscriptions` and processed later by calling `process(subscriber, handler)`.\n * Survive restarts; suitable for background workers and scheduled agents.\n *\n * Signal types support single-segment wildcards: `'campaign.*'` matches `'campaign.completed'`\n * and `'campaign.failed'`, but not `'campaign.phase.two'`.\n *\n * Dispatch lifecycle: `pending → processing → completed` (or `failed` on handler error).\n * Use `retry()` to reset failed dispatches back to `pending`.\n *\n * Create instances via `createDispatchBus()` — do not use `new DispatchBus()` directly\n * in application code.\n *\n * @example\n * ```typescript\n * const bus = await createDispatchBus({ db: myDb });\n *\n * // In-memory: immediate, fire-and-forget\n * bus.on('invoice.paid', async (payload) => {\n * console.log('Invoice paid:', payload.invoiceId);\n * });\n *\n * // Persistent: processed by 'notifications' agent on next run\n * await bus.subscribe({ signalType: 'invoice.*', subscriber: 'notifications' });\n *\n * // Emit (notifies in-memory handlers immediately, stores persistent dispatch)\n * await bus.emit('invoice.paid', { invoiceId: 'inv-001' }, { source: 'billing' });\n *\n * // Later, in the notifications agent:\n * await bus.process('notifications', async (payload) => {\n * await sendEmail(payload.invoiceId);\n * });\n * ```\n */\nexport class DispatchBus {\n private db: DatabaseInterface;\n private handlers: Map<string, RegisteredHandler[]> = new Map();\n private initialized: boolean = false;\n\n /**\n * Reserved subscriber name used internally for in-memory `on()` handlers.\n * Callers may not register persistent subscriptions under this name, nor\n * assert it as an emit `source`, so they cannot impersonate the in-memory\n * pseudo-subscriber (S5 #1398).\n */\n private static readonly RESERVED_SUBSCRIBER = '_in_memory_';\n\n /** Maximum stored length of a caller-asserted dispatch source label. */\n private static readonly MAX_SOURCE_LENGTH = 256;\n\n /**\n * Normalize a caller-asserted `source` into untrusted metadata.\n *\n * `source` is a declared label, not an authenticated identity, so it must not\n * be trusted for authorization. This caps its length and **rejects** the\n * reserved internal sentinel (`_in_memory_`) by throwing — the contract is\n * that the sentinel is not an accepted source, so a caller cannot quietly\n * impersonate the in-memory subscriber pseudo-source (S5 #1398). Empty/missing\n * values default to `'unknown'` (unchanged behavior).\n *\n * @throws Error if `source` is the reserved in-memory sentinel.\n */\n private static sanitizeSource(source: string | undefined): string {\n const raw = (source ?? '').trim();\n if (!raw) {\n return 'unknown';\n }\n if (raw === DispatchBus.RESERVED_SUBSCRIBER) {\n throw new Error(\n `DispatchBus.emit: \"${DispatchBus.RESERVED_SUBSCRIBER}\" is a reserved source and cannot be asserted`,\n );\n }\n return raw.slice(0, DispatchBus.MAX_SOURCE_LENGTH);\n }\n\n /**\n * Create a new DispatchBus (use createDispatchBus factory instead)\n */\n constructor(db: DatabaseInterface) {\n this.db = db;\n }\n\n /**\n * Initialize the dispatch tables\n */\n async initialize(): Promise<void> {\n if (this.initialized) return;\n\n // `_smrt_dispatch`/`_smrt_dispatch_subscriptions` are framework-owned\n // system tables (part of `ALL_SYSTEM_TABLES`), but this standalone\n // initializer provisions/verifies them independently of a full\n // `ensureSystemTables()` bootstrap. Without serialization, concurrent\n // `DispatchBus` instances against the same already-warmed PostgreSQL\n // database (e.g. multiple `Suasor`/`Agent` instantiations, or concurrent\n // test files sharing one database) race on unguarded DDL — proven to\n // throw `duplicate key value violates unique constraint\n // \"pg_type_typname_nsp_index\"` on concurrent `CREATE TABLE IF NOT\n // EXISTS` (#2861). Fresh DDL always creates timezone-aware columns (see\n // `getSystemTableDDLForEngine()`), so this race does not itself produce a\n // legacy-typed column; a stale `assertPostgresSystemTimestampsCurrent()`\n // guard requires a *separate* concurrent actor to have put some\n // `_smrt_*` table into a legacy-typed state (e.g. a test deliberately\n // simulating a pre-migration database), which this lock does not by\n // itself rule out — see the issue for that residual risk. Running this\n // under the same advisory lock `ensureSystemTables()` uses makes every\n // writer of these tables mutually exclusive instead of merely\n // idempotent, which is what the proven race requires.\n //\n // Resolve the engine from `this.db` — the caller-supplied handle — once,\n // up front. `runSerializedAgainstSystemTableBootstrap()` hands the\n // callback a *different* handle on PostgreSQL (the lock-holding\n // transaction it opens), and that handle is not guaranteed to carry the\n // `url`/`type` (or `config.url`/`config.type`) fields engine detection\n // reads (see the `db.config?.url`-style adapters `getDatabaseEngine()`\n // already supports elsewhere in this package). Recomputing the engine\n // on the transaction handle can therefore silently disagree with the\n // outer gate that just decided `postgres` — `detectEngine('', undefined)`\n // falls back to `sqlite`, which would create the dispatch tables with\n // `TIMESTAMP` instead of `TIMESTAMPTZ` and skip the PostgreSQL timestamp\n // guard entirely. Threading the one resolved `engine` through — as\n // `ensureSystemTables()`/`bootstrapSystemTables()` already thread\n // `typeHint` — keeps the inner detection bound to the outer decision.\n const engine = getDatabaseEngine(this.db);\n await runSerializedAgainstSystemTableBootstrap(\n this.db,\n engine,\n async (tx) => {\n // Create dispatch tables if they don't exist\n const dispatchExists = await DispatchCollection.tableExists(tx, engine);\n if (!dispatchExists) {\n // Split the DDL into separate statements and execute each\n const statements = getSystemTableDDLForEngine(\n CREATE_SMRT_DISPATCH_TABLE,\n engine,\n )\n .split(';')\n .filter((s) => s.trim());\n for (const stmt of statements) {\n await tx.query(stmt);\n }\n }\n await assertPostgresSystemTimestampsCurrent(tx, engine);\n await ensureDispatchSystemTableCompatibility(tx, engine);\n\n const subsExists = await DispatchSubscriptionCollection.tableExists(\n tx,\n engine,\n );\n if (!subsExists) {\n const statements = getSystemTableDDLForEngine(\n CREATE_SMRT_DISPATCH_SUBSCRIPTIONS_TABLE,\n engine,\n )\n .split(';')\n .filter((s) => s.trim());\n for (const stmt of statements) {\n await tx.query(stmt);\n }\n }\n await assertPostgresSystemTimestampsCurrent(tx, engine);\n await ensureDispatchSubscriptionsSystemTableCompatibility(tx, engine);\n },\n );\n\n this.initialized = true;\n }\n\n /**\n * Emits a dispatch message.\n *\n * Two things happen atomically (from the caller's perspective):\n * 1. One or more `Dispatch` rows are inserted with `status: 'pending'` into `_smrt_dispatch`.\n * 2. All matching in-memory handlers (registered via `on()`) are invoked\n * fire-and-forget (errors are caught and logged, not thrown back to the caller).\n *\n * Persistent subscriptions registered via `subscribe()` will see this dispatch\n * the next time `process(subscriber, handler)` is called for their subscriber name.\n *\n * **Delivery modes** affect how dispatches are created:\n * - `compete` subscribers share a single dispatch (`target_subscriber = NULL`).\n * First subscriber to `process()` claims it (at-most-once delivery).\n * - `fanout` subscribers each get their own dispatch copy (`target_subscriber` set\n * to the subscriber name), so each processes independently.\n *\n * If no subscriptions exist, one dispatch is created for future processing.\n *\n * **Security (S5 #1398):**\n * - `tenant_id` is derived **server-side** from the active tenant context and\n * is never read from caller options, so it cannot be spoofed. Subscribers in\n * a different tenant cannot see or claim this dispatch (see {@link process}).\n * When there is no active tenant context (system/global), `tenant_id` is\n * `NULL` and the dispatch is visible to all scopes — preserving pre-tenancy\n * behavior.\n * - `options.source` is **caller-asserted, untrusted metadata** — it is a\n * declared label only and must not be relied on as an authenticated emitter\n * identity. It is length-capped and the reserved internal sentinel\n * (`_in_memory_`) is rejected so a caller cannot impersonate the in-memory\n * subscriber pseudo-source.\n *\n * @param type - Signal type string, e.g. `'campaign.completed'` or `'invoice.paid'`\n * @param payload - Any JSON-serializable data to attach to the dispatch\n * @param options.source - Declared (untrusted) name of the emitting agent/component (default `'unknown'`)\n * @param options.sourceId - Optional ID of the specific emitting entity\n * @param options.metadata - Optional additional JSON metadata for the dispatch record\n * @returns A persisted `Dispatch` instance (the compete dispatch, or the first fanout copy if fanout-only)\n *\n * @example\n * ```typescript\n * const dispatch = await bus.emit(\n * 'campaign.completed',\n * { campaignId: 'cmp-001', impressions: 10_000 },\n * { source: 'suasor', sourceId: agentId },\n * );\n * console.log(dispatch.id); // UUID of the created dispatch record\n * ```\n *\n * @see {@link on} for in-memory handlers\n * @see {@link subscribe} for persistent subscriptions\n * @see {@link process} to consume pending dispatches\n */\n async emit(\n type: string,\n payload: unknown,\n options: DispatchEmitOptions = {},\n ): Promise<Dispatch> {\n await this.initialize();\n\n // Derive the tenant scope server-side from the active context. This is the\n // trust anchor for cross-tenant isolation (S5 #1398) — it is never taken\n // from caller options, so it cannot be spoofed. `undefined`/`null` means\n // \"no tenant context\" → tenant_id stays NULL (global dispatch).\n const tenantId = resolveDispatchTenantId() ?? null;\n\n // Treat the caller-asserted source as untrusted metadata.\n const source = DispatchBus.sanitizeSource(options.source);\n\n // Query subscriptions to determine if fan-out copies are needed.\n // This runs on every emit() — uses SQL-level exact matching with\n // in-memory fallback only for wildcard subscriptions to minimize I/O.\n // Scoped to the active tenant so emit only fans out to this tenant's\n // (and, for global emits, global) subscriptions (S5 #1398).\n const matchingSubs = await DispatchSubscriptionCollection.findBySignalType(\n this.db,\n type,\n resolveDispatchTenantScope(),\n );\n\n // Separate into compete and fanout subscribers\n const fanoutSubs = matchingSubs.filter((s) => s.delivery === 'fanout');\n const competeSubs = matchingSubs.filter((s) => s.delivery !== 'fanout');\n\n // Create the base dispatch record\n const dispatch = new Dispatch({\n type,\n source,\n source_id: options.sourceId || null,\n payload: JSON.stringify(payload || {}),\n metadata: JSON.stringify(options.metadata || {}),\n status: 'pending',\n correlation_id: options.correlationId || null,\n tenant_id: tenantId,\n });\n\n // Track first persisted dispatch to return to caller\n let returnDispatch = dispatch;\n\n // For fanout subscribers: create a per-subscriber dispatch copy\n for (const sub of fanoutSubs) {\n const fanoutDispatch = new Dispatch({\n type,\n source,\n source_id: options.sourceId || null,\n payload: JSON.stringify(payload || {}),\n metadata: JSON.stringify(options.metadata || {}),\n status: 'pending',\n target_subscriber: sub.subscriber,\n correlation_id: options.correlationId || null,\n tenant_id: tenantId,\n });\n await DispatchCollection.insert(this.db, fanoutDispatch);\n // If no compete dispatch will be persisted, return the first fanout copy\n if (competeSubs.length === 0 && matchingSubs.length > 0) {\n returnDispatch = fanoutDispatch;\n }\n }\n\n // For compete subscribers (or if no subscriptions found): insert the original\n // dispatch with target_subscriber = NULL so any compete subscriber can claim it\n if (competeSubs.length > 0 || matchingSubs.length === 0) {\n await DispatchCollection.insert(this.db, dispatch);\n }\n\n // Notify in-memory handlers immediately (fire-and-forget)\n this.notifyHandlers(type, payload, dispatch.getMetadata());\n\n return returnDispatch;\n }\n\n /**\n * Registers an in-memory handler for a signal type pattern.\n *\n * In-memory handlers are called immediately (fire-and-forget) during `emit()`.\n * They are stored only in memory and lost on process restart — use `subscribe()`\n * for durable, restart-safe subscriptions.\n *\n * Pattern matching supports a single-segment wildcard `*`:\n * - `'campaign.*'` matches `'campaign.completed'` and `'campaign.failed'`\n * - `'campaign.*'` does **not** match `'campaign.phase.two'`\n *\n * Multiple handlers may be registered for the same pattern — all will be called.\n *\n * @param pattern - Signal type pattern, optionally with a trailing `.*` wildcard\n * @param handler - Async or sync function to call with `(payload, metadata)`\n *\n * @example\n * ```typescript\n * bus.on('invoice.*', async (payload, metadata) => {\n * console.log(`Invoice event from ${metadata.source}:`, payload);\n * });\n * ```\n *\n * @see {@link off} to remove a handler\n * @see {@link subscribe} for persistent (restart-safe) subscriptions\n */\n on(pattern: string, handler: DispatchHandler): void {\n const subscription = new DispatchSubscription({\n signal_type: pattern,\n subscriber: '_in_memory_',\n handler: 'callback',\n });\n\n const registered: RegisteredHandler = { pattern, handler, subscription };\n\n if (!this.handlers.has(pattern)) {\n this.handlers.set(pattern, []);\n }\n this.handlers.get(pattern)?.push(registered);\n }\n\n /**\n * Remove an in-memory handler\n *\n * @param pattern - Signal type pattern\n * @param handler - Handler function to remove\n * @returns True if handler was found and removed\n */\n off(pattern: string, handler: DispatchHandler): boolean {\n const handlers = this.handlers.get(pattern);\n if (!handlers) return false;\n\n const index = handlers.findIndex((h) => h.handler === handler);\n if (index === -1) return false;\n\n handlers.splice(index, 1);\n if (handlers.length === 0) {\n this.handlers.delete(pattern);\n }\n return true;\n }\n\n /**\n * Creates or updates a persistent subscription in the database.\n *\n * Persistent subscriptions survive process restarts. When `process(subscriber, handler)`\n * is called later, all pending dispatches matching this subscriber's signal patterns\n * will be delivered.\n *\n * Calling `subscribe()` with the same `signalType`/`subscriber` pair is idempotent\n * (upsert) — it is safe to call on every agent startup.\n *\n * Set `delivery: 'fanout'` to give each subscriber their own dispatch copy.\n * Default is `'compete'` (at-most-once, first subscriber to process claims it).\n *\n * @param options.signalType - Signal type pattern to subscribe to (wildcards supported)\n * @param options.subscriber - Name that identifies this subscriber (e.g. agent class name)\n * @param options.handler - Optional method name on the subscriber to call (default `'handleDispatch'`)\n * @param options.delivery - `'compete'` (default) for at-most-once, `'fanout'` for per-subscriber copies\n * @param options.enabled - If `false`, subscription is created but disabled (default `true`)\n *\n * @example\n * ```typescript\n * // Subscribe on agent startup (idempotent)\n * await bus.subscribe({ signalType: 'campaign.*', subscriber: 'FiscusAgent' });\n *\n * // Fan-out: each subscriber gets their own dispatch copy\n * await bus.subscribe({\n * signalType: 'campaign.*',\n * subscriber: 'AuditorAgent',\n * delivery: 'fanout',\n * });\n *\n * // Later, process matching pending dispatches\n * await bus.process('FiscusAgent', async (payload, metadata) => { ... });\n * ```\n *\n * @see {@link process} to consume pending dispatches for this subscriber\n * @see {@link unsubscribe} to remove the subscription\n * @see {@link on} for non-persistent in-memory handling\n */\n async subscribe(options: DispatchSubscribeOptions): Promise<void> {\n await this.initialize();\n\n // Gate the subscriber namespace (S5 #1398): require a concrete subscriber\n // name and reject the reserved internal sentinel so callers cannot\n // register a persistent subscription that masquerades as the in-memory\n // pseudo-subscriber.\n const subscriber = (options.subscriber ?? '').trim();\n if (!subscriber) {\n throw new Error('DispatchBus.subscribe requires a non-empty subscriber');\n }\n if (subscriber === DispatchBus.RESERVED_SUBSCRIBER) {\n throw new Error(\n `DispatchBus.subscribe: \"${DispatchBus.RESERVED_SUBSCRIBER}\" is a reserved subscriber name`,\n );\n }\n const signalType = (options.signalType ?? '').trim();\n if (!signalType) {\n throw new Error('DispatchBus.subscribe requires a non-empty signalType');\n }\n\n const subscription = new DispatchSubscription({\n signal_type: signalType,\n subscriber,\n handler: options.handler || 'handleDispatch',\n delivery: options.delivery || 'compete',\n enabled: options.enabled !== false ? 1 : 0,\n // Server-derived tenant scope (never from caller options). NULL when there\n // is no active tenant context (global subscription).\n tenant_id: resolveDispatchTenantId() ?? null,\n });\n\n await DispatchSubscriptionCollection.upsert(this.db, subscription);\n }\n\n /**\n * Remove a persistent subscription\n *\n * @param signalType - Signal type pattern\n * @param subscriber - Subscriber name\n */\n async unsubscribe(signalType: string, subscriber: string): Promise<void> {\n await this.initialize();\n // Scoped to the active tenant so one tenant cannot remove another's\n // subscription (S5 #1398).\n await DispatchSubscriptionCollection.deleteByKey(\n this.db,\n signalType,\n subscriber,\n resolveDispatchTenantScope(),\n );\n }\n\n /**\n * Processes pending dispatches for a named subscriber.\n *\n * For each matching `pending` dispatch:\n * 1. Sets status to `processing`\n * 2. Calls `handler(payload, metadata)`\n * 3. On success: sets status to `completed`\n * 4. On error: sets status to `failed` (stores the error message)\n *\n * Uses a wildcard-aware query strategy: subscriptions with `*` patterns fetch\n * all pending dispatches and filter in memory; exact-match subscriptions use\n * a direct SQL `IN` query for efficiency.\n *\n * @param subscriber - The subscriber name (must match a `subscribe()` call)\n * @param handler - Function to call for each pending dispatch\n * @param options.limit - Maximum dispatches to process in one call (default 100)\n * @param options.signalTypes - Optional filter to process only specific signal types\n * @returns Number of dispatches successfully processed (excludes failed)\n *\n * @example\n * ```typescript\n * const count = await bus.process('FiscusAgent', async (payload, metadata) => {\n * if (metadata.signalType === 'campaign.completed') {\n * await generateInvoice(payload.campaignId);\n * }\n * });\n * console.log(`Processed ${count} dispatches`);\n * ```\n *\n * @see {@link subscribe} to register the persistent subscription first\n * @see {@link retry} to reset failed dispatches back to pending\n */\n async process(\n subscriber: string,\n handler: DispatchHandler,\n options: DispatchProcessOptions = {},\n ): Promise<number> {\n await this.initialize();\n\n // Resolve the active tenant scope server-side (S5 #1398). This distinguishes\n // three states (see resolveDispatchTenantScope):\n // - tenancy off → no filter; system/global + non-tenant deployments behave\n // exactly as before.\n // - active tenant T → only T's dispatches plus global (NULL) dispatches are\n // claimable, so a subscriber in tenant A cannot see/claim tenant B's.\n // - tenancy on but no active tenant → fail-closed to global (NULL) rows\n // only; a missing context never leaks other tenants' dispatches.\n const tenantScope = resolveDispatchTenantScope();\n\n // Get subscriber's subscriptions, scoped to the active tenant so a tenant-A\n // processor only matches tenant-A (and, when global, global) subscriptions.\n const subscriptions = await DispatchSubscriptionCollection.findBySubscriber(\n this.db,\n subscriber,\n true,\n tenantScope,\n );\n\n if (subscriptions.length === 0) {\n return 0;\n }\n\n // Get signal types (including wildcards)\n const signalTypes = subscriptions.map((s) => s.signalType);\n\n // For non-wildcard subscriptions, we can query directly\n // For wildcards, we need to get all pending and filter\n const hasWildcards = signalTypes.some((t) => t.includes('*'));\n\n // Helper: check if a dispatch should be visible to this subscriber\n const isVisibleToSubscriber = (dispatch: Dispatch): boolean => {\n // Tenant isolation (defense in depth — the SQL queries below also filter).\n if (tenantScope.enforced) {\n if (tenantScope.tenantId === null) {\n // Tenancy on, no active tenant → only global (NULL) rows are visible.\n if (dispatch.tenantId !== null) {\n return false;\n }\n } else if (\n dispatch.tenantId !== null &&\n dispatch.tenantId !== tenantScope.tenantId\n ) {\n // Active tenant → that tenant's rows plus global (NULL) rows only.\n return false;\n }\n }\n if (dispatch.targetSubscriber === subscriber) {\n // Targeted at this subscriber (fanout copy) — always visible\n return true;\n }\n if (dispatch.targetSubscriber !== null) {\n // Targeted at a different subscriber — not visible\n return false;\n }\n // Null target (compete dispatch): only visible if this subscriber\n // has a compete subscription matching this dispatch type\n const matchingSub = subscriptions.find((sub) =>\n sub.matches(dispatch.type),\n );\n return matchingSub?.delivery === 'compete';\n };\n\n let pendingDispatches: Dispatch[];\n if (hasWildcards) {\n // Get all pending dispatches and filter by subscription patterns + target\n const allPending = await DispatchCollection.list(this.db, {\n status: 'pending',\n limit: options.limit || 100,\n tenantScope,\n });\n\n pendingDispatches = allPending.filter(\n (dispatch) =>\n subscriptions.some((sub) => sub.matches(dispatch.type)) &&\n isVisibleToSubscriber(dispatch),\n );\n } else {\n // Direct query for exact signal types (with target_subscriber filter)\n const raw = await DispatchCollection.findPending(\n this.db,\n signalTypes,\n options.limit || 100,\n subscriber,\n tenantScope,\n );\n // Post-filter for delivery mode correctness\n pendingDispatches = raw.filter(isVisibleToSubscriber);\n }\n\n // Filter by specific signal types if provided\n if (options.signalTypes && options.signalTypes.length > 0) {\n pendingDispatches = pendingDispatches.filter((d) =>\n options.signalTypes?.includes(d.type),\n );\n }\n\n let processed = 0;\n\n for (const dispatch of pendingDispatches) {\n // Atomically claim the dispatch (S5 #1398). The conditional UPDATE\n // (WHERE id = ? AND status = 'pending' [AND tenant predicate]) closes the\n // TOCTOU window: if a competing worker — or a system-scoped processor —\n // already moved this row out of 'pending', our claim affects no rows and\n // we skip it, so a compete/global dispatch is processed at most once.\n const claimed = await DispatchCollection.claim(\n this.db,\n dispatch,\n tenantScope,\n );\n if (!claimed) {\n continue;\n }\n\n try {\n // Invoke handler\n await handler(dispatch.payload, dispatch.getMetadata());\n\n // Mark as completed\n dispatch.markCompleted(subscriber);\n await DispatchCollection.update(this.db, dispatch);\n processed++;\n } catch (error) {\n // Mark as failed\n const errorMessage =\n error instanceof Error ? error.message : String(error);\n dispatch.markFailed(errorMessage);\n await DispatchCollection.update(this.db, dispatch);\n }\n }\n\n return processed;\n }\n\n /**\n * Retry failed dispatches\n *\n * Resets failed dispatches to pending status so they can be processed again.\n *\n * @param options - Retry options\n * @returns Number of dispatches reset\n */\n async retry(options: DispatchRetryOptions = {}): Promise<number> {\n await this.initialize();\n\n // Tenant isolation (S5 #1398): only the active tenant's failed dispatches\n // (plus global ones) are eligible — a tenant's retry() must not reset\n // another tenant's rows. Scope is derived server-side; callers cannot widen\n // it.\n const retryable = await DispatchCollection.findRetryable(\n this.db,\n options,\n resolveDispatchTenantScope(),\n );\n\n for (const dispatch of retryable) {\n dispatch.resetForRetry();\n await DispatchCollection.update(this.db, dispatch);\n }\n\n return retryable.length;\n }\n\n /**\n * Clean up old dispatches\n *\n * @param options - Cleanup options\n * @returns Number of dispatches deleted\n */\n async cleanup(\n options: DispatchCleanupOptions = {},\n ): Promise<DispatchCleanupResult> {\n await this.initialize();\n // Tenant isolation (S5 #1398): only the active tenant's dispatches (plus\n // global ones) are deleted — a tenant's cleanup() must not delete another\n // tenant's rows. Scope is derived server-side; callers cannot widen it.\n return DispatchCollection.cleanup(\n this.db,\n options,\n resolveDispatchTenantScope(),\n );\n }\n\n /**\n * Lists dispatches, scoped to the active tenant context (S5 #1398).\n *\n * The tenant scope is derived **server-side** from the active context and\n * **overrides any caller-supplied scope** — callers cannot select another\n * tenant or widen visibility to all tenants. Scoping rules:\n *\n * - tenancy off → no tenant filter (pre-tenancy behavior).\n * - active tenant T → that tenant's dispatches plus global (NULL) dispatches.\n * - tenancy on but no active tenant → global (NULL) dispatches only\n * (fail-closed; never all tenants).\n *\n * @param options - Non-tenant list filters (status/type/source/etc.). Any\n * `tenantScope` field is ignored and replaced with the server-derived one.\n */\n async list(options: DispatchListOptions = {}): Promise<Dispatch[]> {\n await this.initialize();\n // Strip any caller-supplied scope and inject the server-derived one.\n const { tenantScope: _ignoredCallerScope, ...safeOptions } = options;\n return DispatchCollection.list(this.db, {\n ...safeOptions,\n tenantScope: resolveDispatchTenantScope(),\n });\n }\n\n /**\n * Gets a dispatch by ID, enforcing the active tenant scope (S5 #1398).\n *\n * Applies the same server-derived tenant predicate as {@link list}: a\n * subscriber in tenant A cannot fetch tenant B's dispatch by id, and when\n * tenancy is on with no active tenant only global (NULL) dispatches are\n * returned. Returns `null` when the dispatch exists but is out of scope.\n */\n async get(id: string): Promise<Dispatch | null> {\n await this.initialize();\n return DispatchCollection.getScoped(\n this.db,\n id,\n resolveDispatchTenantScope(),\n );\n }\n\n /**\n * List all subscriptions\n */\n async listSubscriptions(\n subscriber?: string,\n ): Promise<DispatchSubscription[]> {\n await this.initialize();\n\n // Scoped to the active tenant so listing never leaks another tenant's\n // subscriptions (S5 #1398).\n const tenantScope = resolveDispatchTenantScope();\n\n if (subscriber) {\n return DispatchSubscriptionCollection.findBySubscriber(\n this.db,\n subscriber,\n false,\n tenantScope,\n );\n }\n return DispatchSubscriptionCollection.list(this.db, false, tenantScope);\n }\n\n /**\n * Notify in-memory handlers (fire-and-forget)\n */\n private notifyHandlers(\n type: string,\n payload: unknown,\n metadata: DispatchMetadata,\n ): void {\n // Collect all matching handlers\n const matchingHandlers: RegisteredHandler[] = [];\n\n for (const [, handlers] of this.handlers) {\n for (const registered of handlers) {\n if (registered.subscription.matches(type)) {\n matchingHandlers.push(registered);\n }\n }\n }\n\n // Fire-and-forget - don't await\n for (const registered of matchingHandlers) {\n try {\n const result = registered.handler(payload, metadata);\n // Handle both sync and async handlers\n if (result && typeof result.catch === 'function') {\n void result.catch((error: unknown) => {\n logger.error(\n `DispatchBus: Handler for \"${registered.pattern}\" failed`,\n { error },\n );\n });\n }\n } catch (error) {\n logger.error(\n `DispatchBus: Handler for \"${registered.pattern}\" failed`,\n { error },\n );\n }\n }\n }\n}\n\n/**\n * Create a DispatchBus instance\n *\n * @param options - Bus configuration\n * @returns Initialized DispatchBus\n */\nexport async function createDispatchBus(\n options: DispatchBusOptions = {},\n): Promise<DispatchBus> {\n // Resolve database configuration\n const dbConfig = options.db || options.persistence;\n\n if (!dbConfig) {\n throw new Error('DispatchBus requires a database configuration');\n }\n\n // Get or create database interface\n let db: DatabaseInterface;\n if (typeof dbConfig === 'string') {\n // `DispatchBusOptions.db` does not admit a bare string, but this branch is\n // live code and must not be the one unbounded pool a reader finds while\n // auditing this file (#2377).\n db = await getDatabase(\n applyPostgresRuntimeTimeouts({\n url: dbConfig,\n }) as Parameters<typeof getDatabase>[0],\n );\n } else if ('query' in dbConfig) {\n // Already a DatabaseInterface (has query method)\n db = dbConfig as DatabaseInterface;\n } else if ('type' in dbConfig && 'url' in dbConfig) {\n // Database config object - use getDatabase with type and url.\n // Bounded like every other runtime pool so a dispatch-bus connection is\n // not the one unbounded PostgreSQL client in the process (#2377).\n db = await getDatabase(\n applyPostgresRuntimeTimeouts({\n type: dbConfig.type as 'sqlite' | 'postgres' | 'duckdb',\n url: dbConfig.url,\n }) as Parameters<typeof getDatabase>[0],\n );\n } else {\n throw new Error('Invalid database configuration for DispatchBus');\n }\n\n const bus = new DispatchBus(db);\n await bus.initialize();\n\n return bus;\n}\n"],"mappings":"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"}
|
|
@@ -72,8 +72,13 @@ export declare class DispatchSubscriptionCollection {
|
|
|
72
72
|
*/
|
|
73
73
|
static disable(db: DatabaseInterface, signalType: string, subscriber: string, tenantScope?: DispatchTenantScope): Promise<void>;
|
|
74
74
|
/**
|
|
75
|
-
* Check if subscriptions table exists
|
|
75
|
+
* Check if subscriptions table exists.
|
|
76
|
+
*
|
|
77
|
+
* Delegates to the shared, engine-aware {@link tableExists} rather than
|
|
78
|
+
* probing with `SELECT ... LIMIT 1` inside a try/catch — see
|
|
79
|
+
* `DispatchCollection.tableExists()` for why a probe-and-catch check is
|
|
80
|
+
* unsafe inside a PostgreSQL transaction (#2861).
|
|
76
81
|
*/
|
|
77
|
-
static tableExists(db: DatabaseInterface): Promise<boolean>;
|
|
82
|
+
static tableExists(db: DatabaseInterface, typeHint?: string): Promise<boolean>;
|
|
78
83
|
}
|
|
79
84
|
//# sourceMappingURL=DispatchSubscriptions.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"DispatchSubscriptions.d.ts","sourceRoot":"","sources":["../../../src/dispatch/collections/DispatchSubscriptions.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;
|
|
1
|
+
{"version":3,"file":"DispatchSubscriptions.d.ts","sourceRoot":"","sources":["../../../src/dispatch/collections/DispatchSubscriptions.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAE5D,OAAO,EACL,oBAAoB,EAErB,MAAM,mCAAmC,CAAC;AAC3C,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,uBAAuB,CAAC;AAkCjE;;GAEG;AACH,qBAAa,8BAA8B;IACzC;;;;;;;;OAQG;WACU,MAAM,CACjB,EAAE,EAAE,iBAAiB,EACrB,YAAY,EAAE,oBAAoB,GACjC,OAAO,CAAC,IAAI,CAAC;IAmBhB;;OAEG;WACU,GAAG,CACd,EAAE,EAAE,iBAAiB,EACrB,EAAE,EAAE,MAAM,GACT,OAAO,CAAC,oBAAoB,GAAG,IAAI,CAAC;IASvC;;OAEG;WACU,IAAI,CACf,EAAE,EAAE,iBAAiB,EACrB,UAAU,EAAE,MAAM,EAClB,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC,oBAAoB,GAAG,IAAI,CAAC;IAUvC;;OAEG;WACU,gBAAgB,CAC3B,EAAE,EAAE,iBAAiB,EACrB,UAAU,EAAE,MAAM,EAClB,WAAW,GAAE,OAAc,EAC3B,WAAW,CAAC,EAAE,mBAAmB,GAChC,OAAO,CAAC,oBAAoB,EAAE,CAAC;IAqBlC;;;;;;OAMG;WACU,uBAAuB,CAClC,EAAE,EAAE,iBAAiB,EACrB,UAAU,EAAE,MAAM,EAClB,WAAW,CAAC,EAAE,mBAAmB,GAChC,OAAO,CAAC,oBAAoB,EAAE,CAAC;IA8ClC;;;;;OAKG;WACU,gBAAgB,CAC3B,EAAE,EAAE,iBAAiB,EACrB,UAAU,EAAE,MAAM,EAClB,WAAW,CAAC,EAAE,mBAAmB,GAChC,OAAO,CAAC,oBAAoB,EAAE,CAAC;IAIlC;;OAEG;WACU,cAAc,CACzB,EAAE,EAAE,iBAAiB,EACrB,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC,MAAM,EAAE,CAAC;IAYpB;;OAEG;WACU,IAAI,CACf,EAAE,EAAE,iBAAiB,EACrB,WAAW,GAAE,OAAe,EAC5B,WAAW,CAAC,EAAE,mBAAmB,GAChC,OAAO,CAAC,oBAAoB,EAAE,CAAC;IAuBlC;;OAEG;WACU,MAAM,CAAC,EAAE,EAAE,iBAAiB,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAIrE;;;OAGG;WACU,WAAW,CACtB,EAAE,EAAE,iBAAiB,EACrB,UAAU,EAAE,MAAM,EAClB,UAAU,EAAE,MAAM,EAClB,WAAW,CAAC,EAAE,mBAAmB,GAChC,OAAO,CAAC,IAAI,CAAC;IAUhB;;OAEG;WACU,kBAAkB,CAC7B,EAAE,EAAE,iBAAiB,EACrB,UAAU,EAAE,MAAM,GACjB,OAAO,CAAC,IAAI,CAAC;IAOhB;;OAEG;WACU,MAAM,CACjB,EAAE,EAAE,iBAAiB,EACrB,UAAU,EAAE,MAAM,EAClB,UAAU,EAAE,MAAM,EAClB,WAAW,CAAC,EAAE,mBAAmB,GAChC,OAAO,CAAC,IAAI,CAAC;IAchB;;OAEG;WACU,OAAO,CAClB,EAAE,EAAE,iBAAiB,EACrB,UAAU,EAAE,MAAM,EAClB,UAAU,EAAE,MAAM,EAClB,WAAW,CAAC,EAAE,mBAAmB,GAChC,OAAO,CAAC,IAAI,CAAC;IAchB;;;;;;;OAOG;WACU,WAAW,CACtB,EAAE,EAAE,iBAAiB,EACrB,QAAQ,CAAC,EAAE,MAAM,GAChB,OAAO,CAAC,OAAO,CAAC;CAGpB"}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { tableExists } from "../../system/compatibility.js";
|
|
1
2
|
import { DispatchSubscription } from "../models/DispatchSubscription.js";
|
|
2
3
|
//#region src/dispatch/collections/DispatchSubscriptions.ts
|
|
3
4
|
/**
|
|
@@ -199,15 +200,15 @@ var DispatchSubscriptionCollection = class {
|
|
|
199
200
|
await db.query(sql, ...params);
|
|
200
201
|
}
|
|
201
202
|
/**
|
|
202
|
-
* Check if subscriptions table exists
|
|
203
|
+
* Check if subscriptions table exists.
|
|
204
|
+
*
|
|
205
|
+
* Delegates to the shared, engine-aware {@link tableExists} rather than
|
|
206
|
+
* probing with `SELECT ... LIMIT 1` inside a try/catch — see
|
|
207
|
+
* `DispatchCollection.tableExists()` for why a probe-and-catch check is
|
|
208
|
+
* unsafe inside a PostgreSQL transaction (#2861).
|
|
203
209
|
*/
|
|
204
|
-
static async tableExists(db) {
|
|
205
|
-
|
|
206
|
-
await db.query(`SELECT 1 FROM _smrt_dispatch_subscriptions LIMIT 1`);
|
|
207
|
-
return true;
|
|
208
|
-
} catch {
|
|
209
|
-
return false;
|
|
210
|
-
}
|
|
210
|
+
static async tableExists(db, typeHint) {
|
|
211
|
+
return tableExists(db, "_smrt_dispatch_subscriptions", typeHint);
|
|
211
212
|
}
|
|
212
213
|
};
|
|
213
214
|
//#endregion
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"DispatchSubscriptions.js","names":[],"sources":["../../../src/dispatch/collections/DispatchSubscriptions.ts"],"sourcesContent":["/**\n * DispatchSubscriptionCollection - Storage operations for dispatch subscriptions\n *\n * CRUD operations for the _smrt_dispatch_subscriptions system table.\n */\n\nimport type { DatabaseInterface } from '@happyvertical/sql';\nimport {\n DispatchSubscription,\n type DispatchSubscriptionData,\n} from '../models/DispatchSubscription.js';\nimport type { DispatchTenantScope } from '../tenant-resolver.js';\n\n/**\n * Build a SQL tenant predicate fragment for subscription queries (S5 #1398).\n *\n * Mirrors the dispatch-row scoping, but subscription identity is *per tenant* —\n * a subscription belongs to exactly one scope, so an active tenant sees only its\n * own subscriptions (not also globals), preventing tenant B from finding/\n * mutating tenant A's (or a shared global) subscription row. Semantics:\n *\n * - tenancy off / no scope → no predicate.\n * - active tenant T → `tenant_id = ?`.\n * - tenancy on, no active tenant → `tenant_id IS NULL` (global subs only).\n *\n * Uses `?` positional placeholders (these collection queries bind via the\n * sqlite-style `?` convention used elsewhere in this file).\n *\n * @returns The SQL fragment (without a leading `AND`), or `null` when no\n * predicate applies. When a value is bound it is pushed onto `params`.\n */\nfunction subscriptionTenantClause(\n scope: DispatchTenantScope | undefined,\n params: unknown[],\n): string | null {\n if (!scope?.enforced) {\n return null;\n }\n if (scope.tenantId !== null) {\n params.push(scope.tenantId);\n return 'tenant_id = ?';\n }\n return 'tenant_id IS NULL';\n}\n\n/**\n * Storage operations for subscriptions in _smrt_dispatch_subscriptions table\n */\nexport class DispatchSubscriptionCollection {\n /**\n * Insert or update a subscription.\n *\n * Identity is tenant-scoped (S5 #1398): the conflict key is\n * `(tenant_id, signal_type, subscriber)` so the same (signal_type, subscriber)\n * pair can exist independently per tenant and one tenant cannot overwrite\n * another's subscription. Global (NULL tenant_id) subscriptions are deduped by\n * the NULL-aware upsert path in @happyvertical/sql.\n */\n static async upsert(\n db: DatabaseInterface,\n subscription: DispatchSubscription,\n ): Promise<void> {\n const row = subscription.toRow();\n await db.upsert(\n '_smrt_dispatch_subscriptions',\n ['tenant_id', 'signal_type', 'subscriber'],\n {\n id: row.id,\n signal_type: row.signal_type,\n subscriber: row.subscriber,\n handler: row.handler,\n delivery: row.delivery,\n enabled: row.enabled,\n tenant_id: row.tenant_id,\n created_at: row.created_at,\n updated_at: row.updated_at,\n },\n );\n }\n\n /**\n * Get a subscription by ID\n */\n static async get(\n db: DatabaseInterface,\n id: string,\n ): Promise<DispatchSubscription | null> {\n const result = await db.single`\n SELECT * FROM _smrt_dispatch_subscriptions WHERE id = ${id}\n `;\n\n if (!result) return null;\n return DispatchSubscription.fromRow(result as DispatchSubscriptionData);\n }\n\n /**\n * Find subscription by signal type and subscriber\n */\n static async find(\n db: DatabaseInterface,\n signalType: string,\n subscriber: string,\n ): Promise<DispatchSubscription | null> {\n const result = await db.single`\n SELECT * FROM _smrt_dispatch_subscriptions\n WHERE signal_type = ${signalType} AND subscriber = ${subscriber}\n `;\n\n if (!result) return null;\n return DispatchSubscription.fromRow(result as DispatchSubscriptionData);\n }\n\n /**\n * Get all subscriptions for a subscriber, scoped to the active tenant (S5 #1398).\n */\n static async findBySubscriber(\n db: DatabaseInterface,\n subscriber: string,\n enabledOnly: boolean = true,\n tenantScope?: DispatchTenantScope,\n ): Promise<DispatchSubscription[]> {\n let sql = `SELECT * FROM _smrt_dispatch_subscriptions WHERE subscriber = ?`;\n const params: unknown[] = [subscriber];\n\n if (enabledOnly) {\n sql += ' AND enabled = 1';\n }\n\n const tenantClause = subscriptionTenantClause(tenantScope, params);\n if (tenantClause) {\n sql += ` AND ${tenantClause}`;\n }\n\n sql += ' ORDER BY signal_type ASC';\n\n const { rows } = await db.query(sql, ...params);\n return rows.map((row: Record<string, unknown>) =>\n DispatchSubscription.fromRow(row as unknown as DispatchSubscriptionData),\n );\n }\n\n /**\n * Get all subscriptions that match a signal type\n *\n * Uses SQL-level filtering for exact matches. Wildcard subscriptions\n * (containing '*') are fetched separately and filtered in memory since\n * pattern matching requires application-side regex evaluation.\n */\n static async findMatchingSubscribers(\n db: DatabaseInterface,\n signalType: string,\n tenantScope?: DispatchTenantScope,\n ): Promise<DispatchSubscription[]> {\n // SQL-level: exact match subscriptions (scoped to the active tenant).\n const exactParams: unknown[] = [signalType];\n let exactSql = `SELECT * FROM _smrt_dispatch_subscriptions\n WHERE enabled = 1 AND signal_type = ?`;\n const exactTenantClause = subscriptionTenantClause(\n tenantScope,\n exactParams,\n );\n if (exactTenantClause) {\n exactSql += ` AND ${exactTenantClause}`;\n }\n const { rows: exactRows } = await db.query(exactSql, ...exactParams);\n\n const exactSubs = exactRows.map((row: Record<string, unknown>) =>\n DispatchSubscription.fromRow(row as unknown as DispatchSubscriptionData),\n );\n\n // SQL-level: wildcard subscriptions (contain '*'), scoped to the tenant.\n // Must be filtered in memory for pattern matching.\n const wildcardParams: unknown[] = [];\n let wildcardSql = `SELECT * FROM _smrt_dispatch_subscriptions\n WHERE enabled = 1 AND signal_type LIKE '%*%'`;\n const wildcardTenantClause = subscriptionTenantClause(\n tenantScope,\n wildcardParams,\n );\n if (wildcardTenantClause) {\n wildcardSql += ` AND ${wildcardTenantClause}`;\n }\n const { rows: wildcardRows } = await db.query(\n wildcardSql,\n ...wildcardParams,\n );\n\n const wildcardSubs = wildcardRows\n .map((row: Record<string, unknown>) =>\n DispatchSubscription.fromRow(\n row as unknown as DispatchSubscriptionData,\n ),\n )\n .filter((sub) => sub.matches(signalType));\n\n return [...exactSubs, ...wildcardSubs];\n }\n\n /**\n * Find all enabled subscriptions matching a signal type\n *\n * Alias for findMatchingSubscribers — used by emit() to determine\n * fan-out targets. Scoped to the active tenant (S5 #1398).\n */\n static async findBySignalType(\n db: DatabaseInterface,\n signalType: string,\n tenantScope?: DispatchTenantScope,\n ): Promise<DispatchSubscription[]> {\n return this.findMatchingSubscribers(db, signalType, tenantScope);\n }\n\n /**\n * Get all unique signal types that a subscriber is subscribed to\n */\n static async getSignalTypes(\n db: DatabaseInterface,\n subscriber: string,\n ): Promise<string[]> {\n const { rows } = await db.query(\n `SELECT DISTINCT signal_type FROM _smrt_dispatch_subscriptions\n WHERE subscriber = ? AND enabled = 1`,\n subscriber,\n );\n\n return rows.map(\n (row: Record<string, unknown>) => row.signal_type as string,\n );\n }\n\n /**\n * List all subscriptions, scoped to the active tenant (S5 #1398).\n */\n static async list(\n db: DatabaseInterface,\n enabledOnly: boolean = false,\n tenantScope?: DispatchTenantScope,\n ): Promise<DispatchSubscription[]> {\n const conditions: string[] = [];\n const params: unknown[] = [];\n if (enabledOnly) {\n conditions.push('enabled = 1');\n }\n const tenantClause = subscriptionTenantClause(tenantScope, params);\n if (tenantClause) {\n conditions.push(tenantClause);\n }\n\n let sql = 'SELECT * FROM _smrt_dispatch_subscriptions';\n if (conditions.length > 0) {\n sql += ` WHERE ${conditions.join(' AND ')}`;\n }\n sql += ' ORDER BY subscriber ASC, signal_type ASC';\n\n const { rows } = await db.query(sql, ...params);\n return rows.map((row: Record<string, unknown>) =>\n DispatchSubscription.fromRow(row as unknown as DispatchSubscriptionData),\n );\n }\n\n /**\n * Delete a subscription\n */\n static async delete(db: DatabaseInterface, id: string): Promise<void> {\n await db.query(`DELETE FROM _smrt_dispatch_subscriptions WHERE id = ?`, id);\n }\n\n /**\n * Delete subscription by signal type and subscriber, scoped to the active\n * tenant (S5 #1398) so tenant B cannot delete tenant A's subscription.\n */\n static async deleteByKey(\n db: DatabaseInterface,\n signalType: string,\n subscriber: string,\n tenantScope?: DispatchTenantScope,\n ): Promise<void> {\n const params: unknown[] = [signalType, subscriber];\n let sql = `DELETE FROM _smrt_dispatch_subscriptions WHERE signal_type = ? AND subscriber = ?`;\n const tenantClause = subscriptionTenantClause(tenantScope, params);\n if (tenantClause) {\n sql += ` AND ${tenantClause}`;\n }\n await db.query(sql, ...params);\n }\n\n /**\n * Delete all subscriptions for a subscriber\n */\n static async deleteBySubscriber(\n db: DatabaseInterface,\n subscriber: string,\n ): Promise<void> {\n await db.query(\n `DELETE FROM _smrt_dispatch_subscriptions WHERE subscriber = ?`,\n subscriber,\n );\n }\n\n /**\n * Enable a subscription, scoped to the active tenant (S5 #1398).\n */\n static async enable(\n db: DatabaseInterface,\n signalType: string,\n subscriber: string,\n tenantScope?: DispatchTenantScope,\n ): Promise<void> {\n const params: unknown[] = [\n new Date().toISOString(),\n signalType,\n subscriber,\n ];\n let sql = `UPDATE _smrt_dispatch_subscriptions SET enabled = 1, updated_at = ? WHERE signal_type = ? AND subscriber = ?`;\n const tenantClause = subscriptionTenantClause(tenantScope, params);\n if (tenantClause) {\n sql += ` AND ${tenantClause}`;\n }\n await db.query(sql, ...params);\n }\n\n /**\n * Disable a subscription, scoped to the active tenant (S5 #1398).\n */\n static async disable(\n db: DatabaseInterface,\n signalType: string,\n subscriber: string,\n tenantScope?: DispatchTenantScope,\n ): Promise<void> {\n const params: unknown[] = [\n new Date().toISOString(),\n signalType,\n subscriber,\n ];\n let sql = `UPDATE _smrt_dispatch_subscriptions SET enabled = 0, updated_at = ? WHERE signal_type = ? AND subscriber = ?`;\n const tenantClause = subscriptionTenantClause(tenantScope, params);\n if (tenantClause) {\n sql += ` AND ${tenantClause}`;\n }\n await db.query(sql, ...params);\n }\n\n /**\n * Check if subscriptions table exists\n */\n static async tableExists(db: DatabaseInterface): Promise<boolean> {\n try {\n await db.query(`SELECT 1 FROM _smrt_dispatch_subscriptions LIMIT 1`);\n return true;\n } catch {\n return false;\n }\n }\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AA+BA,SAAS,yBACP,OACA,QACe;CACf,IAAI,CAAC,OAAO,UACV,OAAO;CAET,IAAI,MAAM,aAAa,MAAM;EAC3B,OAAO,KAAK,MAAM,QAAQ;EAC1B,OAAO;CACT;CACA,OAAO;AACT;;;;AAKA,IAAa,iCAAb,MAA4C;;;;;;;;;;CAU1C,aAAa,OACX,IACA,cACe;EACf,MAAM,MAAM,aAAa,MAAM;EAC/B,MAAM,GAAG,OACP,gCACA;GAAC;GAAa;GAAe;EAAY,GACzC;GACE,IAAI,IAAI;GACR,aAAa,IAAI;GACjB,YAAY,IAAI;GAChB,SAAS,IAAI;GACb,UAAU,IAAI;GACd,SAAS,IAAI;GACb,WAAW,IAAI;GACf,YAAY,IAAI;GAChB,YAAY,IAAI;EAClB,CACF;CACF;;;;CAKA,aAAa,IACX,IACA,IACsC;EACtC,MAAM,SAAS,MAAM,GAAG,MAAM;8DAC4B,GAAG;;EAG7D,IAAI,CAAC,QAAQ,OAAO;EACpB,OAAO,qBAAqB,QAAQ,MAAkC;CACxE;;;;CAKA,aAAa,KACX,IACA,YACA,YACsC;EACtC,MAAM,SAAS,MAAM,GAAG,MAAM;;4BAEN,WAAW,oBAAoB,WAAW;;EAGlE,IAAI,CAAC,QAAQ,OAAO;EACpB,OAAO,qBAAqB,QAAQ,MAAkC;CACxE;;;;CAKA,aAAa,iBACX,IACA,YACA,cAAuB,MACvB,aACiC;EACjC,IAAI,MAAM;EACV,MAAM,SAAoB,CAAC,UAAU;EAErC,IAAI,aACF,OAAO;EAGT,MAAM,eAAe,yBAAyB,aAAa,MAAM;EACjE,IAAI,cACF,OAAO,QAAQ;EAGjB,OAAO;EAEP,MAAM,EAAE,SAAS,MAAM,GAAG,MAAM,KAAK,GAAG,MAAM;EAC9C,OAAO,KAAK,KAAK,QACf,qBAAqB,QAAQ,GAA0C,CACzE;CACF;;;;;;;;CASA,aAAa,wBACX,IACA,YACA,aACiC;EAEjC,MAAM,cAAyB,CAAC,UAAU;EAC1C,IAAI,WAAW;;EAEf,MAAM,oBAAoB,yBACxB,aACA,WACF;EACA,IAAI,mBACF,YAAY,QAAQ;EAEtB,MAAM,EAAE,MAAM,cAAc,MAAM,GAAG,MAAM,UAAU,GAAG,WAAW;EAEnE,MAAM,YAAY,UAAU,KAAK,QAC/B,qBAAqB,QAAQ,GAA0C,CACzE;EAIA,MAAM,iBAA4B,CAAC;EACnC,IAAI,cAAc;;EAElB,MAAM,uBAAuB,yBAC3B,aACA,cACF;EACA,IAAI,sBACF,eAAe,QAAQ;EAEzB,MAAM,EAAE,MAAM,iBAAiB,MAAM,GAAG,MACtC,aACA,GAAG,cACL;EAEA,MAAM,eAAe,aAClB,KAAK,QACJ,qBAAqB,QACnB,GACF,CACF,CAAC,CACA,QAAQ,QAAQ,IAAI,QAAQ,UAAU,CAAC;EAE1C,OAAO,CAAC,GAAG,WAAW,GAAG,YAAY;CACvC;;;;;;;CAQA,aAAa,iBACX,IACA,YACA,aACiC;EACjC,OAAO,KAAK,wBAAwB,IAAI,YAAY,WAAW;CACjE;;;;CAKA,aAAa,eACX,IACA,YACmB;EACnB,MAAM,EAAE,SAAS,MAAM,GAAG,MACxB;8CAEA,UACF;EAEA,OAAO,KAAK,KACT,QAAiC,IAAI,WACxC;CACF;;;;CAKA,aAAa,KACX,IACA,cAAuB,OACvB,aACiC;EACjC,MAAM,aAAuB,CAAC;EAC9B,MAAM,SAAoB,CAAC;EAC3B,IAAI,aACF,WAAW,KAAK,aAAa;EAE/B,MAAM,eAAe,yBAAyB,aAAa,MAAM;EACjE,IAAI,cACF,WAAW,KAAK,YAAY;EAG9B,IAAI,MAAM;EACV,IAAI,WAAW,SAAS,GACtB,OAAO,UAAU,WAAW,KAAK,OAAO;EAE1C,OAAO;EAEP,MAAM,EAAE,SAAS,MAAM,GAAG,MAAM,KAAK,GAAG,MAAM;EAC9C,OAAO,KAAK,KAAK,QACf,qBAAqB,QAAQ,GAA0C,CACzE;CACF;;;;CAKA,aAAa,OAAO,IAAuB,IAA2B;EACpE,MAAM,GAAG,MAAM,yDAAyD,EAAE;CAC5E;;;;;CAMA,aAAa,YACX,IACA,YACA,YACA,aACe;EACf,MAAM,SAAoB,CAAC,YAAY,UAAU;EACjD,IAAI,MAAM;EACV,MAAM,eAAe,yBAAyB,aAAa,MAAM;EACjE,IAAI,cACF,OAAO,QAAQ;EAEjB,MAAM,GAAG,MAAM,KAAK,GAAG,MAAM;CAC/B;;;;CAKA,aAAa,mBACX,IACA,YACe;EACf,MAAM,GAAG,MACP,iEACA,UACF;CACF;;;;CAKA,aAAa,OACX,IACA,YACA,YACA,aACe;EACf,MAAM,SAAoB;oBACxB,IAAI,KAAK,EAAA,CAAE,YAAY;GACvB;GACA;EACF;EACA,IAAI,MAAM;EACV,MAAM,eAAe,yBAAyB,aAAa,MAAM;EACjE,IAAI,cACF,OAAO,QAAQ;EAEjB,MAAM,GAAG,MAAM,KAAK,GAAG,MAAM;CAC/B;;;;CAKA,aAAa,QACX,IACA,YACA,YACA,aACe;EACf,MAAM,SAAoB;oBACxB,IAAI,KAAK,EAAA,CAAE,YAAY;GACvB;GACA;EACF;EACA,IAAI,MAAM;EACV,MAAM,eAAe,yBAAyB,aAAa,MAAM;EACjE,IAAI,cACF,OAAO,QAAQ;EAEjB,MAAM,GAAG,MAAM,KAAK,GAAG,MAAM;CAC/B;;;;CAKA,aAAa,YAAY,IAAyC;EAChE,IAAI;GACF,MAAM,GAAG,MAAM,oDAAoD;GACnE,OAAO;EACT,QAAQ;GACN,OAAO;EACT;CACF;AACF"}
|
|
1
|
+
{"version":3,"file":"DispatchSubscriptions.js","names":[],"sources":["../../../src/dispatch/collections/DispatchSubscriptions.ts"],"sourcesContent":["/**\n * DispatchSubscriptionCollection - Storage operations for dispatch subscriptions\n *\n * CRUD operations for the _smrt_dispatch_subscriptions system table.\n */\n\nimport type { DatabaseInterface } from '@happyvertical/sql';\nimport { tableExists } from '../../system/compatibility.js';\nimport {\n DispatchSubscription,\n type DispatchSubscriptionData,\n} from '../models/DispatchSubscription.js';\nimport type { DispatchTenantScope } from '../tenant-resolver.js';\n\n/**\n * Build a SQL tenant predicate fragment for subscription queries (S5 #1398).\n *\n * Mirrors the dispatch-row scoping, but subscription identity is *per tenant* —\n * a subscription belongs to exactly one scope, so an active tenant sees only its\n * own subscriptions (not also globals), preventing tenant B from finding/\n * mutating tenant A's (or a shared global) subscription row. Semantics:\n *\n * - tenancy off / no scope → no predicate.\n * - active tenant T → `tenant_id = ?`.\n * - tenancy on, no active tenant → `tenant_id IS NULL` (global subs only).\n *\n * Uses `?` positional placeholders (these collection queries bind via the\n * sqlite-style `?` convention used elsewhere in this file).\n *\n * @returns The SQL fragment (without a leading `AND`), or `null` when no\n * predicate applies. When a value is bound it is pushed onto `params`.\n */\nfunction subscriptionTenantClause(\n scope: DispatchTenantScope | undefined,\n params: unknown[],\n): string | null {\n if (!scope?.enforced) {\n return null;\n }\n if (scope.tenantId !== null) {\n params.push(scope.tenantId);\n return 'tenant_id = ?';\n }\n return 'tenant_id IS NULL';\n}\n\n/**\n * Storage operations for subscriptions in _smrt_dispatch_subscriptions table\n */\nexport class DispatchSubscriptionCollection {\n /**\n * Insert or update a subscription.\n *\n * Identity is tenant-scoped (S5 #1398): the conflict key is\n * `(tenant_id, signal_type, subscriber)` so the same (signal_type, subscriber)\n * pair can exist independently per tenant and one tenant cannot overwrite\n * another's subscription. Global (NULL tenant_id) subscriptions are deduped by\n * the NULL-aware upsert path in @happyvertical/sql.\n */\n static async upsert(\n db: DatabaseInterface,\n subscription: DispatchSubscription,\n ): Promise<void> {\n const row = subscription.toRow();\n await db.upsert(\n '_smrt_dispatch_subscriptions',\n ['tenant_id', 'signal_type', 'subscriber'],\n {\n id: row.id,\n signal_type: row.signal_type,\n subscriber: row.subscriber,\n handler: row.handler,\n delivery: row.delivery,\n enabled: row.enabled,\n tenant_id: row.tenant_id,\n created_at: row.created_at,\n updated_at: row.updated_at,\n },\n );\n }\n\n /**\n * Get a subscription by ID\n */\n static async get(\n db: DatabaseInterface,\n id: string,\n ): Promise<DispatchSubscription | null> {\n const result = await db.single`\n SELECT * FROM _smrt_dispatch_subscriptions WHERE id = ${id}\n `;\n\n if (!result) return null;\n return DispatchSubscription.fromRow(result as DispatchSubscriptionData);\n }\n\n /**\n * Find subscription by signal type and subscriber\n */\n static async find(\n db: DatabaseInterface,\n signalType: string,\n subscriber: string,\n ): Promise<DispatchSubscription | null> {\n const result = await db.single`\n SELECT * FROM _smrt_dispatch_subscriptions\n WHERE signal_type = ${signalType} AND subscriber = ${subscriber}\n `;\n\n if (!result) return null;\n return DispatchSubscription.fromRow(result as DispatchSubscriptionData);\n }\n\n /**\n * Get all subscriptions for a subscriber, scoped to the active tenant (S5 #1398).\n */\n static async findBySubscriber(\n db: DatabaseInterface,\n subscriber: string,\n enabledOnly: boolean = true,\n tenantScope?: DispatchTenantScope,\n ): Promise<DispatchSubscription[]> {\n let sql = `SELECT * FROM _smrt_dispatch_subscriptions WHERE subscriber = ?`;\n const params: unknown[] = [subscriber];\n\n if (enabledOnly) {\n sql += ' AND enabled = 1';\n }\n\n const tenantClause = subscriptionTenantClause(tenantScope, params);\n if (tenantClause) {\n sql += ` AND ${tenantClause}`;\n }\n\n sql += ' ORDER BY signal_type ASC';\n\n const { rows } = await db.query(sql, ...params);\n return rows.map((row: Record<string, unknown>) =>\n DispatchSubscription.fromRow(row as unknown as DispatchSubscriptionData),\n );\n }\n\n /**\n * Get all subscriptions that match a signal type\n *\n * Uses SQL-level filtering for exact matches. Wildcard subscriptions\n * (containing '*') are fetched separately and filtered in memory since\n * pattern matching requires application-side regex evaluation.\n */\n static async findMatchingSubscribers(\n db: DatabaseInterface,\n signalType: string,\n tenantScope?: DispatchTenantScope,\n ): Promise<DispatchSubscription[]> {\n // SQL-level: exact match subscriptions (scoped to the active tenant).\n const exactParams: unknown[] = [signalType];\n let exactSql = `SELECT * FROM _smrt_dispatch_subscriptions\n WHERE enabled = 1 AND signal_type = ?`;\n const exactTenantClause = subscriptionTenantClause(\n tenantScope,\n exactParams,\n );\n if (exactTenantClause) {\n exactSql += ` AND ${exactTenantClause}`;\n }\n const { rows: exactRows } = await db.query(exactSql, ...exactParams);\n\n const exactSubs = exactRows.map((row: Record<string, unknown>) =>\n DispatchSubscription.fromRow(row as unknown as DispatchSubscriptionData),\n );\n\n // SQL-level: wildcard subscriptions (contain '*'), scoped to the tenant.\n // Must be filtered in memory for pattern matching.\n const wildcardParams: unknown[] = [];\n let wildcardSql = `SELECT * FROM _smrt_dispatch_subscriptions\n WHERE enabled = 1 AND signal_type LIKE '%*%'`;\n const wildcardTenantClause = subscriptionTenantClause(\n tenantScope,\n wildcardParams,\n );\n if (wildcardTenantClause) {\n wildcardSql += ` AND ${wildcardTenantClause}`;\n }\n const { rows: wildcardRows } = await db.query(\n wildcardSql,\n ...wildcardParams,\n );\n\n const wildcardSubs = wildcardRows\n .map((row: Record<string, unknown>) =>\n DispatchSubscription.fromRow(\n row as unknown as DispatchSubscriptionData,\n ),\n )\n .filter((sub) => sub.matches(signalType));\n\n return [...exactSubs, ...wildcardSubs];\n }\n\n /**\n * Find all enabled subscriptions matching a signal type\n *\n * Alias for findMatchingSubscribers — used by emit() to determine\n * fan-out targets. Scoped to the active tenant (S5 #1398).\n */\n static async findBySignalType(\n db: DatabaseInterface,\n signalType: string,\n tenantScope?: DispatchTenantScope,\n ): Promise<DispatchSubscription[]> {\n return this.findMatchingSubscribers(db, signalType, tenantScope);\n }\n\n /**\n * Get all unique signal types that a subscriber is subscribed to\n */\n static async getSignalTypes(\n db: DatabaseInterface,\n subscriber: string,\n ): Promise<string[]> {\n const { rows } = await db.query(\n `SELECT DISTINCT signal_type FROM _smrt_dispatch_subscriptions\n WHERE subscriber = ? AND enabled = 1`,\n subscriber,\n );\n\n return rows.map(\n (row: Record<string, unknown>) => row.signal_type as string,\n );\n }\n\n /**\n * List all subscriptions, scoped to the active tenant (S5 #1398).\n */\n static async list(\n db: DatabaseInterface,\n enabledOnly: boolean = false,\n tenantScope?: DispatchTenantScope,\n ): Promise<DispatchSubscription[]> {\n const conditions: string[] = [];\n const params: unknown[] = [];\n if (enabledOnly) {\n conditions.push('enabled = 1');\n }\n const tenantClause = subscriptionTenantClause(tenantScope, params);\n if (tenantClause) {\n conditions.push(tenantClause);\n }\n\n let sql = 'SELECT * FROM _smrt_dispatch_subscriptions';\n if (conditions.length > 0) {\n sql += ` WHERE ${conditions.join(' AND ')}`;\n }\n sql += ' ORDER BY subscriber ASC, signal_type ASC';\n\n const { rows } = await db.query(sql, ...params);\n return rows.map((row: Record<string, unknown>) =>\n DispatchSubscription.fromRow(row as unknown as DispatchSubscriptionData),\n );\n }\n\n /**\n * Delete a subscription\n */\n static async delete(db: DatabaseInterface, id: string): Promise<void> {\n await db.query(`DELETE FROM _smrt_dispatch_subscriptions WHERE id = ?`, id);\n }\n\n /**\n * Delete subscription by signal type and subscriber, scoped to the active\n * tenant (S5 #1398) so tenant B cannot delete tenant A's subscription.\n */\n static async deleteByKey(\n db: DatabaseInterface,\n signalType: string,\n subscriber: string,\n tenantScope?: DispatchTenantScope,\n ): Promise<void> {\n const params: unknown[] = [signalType, subscriber];\n let sql = `DELETE FROM _smrt_dispatch_subscriptions WHERE signal_type = ? AND subscriber = ?`;\n const tenantClause = subscriptionTenantClause(tenantScope, params);\n if (tenantClause) {\n sql += ` AND ${tenantClause}`;\n }\n await db.query(sql, ...params);\n }\n\n /**\n * Delete all subscriptions for a subscriber\n */\n static async deleteBySubscriber(\n db: DatabaseInterface,\n subscriber: string,\n ): Promise<void> {\n await db.query(\n `DELETE FROM _smrt_dispatch_subscriptions WHERE subscriber = ?`,\n subscriber,\n );\n }\n\n /**\n * Enable a subscription, scoped to the active tenant (S5 #1398).\n */\n static async enable(\n db: DatabaseInterface,\n signalType: string,\n subscriber: string,\n tenantScope?: DispatchTenantScope,\n ): Promise<void> {\n const params: unknown[] = [\n new Date().toISOString(),\n signalType,\n subscriber,\n ];\n let sql = `UPDATE _smrt_dispatch_subscriptions SET enabled = 1, updated_at = ? WHERE signal_type = ? AND subscriber = ?`;\n const tenantClause = subscriptionTenantClause(tenantScope, params);\n if (tenantClause) {\n sql += ` AND ${tenantClause}`;\n }\n await db.query(sql, ...params);\n }\n\n /**\n * Disable a subscription, scoped to the active tenant (S5 #1398).\n */\n static async disable(\n db: DatabaseInterface,\n signalType: string,\n subscriber: string,\n tenantScope?: DispatchTenantScope,\n ): Promise<void> {\n const params: unknown[] = [\n new Date().toISOString(),\n signalType,\n subscriber,\n ];\n let sql = `UPDATE _smrt_dispatch_subscriptions SET enabled = 0, updated_at = ? WHERE signal_type = ? AND subscriber = ?`;\n const tenantClause = subscriptionTenantClause(tenantScope, params);\n if (tenantClause) {\n sql += ` AND ${tenantClause}`;\n }\n await db.query(sql, ...params);\n }\n\n /**\n * Check if subscriptions table exists.\n *\n * Delegates to the shared, engine-aware {@link tableExists} rather than\n * probing with `SELECT ... LIMIT 1` inside a try/catch — see\n * `DispatchCollection.tableExists()` for why a probe-and-catch check is\n * unsafe inside a PostgreSQL transaction (#2861).\n */\n static async tableExists(\n db: DatabaseInterface,\n typeHint?: string,\n ): Promise<boolean> {\n return tableExists(db, '_smrt_dispatch_subscriptions', typeHint);\n }\n}\n"],"mappings":"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"}
|
|
@@ -91,8 +91,18 @@ export declare class DispatchCollection {
|
|
|
91
91
|
*/
|
|
92
92
|
static cleanup(db: DatabaseInterface, options?: DispatchCleanupOptions, tenantScope?: DispatchTenantScope): Promise<DispatchCleanupResult>;
|
|
93
93
|
/**
|
|
94
|
-
* Check if dispatch table exists
|
|
94
|
+
* Check if dispatch table exists.
|
|
95
|
+
*
|
|
96
|
+
* Delegates to the shared, engine-aware {@link tableExists} rather than
|
|
97
|
+
* probing with `SELECT ... LIMIT 1` inside a try/catch: on PostgreSQL, a
|
|
98
|
+
* caught query error still aborts the surrounding transaction (any
|
|
99
|
+
* statement error does, regardless of whether the application handles it),
|
|
100
|
+
* so a probe-and-catch existence check silently poisons a transaction any
|
|
101
|
+
* caller wraps this in — exactly what `DispatchBus.initialize()`'s
|
|
102
|
+
* lock-guarded bootstrap transaction needs to do (#2861). The shared helper
|
|
103
|
+
* uses `information_schema.tables` on PostgreSQL instead, which never
|
|
104
|
+
* raises for a missing table.
|
|
95
105
|
*/
|
|
96
|
-
static tableExists(db: DatabaseInterface): Promise<boolean>;
|
|
106
|
+
static tableExists(db: DatabaseInterface, typeHint?: string): Promise<boolean>;
|
|
97
107
|
}
|
|
98
108
|
//# sourceMappingURL=Dispatches.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Dispatches.d.ts","sourceRoot":"","sources":["../../../src/dispatch/collections/Dispatches.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;
|
|
1
|
+
{"version":3,"file":"Dispatches.d.ts","sourceRoot":"","sources":["../../../src/dispatch/collections/Dispatches.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAEH,OAAO,KAAK,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAG5D,OAAO,EAAE,QAAQ,EAAqB,MAAM,uBAAuB,CAAC;AACpE,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,uBAAuB,CAAC;AACjE,OAAO,KAAK,EACV,sBAAsB,EACtB,qBAAqB,EACrB,mBAAmB,EACnB,oBAAoB,EACpB,cAAc,EACf,MAAM,aAAa,CAAC;AAoErB;;GAEG;AACH,qBAAa,kBAAkB;IAC7B;;OAEG;WACU,MAAM,CACjB,EAAE,EAAE,iBAAiB,EACrB,QAAQ,EAAE,QAAQ,GACjB,OAAO,CAAC,IAAI,CAAC;IA0BhB;;OAEG;WACU,MAAM,CACjB,EAAE,EAAE,iBAAiB,EACrB,QAAQ,EAAE,QAAQ,GACjB,OAAO,CAAC,IAAI,CAAC;IAyBhB;;;;;;OAMG;WACU,GAAG,CACd,EAAE,EAAE,iBAAiB,EACrB,EAAE,EAAE,MAAM,GACT,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC;IAS3B;;;;;;OAMG;WACU,SAAS,CACpB,EAAE,EAAE,iBAAiB,EACrB,EAAE,EAAE,MAAM,EACV,WAAW,CAAC,EAAE,mBAAmB,GAChC,OAAO,CAAC,QAAQ,GAAG,IAAI,CAAC;IAe3B;;OAEG;WACU,IAAI,CACf,EAAE,EAAE,iBAAiB,EACrB,OAAO,GAAE,mBAAwB,GAChC,OAAO,CAAC,QAAQ,EAAE,CAAC;IAuFtB;;;;;;;;;;OAUG;WACU,WAAW,CACtB,EAAE,EAAE,iBAAiB,EACrB,WAAW,EAAE,MAAM,EAAE,EACrB,KAAK,GAAE,MAAY,EACnB,UAAU,CAAC,EAAE,MAAM,EACnB,WAAW,CAAC,EAAE,mBAAmB,GAChC,OAAO,CAAC,QAAQ,EAAE,CAAC;IAsCtB;;;;;;;;;;;;;;;;OAgBG;WACU,KAAK,CAChB,EAAE,EAAE,iBAAiB,EACrB,QAAQ,EAAE,QAAQ,EAClB,WAAW,CAAC,EAAE,mBAAmB,GAChC,OAAO,CAAC,OAAO,CAAC;IA6BnB;;;;;;;OAOG;WACU,aAAa,CACxB,EAAE,EAAE,iBAAiB,EACrB,OAAO,GAAE,oBAAyB,EAClC,WAAW,CAAC,EAAE,mBAAmB,GAChC,OAAO,CAAC,QAAQ,EAAE,CAAC;IAuCtB;;OAEG;WACU,aAAa,CACxB,EAAE,EAAE,iBAAiB,EACrB,MAAM,EAAE,cAAc,GACrB,OAAO,CAAC,MAAM,CAAC;IAUlB;;OAEG;WACU,MAAM,CAAC,EAAE,EAAE,iBAAiB,EAAE,EAAE,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAIrE;;;;;;;OAOG;WACU,OAAO,CAClB,EAAE,EAAE,iBAAiB,EACrB,OAAO,GAAE,sBAA2B,EACpC,WAAW,CAAC,EAAE,mBAAmB,GAChC,OAAO,CAAC,qBAAqB,CAAC;IA4CjC;;;;;;;;;;;;OAYG;WACU,WAAW,CACtB,EAAE,EAAE,iBAAiB,EACrB,QAAQ,CAAC,EAAE,MAAM,GAChB,OAAO,CAAC,OAAO,CAAC;CAGpB"}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { toSafeInteger } from "../../utils/safe-integer.js";
|
|
2
|
+
import { tableExists } from "../../system/compatibility.js";
|
|
2
3
|
import { Dispatch } from "../models/Dispatch.js";
|
|
3
4
|
//#region src/dispatch/collections/Dispatches.ts
|
|
4
5
|
/**
|
|
@@ -290,15 +291,20 @@ var DispatchCollection = class {
|
|
|
290
291
|
return result;
|
|
291
292
|
}
|
|
292
293
|
/**
|
|
293
|
-
* Check if dispatch table exists
|
|
294
|
+
* Check if dispatch table exists.
|
|
295
|
+
*
|
|
296
|
+
* Delegates to the shared, engine-aware {@link tableExists} rather than
|
|
297
|
+
* probing with `SELECT ... LIMIT 1` inside a try/catch: on PostgreSQL, a
|
|
298
|
+
* caught query error still aborts the surrounding transaction (any
|
|
299
|
+
* statement error does, regardless of whether the application handles it),
|
|
300
|
+
* so a probe-and-catch existence check silently poisons a transaction any
|
|
301
|
+
* caller wraps this in — exactly what `DispatchBus.initialize()`'s
|
|
302
|
+
* lock-guarded bootstrap transaction needs to do (#2861). The shared helper
|
|
303
|
+
* uses `information_schema.tables` on PostgreSQL instead, which never
|
|
304
|
+
* raises for a missing table.
|
|
294
305
|
*/
|
|
295
|
-
static async tableExists(db) {
|
|
296
|
-
|
|
297
|
-
await db.query(`SELECT 1 FROM _smrt_dispatch LIMIT 1`);
|
|
298
|
-
return true;
|
|
299
|
-
} catch {
|
|
300
|
-
return false;
|
|
301
|
-
}
|
|
306
|
+
static async tableExists(db, typeHint) {
|
|
307
|
+
return tableExists(db, "_smrt_dispatch", typeHint);
|
|
302
308
|
}
|
|
303
309
|
};
|
|
304
310
|
//#endregion
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Dispatches.js","names":[],"sources":["../../../src/dispatch/collections/Dispatches.ts"],"sourcesContent":["/**\n * DispatchCollection - Storage operations for dispatch messages\n *\n * CRUD operations for the _smrt_dispatch system table.\n */\n\nimport type { DatabaseInterface } from '@happyvertical/sql';\nimport { toSafeInteger } from '../../utils/safe-integer.js';\nimport { Dispatch, type DispatchData } from '../models/Dispatch.js';\nimport type { DispatchTenantScope } from '../tenant-resolver.js';\nimport type {\n DispatchCleanupOptions,\n DispatchCleanupResult,\n DispatchListOptions,\n DispatchRetryOptions,\n DispatchStatus,\n} from '../types.js';\n\n/**\n * Build the SQL tenant predicate for the active scope (S5 #1398).\n *\n * Appends a parameterized condition to `conditions` (and the corresponding\n * value to `params`) based on the resolved {@link DispatchTenantScope}:\n *\n * - tenancy off / no scope → no predicate (pre-tenancy behavior)\n * - `enforced` + active tenant T → `(tenant_id = ? OR tenant_id IS NULL)`\n * - `enforced` + no active tenant → `tenant_id IS NULL` (fail-closed global)\n *\n * @param placeholder - Engine-specific positional placeholder (e.g. `$3`) used\n * when a tenant id value is bound. Ignored for the no-value branches.\n * @returns `true` when a value placeholder was consumed (so the caller can\n * advance its param index), `false` otherwise.\n */\nfunction pushTenantPredicate(\n conditions: string[],\n params: unknown[],\n scope: DispatchTenantScope | undefined,\n placeholder: string,\n): boolean {\n if (!scope?.enforced) {\n return false;\n }\n if (scope.tenantId !== null) {\n conditions.push(`(tenant_id = ${placeholder} OR tenant_id IS NULL)`);\n params.push(scope.tenantId);\n return true;\n }\n conditions.push('tenant_id IS NULL');\n return false;\n}\n\n/**\n * Count the rows a cleanup predicate selects, then delete them (#2375).\n *\n * Counting first gives a usable figure on every adapter — `rowCount` is not\n * reliably populated across the engines SMRT supports — and is what lets\n * `dryRun` preview the very same predicate the real cleanup runs. The two\n * statements are not transactional, so the figure is approximate under\n * concurrent writers.\n */\nasync function deleteOrCount(\n db: DatabaseInterface,\n conditions: string[],\n params: unknown[],\n dryRun: boolean,\n): Promise<number> {\n const where = conditions.join(' AND ');\n const countResult = await db.query(\n `SELECT COUNT(*) AS total FROM _smrt_dispatch WHERE ${where}`,\n ...params,\n );\n const total = toSafeInteger(\n countResult.rows?.[0]?.total ?? 0,\n 'Dispatch cleanup count',\n );\n if (total <= 0) return 0;\n\n if (!dryRun) {\n await db.query(`DELETE FROM _smrt_dispatch WHERE ${where}`, ...params);\n }\n\n return total;\n}\n\n/**\n * Storage operations for dispatches in _smrt_dispatch table\n */\nexport class DispatchCollection {\n /**\n * Insert a new dispatch\n */\n static async insert(\n db: DatabaseInterface,\n dispatch: Dispatch,\n ): Promise<void> {\n const row = dispatch.toRow();\n await db.query(\n `INSERT INTO _smrt_dispatch\n (id, type, source, source_id, payload, status, attempts, last_error,\n processed_at, processed_by, target_subscriber, correlation_id, tenant_id, metadata, created_at, updated_at)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)`,\n row.id,\n row.type,\n row.source,\n row.source_id,\n row.payload,\n row.status,\n row.attempts,\n row.last_error,\n row.processed_at,\n row.processed_by,\n row.target_subscriber,\n row.correlation_id,\n row.tenant_id,\n row.metadata,\n row.created_at,\n row.updated_at,\n );\n }\n\n /**\n * Update an existing dispatch\n */\n static async update(\n db: DatabaseInterface,\n dispatch: Dispatch,\n ): Promise<void> {\n const row = dispatch.toRow();\n await db.query(\n `UPDATE _smrt_dispatch SET\n type = $1, source = $2, source_id = $3, payload = $4, status = $5,\n attempts = $6, last_error = $7, processed_at = $8, processed_by = $9,\n target_subscriber = $10, correlation_id = $11, metadata = $12, updated_at = $13\n WHERE id = $14`,\n row.type,\n row.source,\n row.source_id,\n row.payload,\n row.status,\n row.attempts,\n row.last_error,\n row.processed_at,\n row.processed_by,\n row.target_subscriber,\n row.correlation_id,\n row.metadata,\n row.updated_at,\n row.id,\n );\n }\n\n /**\n * Get a dispatch by ID (unscoped).\n *\n * Internal/system use only — applies no tenant filter. Public reads go\n * through the DispatchBus, which uses {@link getScoped} so a caller cannot\n * fetch another tenant's dispatch by id (S5 #1398).\n */\n static async get(\n db: DatabaseInterface,\n id: string,\n ): Promise<Dispatch | null> {\n const result = await db.single`\n SELECT * FROM _smrt_dispatch WHERE id = ${id}\n `;\n\n if (!result) return null;\n return Dispatch.fromRow(result as DispatchData);\n }\n\n /**\n * Get a dispatch by ID, enforcing the active tenant scope (S5 #1398).\n *\n * Applies the same tenant predicate as {@link list}/{@link findPending}, so a\n * cross-tenant or out-of-scope id lookup returns `null` rather than leaking\n * the row. The scope is derived server-side by the DispatchBus.\n */\n static async getScoped(\n db: DatabaseInterface,\n id: string,\n tenantScope?: DispatchTenantScope,\n ): Promise<Dispatch | null> {\n const conditions: string[] = ['id = $1'];\n const params: unknown[] = [id];\n\n pushTenantPredicate(conditions, params, tenantScope, '$2');\n\n const { rows } = await db.query(\n `SELECT * FROM _smrt_dispatch WHERE ${conditions.join(' AND ')} LIMIT 1`,\n ...params,\n );\n\n if (rows.length === 0) return null;\n return Dispatch.fromRow(rows[0] as unknown as DispatchData);\n }\n\n /**\n * List dispatches with filtering\n */\n static async list(\n db: DatabaseInterface,\n options: DispatchListOptions = {},\n ): Promise<Dispatch[]> {\n const conditions: string[] = [];\n const params: unknown[] = [];\n let paramIndex = 1;\n\n if (options.status) {\n conditions.push(`status = $${paramIndex++}`);\n params.push(options.status);\n }\n\n if (options.source) {\n conditions.push(`source = $${paramIndex++}`);\n params.push(options.source);\n }\n\n if (options.type) {\n conditions.push(`type = $${paramIndex++}`);\n params.push(options.type);\n }\n\n if (options.targetSubscriber) {\n conditions.push(`target_subscriber = $${paramIndex++}`);\n params.push(options.targetSubscriber);\n }\n\n if (options.correlationId !== undefined) {\n conditions.push(`correlation_id = $${paramIndex++}`);\n params.push(options.correlationId);\n }\n\n // Tenant isolation (S5 #1398). Scope is derived server-side by the bus;\n // callers cannot widen it. See DispatchListOptions.tenantScope.\n if (\n pushTenantPredicate(\n conditions,\n params,\n options.tenantScope,\n `$${paramIndex}`,\n )\n ) {\n paramIndex++;\n }\n\n let sql = 'SELECT * FROM _smrt_dispatch';\n if (conditions.length > 0) {\n sql += ` WHERE ${conditions.join(' AND ')}`;\n }\n\n // Whitelist allowed orderBy values to prevent SQL injection\n const allowedOrderBy = [\n 'created_at DESC',\n 'created_at ASC',\n 'updated_at DESC',\n 'updated_at ASC',\n 'type',\n 'type DESC',\n 'type ASC',\n 'status',\n 'status DESC',\n 'status ASC',\n 'source',\n 'source DESC',\n 'source ASC',\n ];\n const safeOrderBy =\n options.orderBy && allowedOrderBy.includes(options.orderBy)\n ? options.orderBy\n : 'created_at DESC';\n sql += ` ORDER BY ${safeOrderBy}`;\n\n // Use parameterized queries for limit/offset\n if (options.limit !== undefined) {\n sql += ` LIMIT $${paramIndex++}`;\n params.push(options.limit);\n }\n\n if (options.offset !== undefined) {\n sql += ` OFFSET $${paramIndex++}`;\n params.push(options.offset);\n }\n\n const { rows } = await db.query(sql, ...params);\n return rows.map((row: Record<string, unknown>) =>\n Dispatch.fromRow(row as unknown as DispatchData),\n );\n }\n\n /**\n * Find pending dispatches for a set of signal types\n *\n * @param subscriber - When provided, filters to dispatches with no target\n * or targeted specifically at this subscriber (for fan-out delivery)\n * @param tenantScope - Active tenant scope (S5 #1398). Derived server-side by\n * the bus; restricts results so a subscriber in one tenant cannot see\n * another tenant's dispatch. See {@link pushTenantPredicate} for the exact\n * semantics (off → no filter; active tenant → that tenant + global; on but\n * no tenant → global only).\n */\n static async findPending(\n db: DatabaseInterface,\n signalTypes: string[],\n limit: number = 100,\n subscriber?: string,\n tenantScope?: DispatchTenantScope,\n ): Promise<Dispatch[]> {\n if (signalTypes.length === 0) {\n return [];\n }\n\n let paramIndex = 1;\n const placeholders = signalTypes.map(() => `$${paramIndex++}`).join(', ');\n const conditions: string[] = [\n \"status = 'pending'\",\n `type IN (${placeholders})`,\n ];\n const params: unknown[] = [...signalTypes];\n\n if (subscriber) {\n conditions.push(\n `(target_subscriber IS NULL OR target_subscriber = $${paramIndex++})`,\n );\n params.push(subscriber);\n }\n\n if (\n pushTenantPredicate(conditions, params, tenantScope, `$${paramIndex}`)\n ) {\n paramIndex++;\n }\n\n const sql = `SELECT * FROM _smrt_dispatch\n WHERE ${conditions.join(' AND ')}\n ORDER BY created_at ASC LIMIT $${paramIndex}`;\n params.push(limit);\n\n const { rows } = await db.query(sql, ...params);\n\n return rows.map((row: Record<string, unknown>) =>\n Dispatch.fromRow(row as unknown as DispatchData),\n );\n }\n\n /**\n * Atomically claim a pending dispatch for processing (S5 #1398).\n *\n * Performs a conditional `UPDATE ... WHERE id = ? AND status = 'pending'`\n * (plus the tenant predicate for the active scope) and reports whether the\n * row was claimed by THIS call. Only the caller whose update affected a row\n * may run the handler, which closes the claim TOCTOU window where a `process`\n * filter-then-update could let two workers (or a tenant-scoped and a\n * system-scoped processor) both pick up the same compete/global dispatch.\n *\n * On a successful claim the dispatch's status/attempts/updated_at are\n * advanced in the DB; the in-memory `dispatch` instance is updated to match\n * so the caller can proceed to `update()` it to completed/failed.\n *\n * @returns `true` if this call claimed the dispatch, `false` if another\n * worker already moved it out of `pending` (or it is not visible in scope).\n */\n static async claim(\n db: DatabaseInterface,\n dispatch: Dispatch,\n tenantScope?: DispatchTenantScope,\n ): Promise<boolean> {\n dispatch.markProcessing();\n const row = dispatch.toRow();\n\n const conditions: string[] = ['id = $4', \"status = 'pending'\"];\n const params: unknown[] = [\n row.status,\n row.attempts,\n row.updated_at,\n row.id,\n ];\n let paramIndex = 5;\n\n if (\n pushTenantPredicate(conditions, params, tenantScope, `$${paramIndex}`)\n ) {\n paramIndex++;\n }\n\n const { rowCount } = await db.query(\n `UPDATE _smrt_dispatch\n SET status = $1, attempts = $2, updated_at = $3\n WHERE ${conditions.join(' AND ')}`,\n ...params,\n );\n\n return (rowCount ?? 0) > 0;\n }\n\n /**\n * Find failed dispatches eligible for retry.\n *\n * @param tenantScope - Active tenant scope (S5 #1398). Derived server-side by\n * the bus; restricts retryable rows to the active tenant (plus global) so a\n * tenant's `retry()` cannot reset another tenant's failed dispatches. See\n * {@link pushTenantPredicate} for the exact semantics.\n */\n static async findRetryable(\n db: DatabaseInterface,\n options: DispatchRetryOptions = {},\n tenantScope?: DispatchTenantScope,\n ): Promise<Dispatch[]> {\n const conditions: string[] = [\"status = 'failed'\"];\n const params: unknown[] = [];\n let paramIndex = 1;\n\n if (options.maxAttempts) {\n conditions.push(`attempts < $${paramIndex++}`);\n params.push(options.maxAttempts);\n }\n\n if (options.olderThan) {\n conditions.push(`created_at < $${paramIndex++}`);\n params.push(options.olderThan.toISOString());\n }\n\n if (options.signalTypes && options.signalTypes.length > 0) {\n const placeholders = options.signalTypes\n .map(() => `$${paramIndex++}`)\n .join(', ');\n conditions.push(`type IN (${placeholders})`);\n params.push(...options.signalTypes);\n }\n\n if (\n pushTenantPredicate(conditions, params, tenantScope, `$${paramIndex}`)\n ) {\n paramIndex++;\n }\n\n const { rows } = await db.query(\n `SELECT * FROM _smrt_dispatch WHERE ${conditions.join(' AND ')} ORDER BY created_at ASC`,\n ...params,\n );\n\n return rows.map((row: Record<string, unknown>) =>\n Dispatch.fromRow(row as unknown as DispatchData),\n );\n }\n\n /**\n * Count dispatches by status\n */\n static async countByStatus(\n db: DatabaseInterface,\n status: DispatchStatus,\n ): Promise<number> {\n const result = await db.single`\n SELECT COUNT(*) as count FROM _smrt_dispatch WHERE status = ${status}\n `;\n return toSafeInteger(\n (result as { count?: unknown } | null)?.count ?? 0,\n 'Dispatch count',\n );\n }\n\n /**\n * Delete a dispatch\n */\n static async delete(db: DatabaseInterface, id: string): Promise<void> {\n await db.query(`DELETE FROM _smrt_dispatch WHERE id = $1`, id);\n }\n\n /**\n * Cleanup old dispatches.\n *\n * @param tenantScope - Active tenant scope (S5 #1398). Derived server-side by\n * the bus; restricts deletions to the active tenant (plus global) so a\n * tenant's `cleanup()` cannot delete another tenant's dispatches. See\n * {@link pushTenantPredicate} for the exact semantics.\n */\n static async cleanup(\n db: DatabaseInterface,\n options: DispatchCleanupOptions = {},\n tenantScope?: DispatchTenantScope,\n ): Promise<DispatchCleanupResult> {\n const result: DispatchCleanupResult = {\n completedDeleted: 0,\n failedDeleted: 0,\n };\n\n if (options.completedOlderThanDays) {\n const cutoff = new Date();\n cutoff.setDate(cutoff.getDate() - options.completedOlderThanDays);\n\n const conditions: string[] = [\n \"status = 'completed'\",\n 'processed_at < $1',\n ];\n const params: unknown[] = [cutoff.toISOString()];\n pushTenantPredicate(conditions, params, tenantScope, '$2');\n\n result.completedDeleted = await deleteOrCount(\n db,\n conditions,\n params,\n options.dryRun ?? false,\n );\n }\n\n if (options.failedOlderThanDays) {\n const cutoff = new Date();\n cutoff.setDate(cutoff.getDate() - options.failedOlderThanDays);\n\n const conditions: string[] = [\"status = 'failed'\", 'updated_at < $1'];\n const params: unknown[] = [cutoff.toISOString()];\n pushTenantPredicate(conditions, params, tenantScope, '$2');\n\n result.failedDeleted = await deleteOrCount(\n db,\n conditions,\n params,\n options.dryRun ?? false,\n );\n }\n\n return result;\n }\n\n /**\n * Check if dispatch table exists\n */\n static async tableExists(db: DatabaseInterface): Promise<boolean> {\n try {\n await db.query(`SELECT 1 FROM _smrt_dispatch LIMIT 1`);\n return true;\n } catch {\n return false;\n }\n }\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"Dispatches.js","names":[],"sources":["../../../src/dispatch/collections/Dispatches.ts"],"sourcesContent":["/**\n * DispatchCollection - Storage operations for dispatch messages\n *\n * CRUD operations for the _smrt_dispatch system table.\n */\n\nimport type { DatabaseInterface } from '@happyvertical/sql';\nimport { tableExists } from '../../system/compatibility.js';\nimport { toSafeInteger } from '../../utils/safe-integer.js';\nimport { Dispatch, type DispatchData } from '../models/Dispatch.js';\nimport type { DispatchTenantScope } from '../tenant-resolver.js';\nimport type {\n DispatchCleanupOptions,\n DispatchCleanupResult,\n DispatchListOptions,\n DispatchRetryOptions,\n DispatchStatus,\n} from '../types.js';\n\n/**\n * Build the SQL tenant predicate for the active scope (S5 #1398).\n *\n * Appends a parameterized condition to `conditions` (and the corresponding\n * value to `params`) based on the resolved {@link DispatchTenantScope}:\n *\n * - tenancy off / no scope → no predicate (pre-tenancy behavior)\n * - `enforced` + active tenant T → `(tenant_id = ? OR tenant_id IS NULL)`\n * - `enforced` + no active tenant → `tenant_id IS NULL` (fail-closed global)\n *\n * @param placeholder - Engine-specific positional placeholder (e.g. `$3`) used\n * when a tenant id value is bound. Ignored for the no-value branches.\n * @returns `true` when a value placeholder was consumed (so the caller can\n * advance its param index), `false` otherwise.\n */\nfunction pushTenantPredicate(\n conditions: string[],\n params: unknown[],\n scope: DispatchTenantScope | undefined,\n placeholder: string,\n): boolean {\n if (!scope?.enforced) {\n return false;\n }\n if (scope.tenantId !== null) {\n conditions.push(`(tenant_id = ${placeholder} OR tenant_id IS NULL)`);\n params.push(scope.tenantId);\n return true;\n }\n conditions.push('tenant_id IS NULL');\n return false;\n}\n\n/**\n * Count the rows a cleanup predicate selects, then delete them (#2375).\n *\n * Counting first gives a usable figure on every adapter — `rowCount` is not\n * reliably populated across the engines SMRT supports — and is what lets\n * `dryRun` preview the very same predicate the real cleanup runs. The two\n * statements are not transactional, so the figure is approximate under\n * concurrent writers.\n */\nasync function deleteOrCount(\n db: DatabaseInterface,\n conditions: string[],\n params: unknown[],\n dryRun: boolean,\n): Promise<number> {\n const where = conditions.join(' AND ');\n const countResult = await db.query(\n `SELECT COUNT(*) AS total FROM _smrt_dispatch WHERE ${where}`,\n ...params,\n );\n const total = toSafeInteger(\n countResult.rows?.[0]?.total ?? 0,\n 'Dispatch cleanup count',\n );\n if (total <= 0) return 0;\n\n if (!dryRun) {\n await db.query(`DELETE FROM _smrt_dispatch WHERE ${where}`, ...params);\n }\n\n return total;\n}\n\n/**\n * Storage operations for dispatches in _smrt_dispatch table\n */\nexport class DispatchCollection {\n /**\n * Insert a new dispatch\n */\n static async insert(\n db: DatabaseInterface,\n dispatch: Dispatch,\n ): Promise<void> {\n const row = dispatch.toRow();\n await db.query(\n `INSERT INTO _smrt_dispatch\n (id, type, source, source_id, payload, status, attempts, last_error,\n processed_at, processed_by, target_subscriber, correlation_id, tenant_id, metadata, created_at, updated_at)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)`,\n row.id,\n row.type,\n row.source,\n row.source_id,\n row.payload,\n row.status,\n row.attempts,\n row.last_error,\n row.processed_at,\n row.processed_by,\n row.target_subscriber,\n row.correlation_id,\n row.tenant_id,\n row.metadata,\n row.created_at,\n row.updated_at,\n );\n }\n\n /**\n * Update an existing dispatch\n */\n static async update(\n db: DatabaseInterface,\n dispatch: Dispatch,\n ): Promise<void> {\n const row = dispatch.toRow();\n await db.query(\n `UPDATE _smrt_dispatch SET\n type = $1, source = $2, source_id = $3, payload = $4, status = $5,\n attempts = $6, last_error = $7, processed_at = $8, processed_by = $9,\n target_subscriber = $10, correlation_id = $11, metadata = $12, updated_at = $13\n WHERE id = $14`,\n row.type,\n row.source,\n row.source_id,\n row.payload,\n row.status,\n row.attempts,\n row.last_error,\n row.processed_at,\n row.processed_by,\n row.target_subscriber,\n row.correlation_id,\n row.metadata,\n row.updated_at,\n row.id,\n );\n }\n\n /**\n * Get a dispatch by ID (unscoped).\n *\n * Internal/system use only — applies no tenant filter. Public reads go\n * through the DispatchBus, which uses {@link getScoped} so a caller cannot\n * fetch another tenant's dispatch by id (S5 #1398).\n */\n static async get(\n db: DatabaseInterface,\n id: string,\n ): Promise<Dispatch | null> {\n const result = await db.single`\n SELECT * FROM _smrt_dispatch WHERE id = ${id}\n `;\n\n if (!result) return null;\n return Dispatch.fromRow(result as DispatchData);\n }\n\n /**\n * Get a dispatch by ID, enforcing the active tenant scope (S5 #1398).\n *\n * Applies the same tenant predicate as {@link list}/{@link findPending}, so a\n * cross-tenant or out-of-scope id lookup returns `null` rather than leaking\n * the row. The scope is derived server-side by the DispatchBus.\n */\n static async getScoped(\n db: DatabaseInterface,\n id: string,\n tenantScope?: DispatchTenantScope,\n ): Promise<Dispatch | null> {\n const conditions: string[] = ['id = $1'];\n const params: unknown[] = [id];\n\n pushTenantPredicate(conditions, params, tenantScope, '$2');\n\n const { rows } = await db.query(\n `SELECT * FROM _smrt_dispatch WHERE ${conditions.join(' AND ')} LIMIT 1`,\n ...params,\n );\n\n if (rows.length === 0) return null;\n return Dispatch.fromRow(rows[0] as unknown as DispatchData);\n }\n\n /**\n * List dispatches with filtering\n */\n static async list(\n db: DatabaseInterface,\n options: DispatchListOptions = {},\n ): Promise<Dispatch[]> {\n const conditions: string[] = [];\n const params: unknown[] = [];\n let paramIndex = 1;\n\n if (options.status) {\n conditions.push(`status = $${paramIndex++}`);\n params.push(options.status);\n }\n\n if (options.source) {\n conditions.push(`source = $${paramIndex++}`);\n params.push(options.source);\n }\n\n if (options.type) {\n conditions.push(`type = $${paramIndex++}`);\n params.push(options.type);\n }\n\n if (options.targetSubscriber) {\n conditions.push(`target_subscriber = $${paramIndex++}`);\n params.push(options.targetSubscriber);\n }\n\n if (options.correlationId !== undefined) {\n conditions.push(`correlation_id = $${paramIndex++}`);\n params.push(options.correlationId);\n }\n\n // Tenant isolation (S5 #1398). Scope is derived server-side by the bus;\n // callers cannot widen it. See DispatchListOptions.tenantScope.\n if (\n pushTenantPredicate(\n conditions,\n params,\n options.tenantScope,\n `$${paramIndex}`,\n )\n ) {\n paramIndex++;\n }\n\n let sql = 'SELECT * FROM _smrt_dispatch';\n if (conditions.length > 0) {\n sql += ` WHERE ${conditions.join(' AND ')}`;\n }\n\n // Whitelist allowed orderBy values to prevent SQL injection\n const allowedOrderBy = [\n 'created_at DESC',\n 'created_at ASC',\n 'updated_at DESC',\n 'updated_at ASC',\n 'type',\n 'type DESC',\n 'type ASC',\n 'status',\n 'status DESC',\n 'status ASC',\n 'source',\n 'source DESC',\n 'source ASC',\n ];\n const safeOrderBy =\n options.orderBy && allowedOrderBy.includes(options.orderBy)\n ? options.orderBy\n : 'created_at DESC';\n sql += ` ORDER BY ${safeOrderBy}`;\n\n // Use parameterized queries for limit/offset\n if (options.limit !== undefined) {\n sql += ` LIMIT $${paramIndex++}`;\n params.push(options.limit);\n }\n\n if (options.offset !== undefined) {\n sql += ` OFFSET $${paramIndex++}`;\n params.push(options.offset);\n }\n\n const { rows } = await db.query(sql, ...params);\n return rows.map((row: Record<string, unknown>) =>\n Dispatch.fromRow(row as unknown as DispatchData),\n );\n }\n\n /**\n * Find pending dispatches for a set of signal types\n *\n * @param subscriber - When provided, filters to dispatches with no target\n * or targeted specifically at this subscriber (for fan-out delivery)\n * @param tenantScope - Active tenant scope (S5 #1398). Derived server-side by\n * the bus; restricts results so a subscriber in one tenant cannot see\n * another tenant's dispatch. See {@link pushTenantPredicate} for the exact\n * semantics (off → no filter; active tenant → that tenant + global; on but\n * no tenant → global only).\n */\n static async findPending(\n db: DatabaseInterface,\n signalTypes: string[],\n limit: number = 100,\n subscriber?: string,\n tenantScope?: DispatchTenantScope,\n ): Promise<Dispatch[]> {\n if (signalTypes.length === 0) {\n return [];\n }\n\n let paramIndex = 1;\n const placeholders = signalTypes.map(() => `$${paramIndex++}`).join(', ');\n const conditions: string[] = [\n \"status = 'pending'\",\n `type IN (${placeholders})`,\n ];\n const params: unknown[] = [...signalTypes];\n\n if (subscriber) {\n conditions.push(\n `(target_subscriber IS NULL OR target_subscriber = $${paramIndex++})`,\n );\n params.push(subscriber);\n }\n\n if (\n pushTenantPredicate(conditions, params, tenantScope, `$${paramIndex}`)\n ) {\n paramIndex++;\n }\n\n const sql = `SELECT * FROM _smrt_dispatch\n WHERE ${conditions.join(' AND ')}\n ORDER BY created_at ASC LIMIT $${paramIndex}`;\n params.push(limit);\n\n const { rows } = await db.query(sql, ...params);\n\n return rows.map((row: Record<string, unknown>) =>\n Dispatch.fromRow(row as unknown as DispatchData),\n );\n }\n\n /**\n * Atomically claim a pending dispatch for processing (S5 #1398).\n *\n * Performs a conditional `UPDATE ... WHERE id = ? AND status = 'pending'`\n * (plus the tenant predicate for the active scope) and reports whether the\n * row was claimed by THIS call. Only the caller whose update affected a row\n * may run the handler, which closes the claim TOCTOU window where a `process`\n * filter-then-update could let two workers (or a tenant-scoped and a\n * system-scoped processor) both pick up the same compete/global dispatch.\n *\n * On a successful claim the dispatch's status/attempts/updated_at are\n * advanced in the DB; the in-memory `dispatch` instance is updated to match\n * so the caller can proceed to `update()` it to completed/failed.\n *\n * @returns `true` if this call claimed the dispatch, `false` if another\n * worker already moved it out of `pending` (or it is not visible in scope).\n */\n static async claim(\n db: DatabaseInterface,\n dispatch: Dispatch,\n tenantScope?: DispatchTenantScope,\n ): Promise<boolean> {\n dispatch.markProcessing();\n const row = dispatch.toRow();\n\n const conditions: string[] = ['id = $4', \"status = 'pending'\"];\n const params: unknown[] = [\n row.status,\n row.attempts,\n row.updated_at,\n row.id,\n ];\n let paramIndex = 5;\n\n if (\n pushTenantPredicate(conditions, params, tenantScope, `$${paramIndex}`)\n ) {\n paramIndex++;\n }\n\n const { rowCount } = await db.query(\n `UPDATE _smrt_dispatch\n SET status = $1, attempts = $2, updated_at = $3\n WHERE ${conditions.join(' AND ')}`,\n ...params,\n );\n\n return (rowCount ?? 0) > 0;\n }\n\n /**\n * Find failed dispatches eligible for retry.\n *\n * @param tenantScope - Active tenant scope (S5 #1398). Derived server-side by\n * the bus; restricts retryable rows to the active tenant (plus global) so a\n * tenant's `retry()` cannot reset another tenant's failed dispatches. See\n * {@link pushTenantPredicate} for the exact semantics.\n */\n static async findRetryable(\n db: DatabaseInterface,\n options: DispatchRetryOptions = {},\n tenantScope?: DispatchTenantScope,\n ): Promise<Dispatch[]> {\n const conditions: string[] = [\"status = 'failed'\"];\n const params: unknown[] = [];\n let paramIndex = 1;\n\n if (options.maxAttempts) {\n conditions.push(`attempts < $${paramIndex++}`);\n params.push(options.maxAttempts);\n }\n\n if (options.olderThan) {\n conditions.push(`created_at < $${paramIndex++}`);\n params.push(options.olderThan.toISOString());\n }\n\n if (options.signalTypes && options.signalTypes.length > 0) {\n const placeholders = options.signalTypes\n .map(() => `$${paramIndex++}`)\n .join(', ');\n conditions.push(`type IN (${placeholders})`);\n params.push(...options.signalTypes);\n }\n\n if (\n pushTenantPredicate(conditions, params, tenantScope, `$${paramIndex}`)\n ) {\n paramIndex++;\n }\n\n const { rows } = await db.query(\n `SELECT * FROM _smrt_dispatch WHERE ${conditions.join(' AND ')} ORDER BY created_at ASC`,\n ...params,\n );\n\n return rows.map((row: Record<string, unknown>) =>\n Dispatch.fromRow(row as unknown as DispatchData),\n );\n }\n\n /**\n * Count dispatches by status\n */\n static async countByStatus(\n db: DatabaseInterface,\n status: DispatchStatus,\n ): Promise<number> {\n const result = await db.single`\n SELECT COUNT(*) as count FROM _smrt_dispatch WHERE status = ${status}\n `;\n return toSafeInteger(\n (result as { count?: unknown } | null)?.count ?? 0,\n 'Dispatch count',\n );\n }\n\n /**\n * Delete a dispatch\n */\n static async delete(db: DatabaseInterface, id: string): Promise<void> {\n await db.query(`DELETE FROM _smrt_dispatch WHERE id = $1`, id);\n }\n\n /**\n * Cleanup old dispatches.\n *\n * @param tenantScope - Active tenant scope (S5 #1398). Derived server-side by\n * the bus; restricts deletions to the active tenant (plus global) so a\n * tenant's `cleanup()` cannot delete another tenant's dispatches. See\n * {@link pushTenantPredicate} for the exact semantics.\n */\n static async cleanup(\n db: DatabaseInterface,\n options: DispatchCleanupOptions = {},\n tenantScope?: DispatchTenantScope,\n ): Promise<DispatchCleanupResult> {\n const result: DispatchCleanupResult = {\n completedDeleted: 0,\n failedDeleted: 0,\n };\n\n if (options.completedOlderThanDays) {\n const cutoff = new Date();\n cutoff.setDate(cutoff.getDate() - options.completedOlderThanDays);\n\n const conditions: string[] = [\n \"status = 'completed'\",\n 'processed_at < $1',\n ];\n const params: unknown[] = [cutoff.toISOString()];\n pushTenantPredicate(conditions, params, tenantScope, '$2');\n\n result.completedDeleted = await deleteOrCount(\n db,\n conditions,\n params,\n options.dryRun ?? false,\n );\n }\n\n if (options.failedOlderThanDays) {\n const cutoff = new Date();\n cutoff.setDate(cutoff.getDate() - options.failedOlderThanDays);\n\n const conditions: string[] = [\"status = 'failed'\", 'updated_at < $1'];\n const params: unknown[] = [cutoff.toISOString()];\n pushTenantPredicate(conditions, params, tenantScope, '$2');\n\n result.failedDeleted = await deleteOrCount(\n db,\n conditions,\n params,\n options.dryRun ?? false,\n );\n }\n\n return result;\n }\n\n /**\n * Check if dispatch table exists.\n *\n * Delegates to the shared, engine-aware {@link tableExists} rather than\n * probing with `SELECT ... LIMIT 1` inside a try/catch: on PostgreSQL, a\n * caught query error still aborts the surrounding transaction (any\n * statement error does, regardless of whether the application handles it),\n * so a probe-and-catch existence check silently poisons a transaction any\n * caller wraps this in — exactly what `DispatchBus.initialize()`'s\n * lock-guarded bootstrap transaction needs to do (#2861). The shared helper\n * uses `information_schema.tables` on PostgreSQL instead, which never\n * raises for a missing table.\n */\n static async tableExists(\n db: DatabaseInterface,\n typeHint?: string,\n ): Promise<boolean> {\n return tableExists(db, '_smrt_dispatch', typeHint);\n }\n}\n"],"mappings":"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"}
|
package/dist/dispatch/types.d.ts
CHANGED
|
@@ -140,7 +140,22 @@ export interface DispatchCleanupResult {
|
|
|
140
140
|
* Options for creating a DispatchBus
|
|
141
141
|
*/
|
|
142
142
|
export interface DispatchBusOptions {
|
|
143
|
-
/**
|
|
143
|
+
/**
|
|
144
|
+
* Database configuration or existing interface.
|
|
145
|
+
*
|
|
146
|
+
* BREAKING (#2861): on PostgreSQL, `initialize()` now serializes its
|
|
147
|
+
* dispatch-table bootstrap against concurrent `DispatchBus` instances
|
|
148
|
+
* using the same advisory-locked transaction `ensureSystemTables()` uses,
|
|
149
|
+
* which requires the handle to implement `beginTransaction()` or
|
|
150
|
+
* `transaction()`. A pre-existing `DatabaseInterface` passed here that
|
|
151
|
+
* implements neither now throws `"Postgres system table bootstrap
|
|
152
|
+
* requires a transaction-capable database adapter"` instead of silently
|
|
153
|
+
* running unguarded, racy DDL. This is a deliberate narrowing: a
|
|
154
|
+
* query-only handle cannot safely take the mutual-exclusion lock the fix
|
|
155
|
+
* for #2861 depends on, so failing closed replaces failing open. SQLite,
|
|
156
|
+
* DuckDB, and JSON adapters are unaffected — only the PostgreSQL path
|
|
157
|
+
* needs the lock.
|
|
158
|
+
*/
|
|
144
159
|
db?: DatabaseInterface | {
|
|
145
160
|
type: 'sqlite' | 'postgres' | 'json' | 'duckdb';
|
|
146
161
|
url: string;
|