@happyvertical/smrt-core 0.40.69 → 0.41.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 +29 -4
- package/README.md +20 -1
- package/agents/change-feed.md +1 -1
- package/agents/query-bounds.md +45 -0
- package/agents/schema-paths.md +786 -0
- package/dist/browser.d.ts +1 -0
- package/dist/browser.d.ts.map +1 -1
- package/dist/browser.js +5 -3
- package/dist/cascade.d.ts +120 -0
- package/dist/cascade.d.ts.map +1 -0
- package/dist/cascade.js +430 -0
- package/dist/cascade.js.map +1 -0
- package/dist/change-feed.d.ts +34 -2
- package/dist/change-feed.d.ts.map +1 -1
- package/dist/change-feed.js +52 -11
- package/dist/change-feed.js.map +1 -1
- package/dist/class.d.ts +36 -3
- package/dist/class.d.ts.map +1 -1
- package/dist/class.js +87 -9
- package/dist/class.js.map +1 -1
- package/dist/collection-cache.js +0 -0
- package/dist/collection-cache.js.map +1 -1
- package/dist/collection.d.ts +130 -2
- package/dist/collection.d.ts.map +1 -1
- package/dist/collection.js +290 -57
- package/dist/collection.js.map +1 -1
- package/dist/config.d.ts +10 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js.map +1 -1
- package/dist/database.d.ts +8 -0
- package/dist/database.d.ts.map +1 -1
- package/dist/database.js +16 -8
- package/dist/database.js.map +1 -1
- package/dist/db-errors.d.ts +105 -0
- package/dist/db-errors.d.ts.map +1 -0
- package/dist/db-errors.js +382 -0
- package/dist/db-errors.js.map +1 -0
- package/dist/decorators/index.d.ts +80 -6
- package/dist/decorators/index.d.ts.map +1 -1
- package/dist/decorators/index.js +102 -12
- package/dist/decorators/index.js.map +1 -1
- package/dist/dispatch/bus.d.ts.map +1 -1
- package/dist/dispatch/bus.js +4 -3
- package/dist/dispatch/bus.js.map +1 -1
- package/dist/dispatch/collections/Dispatches.d.ts.map +1 -1
- package/dist/dispatch/collections/Dispatches.js +19 -4
- package/dist/dispatch/collections/Dispatches.js.map +1 -1
- package/dist/dispatch/types.d.ts +5 -0
- package/dist/dispatch/types.d.ts.map +1 -1
- package/dist/embedded-write-queue.d.ts +46 -0
- package/dist/embedded-write-queue.d.ts.map +1 -0
- package/dist/embedded-write-queue.js +66 -0
- package/dist/embedded-write-queue.js.map +1 -0
- package/dist/embeddings/storage.d.ts +7 -0
- package/dist/embeddings/storage.d.ts.map +1 -1
- package/dist/embeddings/storage.js +29 -12
- package/dist/embeddings/storage.js.map +1 -1
- package/dist/errors.d.ts +31 -3
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +34 -2
- package/dist/errors.js.map +1 -1
- package/dist/generators/changes-route.d.ts.map +1 -1
- package/dist/generators/changes-route.js +6 -3
- package/dist/generators/changes-route.js.map +1 -1
- package/dist/generators/mcp-runtime-template.d.ts +8 -0
- package/dist/generators/mcp-runtime-template.d.ts.map +1 -1
- package/dist/generators/mcp-runtime-template.js +38 -4
- package/dist/generators/mcp-runtime-template.js.map +1 -1
- package/dist/generators/mcp.d.ts +16 -0
- package/dist/generators/mcp.d.ts.map +1 -1
- package/dist/generators/mcp.js +41 -3
- package/dist/generators/mcp.js.map +1 -1
- package/dist/generators/rest.d.ts +22 -0
- package/dist/generators/rest.d.ts.map +1 -1
- package/dist/generators/rest.js +34 -3
- package/dist/generators/rest.js.map +1 -1
- package/dist/hierarchical.js +1 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -5
- package/dist/interceptors.d.ts +21 -0
- package/dist/interceptors.d.ts.map +1 -1
- package/dist/interceptors.js +27 -1
- package/dist/interceptors.js.map +1 -1
- package/dist/manifest/generator.d.ts.map +1 -1
- package/dist/manifest/generator.js +4 -7
- package/dist/manifest/generator.js.map +1 -1
- package/dist/manifest/static-manifest.js +10 -10
- package/dist/manifest/static-manifest.js.map +1 -1
- package/dist/manifest/store.js +1 -1
- package/dist/manifest/store.js.map +1 -1
- package/dist/manifest.json +19 -19
- package/dist/migrations/differ.d.ts +211 -9
- package/dist/migrations/differ.d.ts.map +1 -1
- package/dist/migrations/differ.js +613 -50
- package/dist/migrations/differ.js.map +1 -1
- package/dist/migrations/generator.d.ts +31 -4
- package/dist/migrations/generator.d.ts.map +1 -1
- package/dist/migrations/generator.js +49 -5
- package/dist/migrations/generator.js.map +1 -1
- package/dist/migrations/index.d.ts +4 -2
- package/dist/migrations/index.d.ts.map +1 -1
- package/dist/migrations/index.js +6 -3
- package/dist/migrations/minor-units.d.ts +162 -0
- package/dist/migrations/minor-units.d.ts.map +1 -0
- package/dist/migrations/minor-units.js +381 -0
- package/dist/migrations/minor-units.js.map +1 -0
- package/dist/migrations/orchestrate.js +35 -6
- package/dist/migrations/orchestrate.js.map +1 -1
- package/dist/migrations/sqlite-rebuild.d.ts +142 -0
- package/dist/migrations/sqlite-rebuild.d.ts.map +1 -0
- package/dist/migrations/sqlite-rebuild.js +514 -0
- package/dist/migrations/sqlite-rebuild.js.map +1 -0
- package/dist/migrations/tracker.d.ts +114 -1
- package/dist/migrations/tracker.d.ts.map +1 -1
- package/dist/migrations/tracker.js +331 -16
- package/dist/migrations/tracker.js.map +1 -1
- package/dist/migrations/types.d.ts +19 -4
- package/dist/migrations/types.d.ts.map +1 -1
- package/dist/migrations.js +6 -3
- package/dist/object.d.ts +142 -10
- package/dist/object.d.ts.map +1 -1
- package/dist/object.js +196 -41
- package/dist/object.js.map +1 -1
- package/dist/postgres-timeouts.d.ts +240 -0
- package/dist/postgres-timeouts.d.ts.map +1 -0
- package/dist/postgres-timeouts.js +204 -0
- package/dist/postgres-timeouts.js.map +1 -0
- package/dist/query-bounds.d.ts +101 -0
- package/dist/query-bounds.d.ts.map +1 -0
- package/dist/query-bounds.js +177 -0
- package/dist/query-bounds.js.map +1 -0
- package/dist/registry/class-registration.d.ts.map +1 -1
- package/dist/registry/class-registration.js +3 -1
- package/dist/registry/class-registration.js.map +1 -1
- package/dist/registry/manifest-field-merge.d.ts +12 -0
- package/dist/registry/manifest-field-merge.d.ts.map +1 -1
- package/dist/registry/manifest-field-merge.js +14 -2
- package/dist/registry/manifest-field-merge.js.map +1 -1
- package/dist/registry/schema-builder.d.ts +22 -1
- package/dist/registry/schema-builder.d.ts.map +1 -1
- package/dist/registry/schema-builder.js +205 -165
- package/dist/registry/schema-builder.js.map +1 -1
- package/dist/registry/types.d.ts +35 -3
- package/dist/registry/types.d.ts.map +1 -1
- package/dist/registry.d.ts +41 -46
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +61 -83
- package/dist/registry.js.map +1 -1
- package/dist/scanner/manifest-generator.d.ts +45 -0
- package/dist/scanner/manifest-generator.d.ts.map +1 -1
- package/dist/scanner/manifest-generator.js +92 -28
- package/dist/scanner/manifest-generator.js.map +1 -1
- package/dist/scanner/types.d.ts +5 -0
- package/dist/scanner/types.d.ts.map +1 -1
- package/dist/scanner/types.js.map +1 -1
- package/dist/schema/conflict-target.d.ts +104 -0
- package/dist/schema/conflict-target.d.ts.map +1 -0
- package/dist/schema/conflict-target.js +129 -0
- package/dist/schema/conflict-target.js.map +1 -0
- package/dist/schema/ddl/base-strategy.d.ts.map +1 -1
- package/dist/schema/ddl/base-strategy.js +2 -2
- package/dist/schema/ddl/base-strategy.js.map +1 -1
- package/dist/schema/ddl/duckdb-strategy.d.ts.map +1 -1
- package/dist/schema/ddl/duckdb-strategy.js +2 -1
- package/dist/schema/ddl/duckdb-strategy.js.map +1 -1
- package/dist/schema/ddl/postgres-strategy.d.ts.map +1 -1
- package/dist/schema/ddl/postgres-strategy.js +12 -1
- package/dist/schema/ddl/postgres-strategy.js.map +1 -1
- package/dist/schema/generator.d.ts +307 -6
- package/dist/schema/generator.d.ts.map +1 -1
- package/dist/schema/generator.js +510 -87
- package/dist/schema/generator.js.map +1 -1
- package/dist/schema/index-utils.d.ts +120 -0
- package/dist/schema/index-utils.d.ts.map +1 -1
- package/dist/schema/index-utils.js +242 -1
- package/dist/schema/index-utils.js.map +1 -1
- package/dist/schema/index.d.ts +3 -0
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema/index.js +4 -1
- package/dist/schema/live-parity.d.ts +90 -0
- package/dist/schema/live-parity.d.ts.map +1 -0
- package/dist/schema/live-parity.js +602 -0
- package/dist/schema/live-parity.js.map +1 -0
- package/dist/schema/manifest-schema.d.ts +121 -0
- package/dist/schema/manifest-schema.d.ts.map +1 -0
- package/dist/schema/manifest-schema.js +267 -0
- package/dist/schema/manifest-schema.js.map +1 -0
- package/dist/schema/schema-aggregator.d.ts +24 -10
- package/dist/schema/schema-aggregator.d.ts.map +1 -1
- package/dist/schema/schema-aggregator.js +35 -90
- package/dist/schema/schema-aggregator.js.map +1 -1
- package/dist/schema/system-table-shapes.d.ts +65 -0
- package/dist/schema/system-table-shapes.d.ts.map +1 -0
- package/dist/schema/system-table-shapes.js +187 -0
- package/dist/schema/system-table-shapes.js.map +1 -0
- package/dist/schema/types.d.ts +103 -4
- package/dist/schema/types.d.ts.map +1 -1
- package/dist/schema/utils.d.ts +2 -1
- package/dist/schema/utils.d.ts.map +1 -1
- package/dist/schema/utils.js +5 -3
- package/dist/schema/utils.js.map +1 -1
- package/dist/schema.js +4 -1
- package/dist/smrt-knowledge.json +20 -8
- package/dist/sync/apply.d.ts.map +1 -1
- package/dist/sync/apply.js +9 -16
- package/dist/sync/apply.js.map +1 -1
- package/dist/system/compatibility.d.ts +42 -0
- package/dist/system/compatibility.d.ts.map +1 -1
- package/dist/system/compatibility.js +182 -9
- package/dist/system/compatibility.js.map +1 -1
- package/dist/system/index.d.ts +1 -0
- package/dist/system/index.d.ts.map +1 -1
- package/dist/system/index.js +3 -2
- package/dist/system/retention.d.ts +237 -0
- package/dist/system/retention.d.ts.map +1 -0
- package/dist/system/retention.js +497 -0
- package/dist/system/retention.js.map +1 -0
- package/dist/system/schema.d.ts +100 -15
- package/dist/system/schema.d.ts.map +1 -1
- package/dist/system/schema.js +81 -45
- package/dist/system/schema.js.map +1 -1
- package/dist/system/types.d.ts +0 -2
- package/dist/system/types.d.ts.map +1 -1
- package/dist/testing/database.d.ts.map +1 -1
- package/dist/testing/database.js +1 -0
- package/dist/testing/database.js.map +1 -1
- package/dist/vite-plugin/index.d.ts.map +1 -1
- package/dist/vite-plugin/index.js +4 -9
- package/dist/vite-plugin/index.js.map +1 -1
- package/dist/vite-plugin/sveltekit-generator.d.ts.map +1 -1
- package/dist/vite-plugin/sveltekit-generator.js +71 -5
- package/dist/vite-plugin/sveltekit-generator.js.map +1 -1
- package/dist/vite-plugin/web-collections.d.ts.map +1 -1
- package/dist/vite-plugin/web-collections.js +6 -4
- package/dist/vite-plugin/web-collections.js.map +1 -1
- package/package.json +5 -5
package/dist/decorators/index.js
CHANGED
|
@@ -8,6 +8,51 @@ import { ObjectRegistry } from "../registry.js";
|
|
|
8
8
|
* Properties are typed as primitives with decorator metadata.
|
|
9
9
|
*/
|
|
10
10
|
/**
|
|
11
|
+
* Resolve a relationship decorator's target argument to a class name.
|
|
12
|
+
*
|
|
13
|
+
* Accepted target forms:
|
|
14
|
+
* - `'Target'` — class name string; resolves lazily, immune to import cycles.
|
|
15
|
+
* - `Target` — class constructor.
|
|
16
|
+
* - `() => Target` — thunk (inline or a named `const`), invoked here to read
|
|
17
|
+
* the name, so its target must already be initialized.
|
|
18
|
+
*
|
|
19
|
+
* A thunk's own `.name` is `''`, so reading `relatedClass.name` used to register
|
|
20
|
+
* `related: ''` (issue #2379): the field kept `type: 'foreignKey'` but lost its
|
|
21
|
+
* target, which silently dropped the relationship edge, `loadRelated()`, and any
|
|
22
|
+
* FK-derived index. Thunks are therefore invoked at decoration time and an
|
|
23
|
+
* unresolvable target throws with the string form as the remedy — an empty
|
|
24
|
+
* `related` is never registered.
|
|
25
|
+
*
|
|
26
|
+
* Resolution runs inside the field-registration callback, which is the latest
|
|
27
|
+
* point in the decorator lifecycle (after the class binding exists for legacy
|
|
28
|
+
* decorators, and at `@smrt()` application time for standard decorators), so a
|
|
29
|
+
* self-referential `() => Self` thunk resolves rather than hitting the TDZ.
|
|
30
|
+
* A thunk pointing at a class declared LATER in the same module is still in
|
|
31
|
+
* that class's temporal dead zone when the decorators of the earlier class run;
|
|
32
|
+
* that now fails loudly, naming the string form, instead of silently
|
|
33
|
+
* registering an empty target.
|
|
34
|
+
*/
|
|
35
|
+
function resolveRelatedClassName(decoratorName, relatedClass, className, propertyKey) {
|
|
36
|
+
const where = `@${decoratorName}() on ${className}.${propertyKey}`;
|
|
37
|
+
const remedy = `Pass the target class name as a string instead — \`@${decoratorName}('Target')\` resolves lazily and is immune to import cycles.`;
|
|
38
|
+
if (typeof relatedClass === "string") {
|
|
39
|
+
const name = relatedClass.trim();
|
|
40
|
+
if (!name) throw new Error(`${where}: target class name is empty. Pass a class, a class name, or a \`() => Target\` thunk.`);
|
|
41
|
+
return name;
|
|
42
|
+
}
|
|
43
|
+
if (typeof relatedClass !== "function") throw new Error(`${where}: expected a class, a class name, or a \`() => Target\` thunk, received ${relatedClass === null ? "null" : typeof relatedClass}. ${remedy}`);
|
|
44
|
+
if (relatedClass.name && relatedClass.prototype !== void 0) return relatedClass.name;
|
|
45
|
+
let resolved;
|
|
46
|
+
try {
|
|
47
|
+
resolved = relatedClass();
|
|
48
|
+
} catch (error) {
|
|
49
|
+
throw new Error(`${where}: the \`() => Target\` thunk threw while resolving its target (${error instanceof Error ? error.message : String(error)}). ${remedy}`, { cause: error });
|
|
50
|
+
}
|
|
51
|
+
if (typeof resolved === "function" && resolved.name) return resolved.name;
|
|
52
|
+
if (typeof resolved === "string" && resolved.trim()) return resolved.trim();
|
|
53
|
+
throw new Error(`${where}: the \`() => Target\` thunk resolved to ${resolved === null ? "null" : typeof resolved} instead of a named class. ${remedy}`);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
11
56
|
* Marks a class property with validation constraints and metadata options.
|
|
12
57
|
*
|
|
13
58
|
* Use `@field()` when you need options beyond what plain TypeScript initializers
|
|
@@ -64,9 +109,34 @@ function field(options = {}) {
|
|
|
64
109
|
* For cross-package foreign keys, use a plain `string` property instead to avoid
|
|
65
110
|
* circular dependencies between packages.
|
|
66
111
|
*
|
|
67
|
-
*
|
|
112
|
+
* **No DDL constraint is emitted.** SMRT generates no `FOREIGN KEY` clause on
|
|
113
|
+
* any engine — the relationship is enforced by the framework, not the database
|
|
114
|
+
* (#2371). What that buys you is `loadRelated()`, eager `include:` loading, and
|
|
115
|
+
* the `onDelete` behaviour applied by `SmrtObject.delete()`:
|
|
116
|
+
*
|
|
117
|
+
* ```typescript
|
|
118
|
+
* @foreignKey(Order, { onDelete: 'CASCADE' }) // deleted with the order
|
|
119
|
+
* orderId: string = '';
|
|
120
|
+
*
|
|
121
|
+
* @foreignKey(Customer, { onDelete: 'RESTRICT' }) // blocks the customer delete
|
|
122
|
+
* customerId: string = '';
|
|
123
|
+
* ```
|
|
124
|
+
*
|
|
125
|
+
* Without an `onDelete`, a column that is part of this class's
|
|
126
|
+
* `conflictColumns` defaults to `CASCADE` (this is what cleans up junction
|
|
127
|
+
* rows); any other column is left untouched when the target is deleted.
|
|
128
|
+
*
|
|
129
|
+
* @param relatedClass - The target class constructor, its name as a string, or a
|
|
130
|
+
* `() => Target` thunk. A thunk is **invoked at decoration time**, so its
|
|
131
|
+
* target must already be initialized: a class from an already-evaluated module
|
|
132
|
+
* or the decorated class itself. Use the string form for a class declared
|
|
133
|
+
* later in the same module or reached through an import cycle — it is never
|
|
134
|
+
* evaluated, so it cannot hit the temporal dead zone.
|
|
68
135
|
* @param options - Optional field constraints (required, nullable, etc.)
|
|
69
136
|
* @returns A TypeScript property decorator
|
|
137
|
+
* @throws Error when the target cannot be resolved to a class name (an empty
|
|
138
|
+
* string, a thunk that throws — including on an uninitialized target — or a
|
|
139
|
+
* thunk returning an anonymous value)
|
|
70
140
|
*
|
|
71
141
|
* @example
|
|
72
142
|
* ```typescript
|
|
@@ -75,6 +145,14 @@ function field(options = {}) {
|
|
|
75
145
|
* // Same-package FK — enables loadRelated() and eager loading
|
|
76
146
|
* @foreignKey(Customer)
|
|
77
147
|
* customerId: string = '';
|
|
148
|
+
*
|
|
149
|
+
* // Self-reference: the class binding exists when its decorators run
|
|
150
|
+
* @foreignKey(() => Order)
|
|
151
|
+
* parentOrderId: string = '';
|
|
152
|
+
*
|
|
153
|
+
* // Declared later in this module — name string, never evaluated
|
|
154
|
+
* @foreignKey('Invoice')
|
|
155
|
+
* invoiceId: string = '';
|
|
78
156
|
* }
|
|
79
157
|
*
|
|
80
158
|
* // Cross-package: use a plain string instead
|
|
@@ -89,12 +167,11 @@ function field(options = {}) {
|
|
|
89
167
|
*/
|
|
90
168
|
function foreignKey(relatedClass, options = {}) {
|
|
91
169
|
return ((targetOrValue, propertyKeyOrContext) => {
|
|
92
|
-
const relatedClassName = typeof relatedClass === "string" ? relatedClass : relatedClass.name;
|
|
93
170
|
registerCompatibleFieldDecorator(targetOrValue, propertyKeyOrContext, (className, propertyKey) => {
|
|
94
171
|
ObjectRegistry.registerFieldDecorator(className, propertyKey, {
|
|
95
172
|
...options,
|
|
96
173
|
type: "foreignKey",
|
|
97
|
-
related:
|
|
174
|
+
related: resolveRelatedClassName("foreignKey", relatedClass, className, propertyKey)
|
|
98
175
|
});
|
|
99
176
|
});
|
|
100
177
|
});
|
|
@@ -104,10 +181,12 @@ function foreignKey(relatedClass, options = {}) {
|
|
|
104
181
|
*
|
|
105
182
|
* Use this for relationships that point to a `SmrtObject` in a *different* package
|
|
106
183
|
* (e.g. `Customer.profileId` pointing at `@happyvertical/smrt-profiles:Profile`).
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
184
|
+
*
|
|
185
|
+
* Like `@foreignKey()`, this emits **no** DDL `FOREIGN KEY` constraint — SMRT
|
|
186
|
+
* does not emit them on any engine (#2371). The decorated property is a
|
|
187
|
+
* `UUID` column on PostgreSQL/DuckDB and `TEXT` on SQLite, matching the target's
|
|
188
|
+
* id type; pass `{ idType: 'text' }` when the target declares
|
|
189
|
+
* `@smrt({ idType: 'text' })`.
|
|
111
190
|
*
|
|
112
191
|
* What you get over a plain string field:
|
|
113
192
|
* - The relationship is registered with the `ObjectRegistry`, so `loadRelated()`
|
|
@@ -115,6 +194,8 @@ function foreignKey(relatedClass, options = {}) {
|
|
|
115
194
|
* manifest is loaded.
|
|
116
195
|
* - Optional save-time validation (`validate: true`) confirms the referenced
|
|
117
196
|
* object exists, catching typos and stale IDs before they hit the database.
|
|
197
|
+
* - `onDelete` is honoured by `SmrtObject.delete()` when the target package's
|
|
198
|
+
* manifest is loaded in the same runtime.
|
|
118
199
|
*
|
|
119
200
|
* The `qualifiedName` is a fully-qualified class identifier in the form
|
|
120
201
|
* `@package/scope:ClassName` — for example `@happyvertical/smrt-profiles:Profile`.
|
|
@@ -136,7 +217,7 @@ function foreignKey(relatedClass, options = {}) {
|
|
|
136
217
|
* }
|
|
137
218
|
* ```
|
|
138
219
|
*
|
|
139
|
-
* @see {@link foreignKey} for same-package relationships
|
|
220
|
+
* @see {@link foreignKey} for same-package relationships
|
|
140
221
|
* @see SmrtObject.loadRelated for runtime resolution
|
|
141
222
|
*/
|
|
142
223
|
function crossPackageRef(qualifiedName, options = {}) {
|
|
@@ -171,6 +252,17 @@ function crossPackageRef(qualifiedName, options = {}) {
|
|
|
171
252
|
* `loadRelatedMany` and the generated accessor resolve the intended inverse
|
|
172
253
|
* side. Without it the first matching foreign key is used.
|
|
173
254
|
*
|
|
255
|
+
* **Delete behaviour is declared on the child, not here.** `@oneToMany` is a
|
|
256
|
+
* transient read-side accessor; to have children removed with their parent,
|
|
257
|
+
* put `onDelete: 'CASCADE'` on the inverse `@foreignKey` (#2371):
|
|
258
|
+
*
|
|
259
|
+
* ```typescript
|
|
260
|
+
* class OrderItem extends SmrtObject {
|
|
261
|
+
* @foreignKey(Order, { onDelete: 'CASCADE' })
|
|
262
|
+
* orderId: string = '';
|
|
263
|
+
* }
|
|
264
|
+
* ```
|
|
265
|
+
*
|
|
174
266
|
* @param relatedClass - The class constructor of the child/related objects
|
|
175
267
|
* @param options - Optional relationship options. `foreignKey` selects the
|
|
176
268
|
* inverse foreign-key field on `relatedClass` when it has more than one.
|
|
@@ -211,12 +303,11 @@ function crossPackageRef(qualifiedName, options = {}) {
|
|
|
211
303
|
*/
|
|
212
304
|
function oneToMany(relatedClass, options = {}) {
|
|
213
305
|
return ((targetOrValue, propertyKeyOrContext) => {
|
|
214
|
-
const relatedClassName = typeof relatedClass === "string" ? relatedClass : relatedClass.name;
|
|
215
306
|
registerCompatibleFieldDecorator(targetOrValue, propertyKeyOrContext, (className, propertyKey) => {
|
|
216
307
|
ObjectRegistry.registerFieldDecorator(className, propertyKey, {
|
|
217
308
|
...options,
|
|
218
309
|
type: "oneToMany",
|
|
219
|
-
related:
|
|
310
|
+
related: resolveRelatedClassName("oneToMany", relatedClass, className, propertyKey),
|
|
220
311
|
transient: true
|
|
221
312
|
});
|
|
222
313
|
});
|
|
@@ -250,12 +341,11 @@ function oneToMany(relatedClass, options = {}) {
|
|
|
250
341
|
*/
|
|
251
342
|
function manyToMany(relatedClass, options = {}) {
|
|
252
343
|
return ((targetOrValue, propertyKeyOrContext) => {
|
|
253
|
-
const relatedClassName = typeof relatedClass === "string" ? relatedClass : relatedClass.name;
|
|
254
344
|
registerCompatibleFieldDecorator(targetOrValue, propertyKeyOrContext, (className, propertyKey) => {
|
|
255
345
|
ObjectRegistry.registerFieldDecorator(className, propertyKey, {
|
|
256
346
|
...options,
|
|
257
347
|
type: "manyToMany",
|
|
258
|
-
related:
|
|
348
|
+
related: resolveRelatedClassName("manyToMany", relatedClass, className, propertyKey),
|
|
259
349
|
transient: true
|
|
260
350
|
});
|
|
261
351
|
});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../../src/decorators/index.ts"],"sourcesContent":["/**\n * Field decorators for SMRT objects\n *\n * Modern decorator-based API for defining SMRT object properties.\n * Properties are typed as primitives with decorator metadata.\n */\n\nimport { ObjectRegistry } from '../registry.js';\nimport type { FieldUIHints } from '../scanner/types.js';\nimport type { SQLDataType } from '../schema/types.js';\nimport {\n type CompatiblePropertyDecorator,\n type CompatiblePropertyDecoratorContext,\n type LegacyPropertyDecoratorTarget,\n registerCompatibleFieldDecorator,\n} from './compatibility.js';\n\nexport type { FieldUIHints } from '../scanner/types.js';\n\n/**\n * Meta type wrapper for STI (Single Table Inheritance) meta fields\n *\n * Fields typed as Meta<T> are stored in the _meta_data JSONB column\n * rather than as direct table columns. Used for child-specific fields\n * in STI hierarchies.\n *\n * @example\n * ```typescript\n * @smrt({ tableStrategy: 'sti' })\n * class Event extends SmrtObject {\n * title: string = '';\n * }\n *\n * @smrt()\n * class Meeting extends Event {\n * // Stored in _meta_data JSONB column\n * roomNumber: Meta<string> = '';\n * attendees: Meta<string[]> = [];\n * }\n * ```\n */\nexport type Meta<T> = T;\n\n/**\n * Base field options\n */\nexport type PrimitiveFieldType =\n | 'text'\n | 'integer'\n | 'decimal'\n | 'boolean'\n | 'datetime'\n | 'json';\n\nexport type FieldType =\n | PrimitiveFieldType\n | 'meta'\n | 'foreignKey'\n | 'crossPackageRef'\n | 'oneToMany'\n | 'manyToMany';\n\nexport interface FieldOptions {\n /** Explicit field type for runtime-only registration paths */\n type?: FieldType;\n /** Explicit SQL storage type when runtime and persistence contracts differ */\n sqlType?: SQLDataType;\n /** Whether the field is required */\n required?: boolean;\n /** Default value for the field */\n default?: unknown;\n /** Whether the field is unique */\n unique?: boolean;\n /**\n * When `true`, the schema emits a database index targeting this field.\n *\n * For regular (column-backed) fields the index is a plain column index.\n * For `@meta()` fields stored inside `_meta_data` JSONB, the index targets\n * the JSON path — `json_extract(_meta_data, '$.fieldName')` on SQLite,\n * `(_meta_data->>'fieldName')` on Postgres.\n *\n * Note that `collection.list({ where })` cannot currently reach a JSON-path\n * index: dot-notation keys such as `_meta_data.fieldName` are rejected,\n * because nothing rewrites them into the matching extraction expression\n * (#2276, tracked in #2282). The index is still emitted and still serves\n * hand-written SQL that spells the expression out; it just has no\n * collection-level query to accelerate yet.\n */\n indexed?: boolean;\n /** Whether the field is nullable */\n nullable?: boolean;\n /** Whether the field should be excluded from database */\n transient?: boolean;\n /**\n * Marks the field as sensitive (e.g. API secrets, credentials, tax IDs).\n *\n * Sensitive fields are still persisted to the database, but the framework:\n * - excludes them from `toPublicJSON()` (the serializer used by generated\n * REST/MCP/SvelteKit routes), so they never appear in API responses; and\n * - rejects them as `where`-clause filter keys, closing the\n * `?secret[like]=...` value-probing oracle.\n *\n * Use this for any column that holds a secret value that must never be\n * read back over a generated network surface.\n */\n sensitive?: boolean;\n /**\n * Marks the field as read-only over generated write surfaces.\n *\n * Read-only fields are stripped from the request body before\n * `create`/`update` in generated REST/MCP/SvelteKit routes, so callers\n * cannot mass-assign them. Server-side code can still set them directly.\n */\n readonly?: boolean;\n /**\n * Permission slug required to include this field in public/read responses.\n *\n * Fields with a read permission are fail-closed: generated serializers omit\n * them unless the caller's resolved permission set contains this slug.\n * `sensitive: true` still wins and omits the field for every caller.\n */\n readPermission?: string;\n /** Field description */\n description?: string;\n /**\n * Static UI hints for the field-policy rail (#2046, epic #2045).\n *\n * A pure presentation seed — carried in the manifest under the field's\n * `_meta.ui`, readable at runtime via `getAllFields()` at `field._meta.ui`,\n * and emitted to the browser in generated web-collection definitions. Has no\n * schema, persistence, or security effect; `sensitive`/`readPermission`\n * remain the security rail.\n */\n ui?: FieldUIHints;\n /**\n * Controls whether the field is included in JSON exports.\n * - `true`: Always exported (unless site explicitly excludes it)\n * - `false`: Never exported (cannot be overridden by site config)\n * - `undefined`: Uses site's fieldExportDefault setting\n */\n exported?: boolean;\n}\n\n/**\n * Options for text fields\n */\nexport interface TextFieldOptions extends FieldOptions {\n /** Minimum length for text fields */\n minLength?: number;\n /** Maximum length for text fields */\n maxLength?: number;\n /** Regex pattern for validation */\n pattern?: RegExp | string;\n}\n\n/**\n * Options for numeric fields\n */\nexport interface NumericFieldOptions extends FieldOptions {\n /** Minimum value */\n min?: number;\n /** Maximum value */\n max?: number;\n}\n\n/**\n * Options for relationship fields\n */\nexport interface RelationshipFieldOptions extends FieldOptions {\n /** Related class name */\n related?: string;\n /** Foreign key field name */\n foreignKey?: string;\n /** Through table for many-to-many */\n through?: string;\n /** Relationship type */\n type?: 'foreignKey' | 'crossPackageRef' | 'oneToMany' | 'manyToMany';\n}\n\n/**\n * Options specific to cross-package references.\n */\nexport interface CrossPackageRefOptions\n extends Omit<RelationshipFieldOptions, 'related' | 'type'> {\n /**\n * Storage type for the referenced target id. Defaults to 'uuid'.\n *\n * Use 'text' only when the external target model declares\n * `@smrt({ idType: 'text' })`.\n */\n idType?: 'uuid' | 'text';\n\n /**\n * When `true`, the framework verifies the referenced object exists at save time.\n * Validation uses the target package's manifest (loaded on demand via\n * `ObjectRegistry.ensureManifestLoaded()`), so this requires the target manifest\n * to be discoverable at runtime.\n *\n * Empty/null values are always allowed (treated as \"no reference set\").\n *\n * Defaults to `false` — same behavior as a plain string field today.\n */\n validate?: boolean;\n}\n\n/**\n * Marks a class property with validation constraints and metadata options.\n *\n * Use `@field()` when you need options beyond what plain TypeScript initializers\n * express — required validation, numeric ranges, string length limits, uniqueness,\n * or transient (non-persisted) computed properties.\n *\n * For plain persisted fields with no constraints, no decorator is needed: just\n * declare the property with a TypeScript initializer and the framework will infer\n * the column type from the default value (`0` → INTEGER, `0.0` → DECIMAL, `''` → TEXT).\n *\n * @param options - Field configuration options\n * @param options.required - If `true`, `save()` throws `ValidationError` when empty/null\n * @param options.unique - Enforces a UNIQUE database constraint\n * @param options.nullable - If `true`, the column accepts NULL (default depends on type)\n * @param options.transient - If `true`, the property is not persisted to the database\n * @param options.default - Default value applied at the database level\n * @param options.description - Human-readable description used in generated API docs\n * @param options.exported - Controls JSON export visibility (see `FieldOptions`)\n * @returns A TypeScript property decorator\n *\n * @example\n * ```typescript\n * @smrt()\n * class Product extends SmrtObject {\n * @field({ required: true, maxLength: 100 })\n * name: string = '';\n *\n * @field({ min: 0 })\n * stock: number = 0;\n *\n * @field({ transient: true })\n * get displayPrice(): string { return `$${this.price.toFixed(2)}`; }\n * }\n * ```\n *\n * @see {@link meta} for STI child-specific fields stored in `_meta_data` JSON\n * @see {@link foreignKey} for typed relationship fields\n */\nexport function field(\n options: FieldOptions | NumericFieldOptions | TextFieldOptions = {},\n) {\n return ((\n targetOrValue: LegacyPropertyDecoratorTarget | undefined,\n propertyKeyOrContext: CompatiblePropertyDecoratorContext<unknown, unknown>,\n ) => {\n registerCompatibleFieldDecorator(\n targetOrValue,\n propertyKeyOrContext,\n (className, propertyKey) => {\n ObjectRegistry.registerFieldDecorator(className, propertyKey, options);\n },\n );\n }) as CompatiblePropertyDecorator;\n}\n\n/**\n * Declares a many-to-one (foreign key) relationship to another `SmrtObject` class.\n *\n * The decorated property stores the UUID of the related object. At runtime, call\n * `instance.loadRelated('fieldName')` to lazy-load (and cache) the related object,\n * or pass `include: ['fieldName']` to `collection.list()` for batch eager loading.\n *\n * Cross-package rule: Use `@foreignKey()` only for same-package references.\n * For cross-package foreign keys, use a plain `string` property instead to avoid\n * circular dependencies between packages.\n *\n * @param relatedClass - The target class constructor (or class name string)\n * @param options - Optional field constraints (required, nullable, etc.)\n * @returns A TypeScript property decorator\n *\n * @example\n * ```typescript\n * @smrt()\n * class Order extends SmrtObject {\n * // Same-package FK — enables loadRelated() and eager loading\n * @foreignKey(Customer)\n * customerId: string = '';\n * }\n *\n * // Cross-package: use a plain string instead\n * @smrt()\n * class Post extends SmrtObject {\n * authorId: string = ''; // plain string — no circular dep\n * }\n * ```\n *\n * @see {@link oneToMany} for the inverse (parent) side of the relationship\n * @see SmrtObject.loadRelated for lazy-loading the related object at runtime\n */\nexport function foreignKey(\n relatedClass: string | Function,\n options: Omit<RelationshipFieldOptions, 'related'> = {},\n) {\n return ((\n targetOrValue: LegacyPropertyDecoratorTarget | undefined,\n propertyKeyOrContext: CompatiblePropertyDecoratorContext<unknown, unknown>,\n ) => {\n const relatedClassName =\n typeof relatedClass === 'string' ? relatedClass : relatedClass.name;\n\n registerCompatibleFieldDecorator(\n targetOrValue,\n propertyKeyOrContext,\n (className, propertyKey) => {\n ObjectRegistry.registerFieldDecorator(className, propertyKey, {\n ...options,\n type: 'foreignKey',\n related: relatedClassName,\n });\n },\n );\n }) as CompatiblePropertyDecorator;\n}\n\n/**\n * Declares a cross-package foreign key reference.\n *\n * Use this for relationships that point to a `SmrtObject` in a *different* package\n * (e.g. `Customer.profileId` pointing at `@happyvertical/smrt-profiles:Profile`).\n * Unlike `@foreignKey()`, this decorator does **not** emit a DDL `FOREIGN KEY`\n * constraint — cross-package classes are not visible at schema-generation time and\n * adding a constraint would force a circular package dependency. The decorated\n * property remains a plain `TEXT` column at the database level.\n *\n * What you get over a plain string field:\n * - The relationship is registered with the `ObjectRegistry`, so `loadRelated()`\n * and `Collection.list({ include })` can resolve it once the target package's\n * manifest is loaded.\n * - Optional save-time validation (`validate: true`) confirms the referenced\n * object exists, catching typos and stale IDs before they hit the database.\n *\n * The `qualifiedName` is a fully-qualified class identifier in the form\n * `@package/scope:ClassName` — for example `@happyvertical/smrt-profiles:Profile`.\n *\n * @param qualifiedName - Qualified name of the target class\n * @param options - Optional field constraints and `validate` flag\n * @returns A TypeScript property decorator\n *\n * @example\n * ```typescript\n * @smrt()\n * class Customer extends SmrtObject {\n * @crossPackageRef('@happyvertical/smrt-profiles:Profile')\n * profileId: string = '';\n *\n * // With save-time validation\n * @crossPackageRef('@happyvertical/smrt-profiles:Profile', { validate: true })\n * primaryContactId: string = '';\n * }\n * ```\n *\n * @see {@link foreignKey} for same-package relationships (emits FK constraint)\n * @see SmrtObject.loadRelated for runtime resolution\n */\nexport function crossPackageRef(\n qualifiedName: string,\n options: CrossPackageRefOptions = {},\n) {\n return ((\n targetOrValue: LegacyPropertyDecoratorTarget | undefined,\n propertyKeyOrContext: CompatiblePropertyDecoratorContext<unknown, unknown>,\n ) => {\n registerCompatibleFieldDecorator(\n targetOrValue,\n propertyKeyOrContext,\n (className, propertyKey) => {\n ObjectRegistry.registerFieldDecorator(className, propertyKey, {\n ...options,\n type: 'crossPackageRef',\n related: qualifiedName,\n });\n },\n );\n }) as CompatiblePropertyDecorator;\n}\n\n/**\n * Declares a one-to-many relationship from this object to a collection of related objects.\n *\n * The decorated property is `transient` — it is not persisted as a database column.\n * At runtime, call `instance.loadRelatedMany('fieldName')` to load the related objects,\n * or pass `include: ['fieldName']` to `collection.list()` for batch eager loading (issues\n * a single batched query for all instances instead of N individual queries).\n *\n * The inverse side (`@foreignKey`) must exist on the `relatedClass` pointing back to this\n * class. The framework discovers it automatically via `ObjectRegistry.getInverseRelationships()`.\n *\n * **Generated accessor (R10):** registering the class installs a consistent\n * `get<FieldName>()` instance method (e.g. `items` → `order.getItems()`) that\n * delegates to `loadRelatedMany('items')`. Generation is additive — a\n * hand-rolled method of the same name is never overwritten.\n *\n * **Disambiguation:** when `relatedClass` declares more than one `@foreignKey`\n * back to this class, pass `{ foreignKey: '<inverseFieldName>' }` so both\n * `loadRelatedMany` and the generated accessor resolve the intended inverse\n * side. Without it the first matching foreign key is used.\n *\n * @param relatedClass - The class constructor of the child/related objects\n * @param options - Optional relationship options. `foreignKey` selects the\n * inverse foreign-key field on `relatedClass` when it has more than one.\n * @returns A TypeScript property decorator (sets `transient: true` automatically)\n *\n * @example\n * ```typescript\n * @smrt()\n * class Order extends SmrtObject {\n * @oneToMany(OrderItem)\n * items: OrderItem[] = [];\n * }\n *\n * @smrt()\n * class OrderItem extends SmrtObject {\n * @foreignKey(Order)\n * orderId: string = '';\n * }\n *\n * const order = await orders.get({ id });\n * const items = await order.getItems(); // generated; === loadRelatedMany('items')\n * ```\n *\n * @example\n * ```typescript\n * // Multiple inverse foreign keys → disambiguate explicitly.\n * @smrt()\n * class Profile extends SmrtObject {\n * @oneToMany(ProfileRelationship, { foreignKey: 'fromProfileId' })\n * relationshipsFrom: ProfileRelationship[] = [];\n * @oneToMany(ProfileRelationship, { foreignKey: 'toProfileId' })\n * relationshipsTo: ProfileRelationship[] = [];\n * }\n * ```\n *\n * @see {@link foreignKey} for the many-to-one (child) side of the relationship\n * @see SmrtObject.loadRelatedMany for lazy-loading at runtime\n */\nexport function oneToMany(\n relatedClass: string | Function,\n options: Omit<RelationshipFieldOptions, 'related'> = {},\n) {\n return ((\n targetOrValue: LegacyPropertyDecoratorTarget | undefined,\n propertyKeyOrContext: CompatiblePropertyDecoratorContext<unknown, unknown>,\n ) => {\n const relatedClassName =\n typeof relatedClass === 'string' ? relatedClass : relatedClass.name;\n\n registerCompatibleFieldDecorator(\n targetOrValue,\n propertyKeyOrContext,\n (className, propertyKey) => {\n ObjectRegistry.registerFieldDecorator(className, propertyKey, {\n ...options,\n type: 'oneToMany',\n related: relatedClassName,\n transient: true, // Relationship fields are not database columns\n });\n },\n );\n }) as CompatiblePropertyDecorator;\n}\n\n/**\n * Declares a many-to-many relationship between two `SmrtObject` classes via a join table.\n *\n * The decorated property is `transient` — it is not persisted as a database column.\n * The `through` option specifies the junction table name. The join table model must\n * be decorated with `@smrt({ conflictColumns: ['...', '...'] })` to use the natural\n * key columns for upsert operations.\n *\n * Runtime loading: call `instance.loadRelatedMany('field')` to lazy-load, or\n * pass `include: ['field']` to `collection.list()` for batched eager loading.\n *\n * @param relatedClass - The class constructor of the related objects\n * @param options - Relationship options; `through` specifies the junction table name\n * @returns A TypeScript property decorator (sets `transient: true` automatically)\n *\n * @example\n * ```typescript\n * @smrt()\n * class Product extends SmrtObject {\n * @manyToMany(Tag, { through: 'product_tags' })\n * tags: Tag[] = [];\n * }\n * ```\n *\n * @see {@link oneToMany} for one-to-many relationships\n */\nexport function manyToMany(\n relatedClass: string | Function,\n options: Omit<RelationshipFieldOptions, 'related'> = {},\n) {\n return ((\n targetOrValue: LegacyPropertyDecoratorTarget | undefined,\n propertyKeyOrContext: CompatiblePropertyDecoratorContext<unknown, unknown>,\n ) => {\n const relatedClassName =\n typeof relatedClass === 'string' ? relatedClass : relatedClass.name;\n\n registerCompatibleFieldDecorator(\n targetOrValue,\n propertyKeyOrContext,\n (className, propertyKey) => {\n ObjectRegistry.registerFieldDecorator(className, propertyKey, {\n ...options,\n type: 'manyToMany',\n related: relatedClassName,\n transient: true, // Relationship fields are not database columns\n });\n },\n );\n }) as CompatiblePropertyDecorator;\n}\n\n/**\n * Marks a field as a Single Table Inheritance (STI) meta field.\n *\n * Meta fields are stored in the `_meta_data` JSONB column on the shared STI\n * table rather than as dedicated table columns. Use this decorator for fields\n * that are specific to an STI child class and should not pollute the shared\n * table schema with child-specific columns.\n *\n * The `@smrt({ tableStrategy: 'sti' })` decorator must be set on the base class.\n * All child-specific fields should use `@meta()` (or the `Meta<T>` type alias).\n *\n * @param options - Standard field options (required, nullable, description, etc.)\n * @returns A TypeScript property decorator (registers field with `type: 'meta'`)\n *\n * @example\n * ```typescript\n * @smrt({ tableStrategy: 'sti' })\n * class Event extends SmrtObject {\n * title: string = ''; // shared column on events table\n * }\n *\n * @smrt()\n * class Meeting extends Event {\n * @meta()\n * roomNumber: string = ''; // stored in _meta_data JSON, not a column\n *\n * @meta({ required: true })\n * durationMinutes: number = 60;\n * }\n * ```\n *\n * @see {@link Meta} for the equivalent type alias approach\n * @see {@link field} for regular (non-STI) field declarations\n */\nexport function meta(options: FieldOptions = {}) {\n return ((\n targetOrValue: LegacyPropertyDecoratorTarget | undefined,\n propertyKeyOrContext: CompatiblePropertyDecoratorContext<unknown, unknown>,\n ) => {\n registerCompatibleFieldDecorator(\n targetOrValue,\n propertyKeyOrContext,\n (className, propertyKey) => {\n ObjectRegistry.registerFieldDecorator(className, propertyKey, {\n ...options,\n type: 'meta', // Mark this field as a meta field for STI\n });\n },\n );\n }) as CompatiblePropertyDecorator;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAoPA,SAAgB,MACd,UAAiE,CAAC,GAClE;CACA,SACE,eACA,yBACG;EACH,iCACE,eACA,uBACC,WAAW,gBAAgB;GAC1B,eAAe,uBAAuB,WAAW,aAAa,OAAO;EACvE,CACF;CACF;AACF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAoCA,SAAgB,WACd,cACA,UAAqD,CAAC,GACtD;CACA,SACE,eACA,yBACG;EACH,MAAM,mBACJ,OAAO,iBAAiB,WAAW,eAAe,aAAa;EAEjE,iCACE,eACA,uBACC,WAAW,gBAAgB;GAC1B,eAAe,uBAAuB,WAAW,aAAa;IAC5D,GAAG;IACH,MAAM;IACN,SAAS;GACX,CAAC;EACH,CACF;CACF;AACF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA0CA,SAAgB,gBACd,eACA,UAAkC,CAAC,GACnC;CACA,SACE,eACA,yBACG;EACH,iCACE,eACA,uBACC,WAAW,gBAAgB;GAC1B,eAAe,uBAAuB,WAAW,aAAa;IAC5D,GAAG;IACH,MAAM;IACN,SAAS;GACX,CAAC;EACH,CACF;CACF;AACF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA6DA,SAAgB,UACd,cACA,UAAqD,CAAC,GACtD;CACA,SACE,eACA,yBACG;EACH,MAAM,mBACJ,OAAO,iBAAiB,WAAW,eAAe,aAAa;EAEjE,iCACE,eACA,uBACC,WAAW,gBAAgB;GAC1B,eAAe,uBAAuB,WAAW,aAAa;IAC5D,GAAG;IACH,MAAM;IACN,SAAS;IACT,WAAW;GACb,CAAC;EACH,CACF;CACF;AACF;;;;;;;;;;;;;;;;;;;;;;;;;;;AA4BA,SAAgB,WACd,cACA,UAAqD,CAAC,GACtD;CACA,SACE,eACA,yBACG;EACH,MAAM,mBACJ,OAAO,iBAAiB,WAAW,eAAe,aAAa;EAEjE,iCACE,eACA,uBACC,WAAW,gBAAgB;GAC1B,eAAe,uBAAuB,WAAW,aAAa;IAC5D,GAAG;IACH,MAAM;IACN,SAAS;IACT,WAAW;GACb,CAAC;EACH,CACF;CACF;AACF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAoCA,SAAgB,KAAK,UAAwB,CAAC,GAAG;CAC/C,SACE,eACA,yBACG;EACH,iCACE,eACA,uBACC,WAAW,gBAAgB;GAC1B,eAAe,uBAAuB,WAAW,aAAa;IAC5D,GAAG;IACH,MAAM;GACR,CAAC;EACH,CACF;CACF;AACF"}
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../../src/decorators/index.ts"],"sourcesContent":["/**\n * Field decorators for SMRT objects\n *\n * Modern decorator-based API for defining SMRT object properties.\n * Properties are typed as primitives with decorator metadata.\n */\n\nimport { ObjectRegistry } from '../registry.js';\nimport type { FieldUIHints } from '../scanner/types.js';\nimport type { SQLDataType } from '../schema/types.js';\nimport {\n type CompatiblePropertyDecorator,\n type CompatiblePropertyDecoratorContext,\n type LegacyPropertyDecoratorTarget,\n registerCompatibleFieldDecorator,\n} from './compatibility.js';\n\nexport type { FieldUIHints } from '../scanner/types.js';\n\n/**\n * Meta type wrapper for STI (Single Table Inheritance) meta fields\n *\n * Fields typed as Meta<T> are stored in the _meta_data JSONB column\n * rather than as direct table columns. Used for child-specific fields\n * in STI hierarchies.\n *\n * @example\n * ```typescript\n * @smrt({ tableStrategy: 'sti' })\n * class Event extends SmrtObject {\n * title: string = '';\n * }\n *\n * @smrt()\n * class Meeting extends Event {\n * // Stored in _meta_data JSONB column\n * roomNumber: Meta<string> = '';\n * attendees: Meta<string[]> = [];\n * }\n * ```\n */\nexport type Meta<T> = T;\n\n/**\n * Base field options\n */\nexport type PrimitiveFieldType =\n | 'text'\n | 'integer'\n | 'decimal'\n | 'boolean'\n | 'datetime'\n | 'json';\n\nexport type FieldType =\n | PrimitiveFieldType\n | 'meta'\n | 'foreignKey'\n | 'crossPackageRef'\n | 'oneToMany'\n | 'manyToMany';\n\nexport interface FieldOptions {\n /** Explicit field type for runtime-only registration paths */\n type?: FieldType;\n /** Explicit SQL storage type when runtime and persistence contracts differ */\n sqlType?: SQLDataType;\n /** Whether the field is required */\n required?: boolean;\n /** Default value for the field */\n default?: unknown;\n /** Whether the field is unique */\n unique?: boolean;\n /**\n * When `true`, the schema emits a database index targeting this field.\n *\n * For regular (column-backed) fields the index is a plain column index.\n * For `@meta()` fields stored inside `_meta_data` JSONB, the index targets\n * the JSON path — `json_extract(_meta_data, '$.fieldName')` on SQLite,\n * `(_meta_data->>'fieldName')` on Postgres.\n *\n * Note that `collection.list({ where })` cannot currently reach a JSON-path\n * index: dot-notation keys such as `_meta_data.fieldName` are rejected,\n * because nothing rewrites them into the matching extraction expression\n * (#2276, tracked in #2282). The index is still emitted and still serves\n * hand-written SQL that spells the expression out; it just has no\n * collection-level query to accelerate yet.\n */\n indexed?: boolean;\n /** Whether the field is nullable */\n nullable?: boolean;\n /** Whether the field should be excluded from database */\n transient?: boolean;\n /**\n * Marks the field as sensitive (e.g. API secrets, credentials, tax IDs).\n *\n * Sensitive fields are still persisted to the database, but the framework:\n * - excludes them from `toPublicJSON()` (the serializer used by generated\n * REST/MCP/SvelteKit routes), so they never appear in API responses; and\n * - rejects them as `where`-clause filter keys, closing the\n * `?secret[like]=...` value-probing oracle.\n *\n * Use this for any column that holds a secret value that must never be\n * read back over a generated network surface.\n */\n sensitive?: boolean;\n /**\n * Marks the field as read-only over generated write surfaces.\n *\n * Read-only fields are stripped from the request body before\n * `create`/`update` in generated REST/MCP/SvelteKit routes, so callers\n * cannot mass-assign them. Server-side code can still set them directly.\n */\n readonly?: boolean;\n /**\n * Permission slug required to include this field in public/read responses.\n *\n * Fields with a read permission are fail-closed: generated serializers omit\n * them unless the caller's resolved permission set contains this slug.\n * `sensitive: true` still wins and omits the field for every caller.\n */\n readPermission?: string;\n /** Field description */\n description?: string;\n /**\n * Static UI hints for the field-policy rail (#2046, epic #2045).\n *\n * A pure presentation seed — carried in the manifest under the field's\n * `_meta.ui`, readable at runtime via `getAllFields()` at `field._meta.ui`,\n * and emitted to the browser in generated web-collection definitions. Has no\n * schema, persistence, or security effect; `sensitive`/`readPermission`\n * remain the security rail.\n */\n ui?: FieldUIHints;\n /**\n * Controls whether the field is included in JSON exports.\n * - `true`: Always exported (unless site explicitly excludes it)\n * - `false`: Never exported (cannot be overridden by site config)\n * - `undefined`: Uses site's fieldExportDefault setting\n */\n exported?: boolean;\n}\n\n/**\n * Options for text fields\n */\nexport interface TextFieldOptions extends FieldOptions {\n /** Minimum length for text fields */\n minLength?: number;\n /** Maximum length for text fields */\n maxLength?: number;\n /** Regex pattern for validation */\n pattern?: RegExp | string;\n}\n\n/**\n * Options for numeric fields\n */\nexport interface NumericFieldOptions extends FieldOptions {\n /** Minimum value */\n min?: number;\n /** Maximum value */\n max?: number;\n}\n\n/**\n * Options for relationship fields\n */\nexport interface RelationshipFieldOptions extends FieldOptions {\n /** Related class name */\n related?: string;\n /** Foreign key field name */\n foreignKey?: string;\n /** Through table for many-to-many */\n through?: string;\n /** Relationship type */\n type?: 'foreignKey' | 'crossPackageRef' | 'oneToMany' | 'manyToMany';\n /**\n * What happens to this row when the referenced object is deleted (#2371).\n *\n * SMRT emits no DB-level `FOREIGN KEY` constraints, so this is enforced by\n * `SmrtObject.delete()` in the application layer rather than by the engine:\n *\n * - `'CASCADE'` — this row is deleted with the target.\n * - `'SET NULL'` — this column is set to `NULL`. The field must be nullable;\n * declaring it on a `required` field throws `ConfigurationError`.\n * - `'RESTRICT'` — deleting the target throws `DatabaseError` while any row\n * still points at it.\n *\n * When omitted, a column that is part of this class's `conflictColumns`\n * (junction and association rows, which are *identified* by the reference)\n * defaults to `CASCADE`; every other column is left untouched on delete.\n * `@tenantId()` fields are the one exception: `smrt-tenancy` leads a\n * tenant-scoped class's default `conflictColumns` with the tenant column,\n * but that column scopes ownership rather than identifying the row, so it\n * is never defaulted to `CASCADE` — deleting a `Tenant` must not cascade\n * through every tenant-scoped table.\n *\n * Cascaded rows are removed set-based: their `beforeDelete`/`afterDelete`\n * hooks and interceptors do not run and no change-feed entry is written, the\n * same as a database-level `ON DELETE CASCADE`.\n */\n onDelete?: 'CASCADE' | 'SET NULL' | 'RESTRICT';\n}\n\n/**\n * Options specific to cross-package references.\n */\nexport interface CrossPackageRefOptions\n extends Omit<RelationshipFieldOptions, 'related' | 'type'> {\n /**\n * Storage type for the referenced target id. Defaults to 'uuid'.\n *\n * Use 'text' only when the external target model declares\n * `@smrt({ idType: 'text' })`.\n */\n idType?: 'uuid' | 'text';\n\n /**\n * When `true`, the framework verifies the referenced object exists at save time.\n * Validation uses the target package's manifest (loaded on demand via\n * `ObjectRegistry.ensureManifestLoaded()`), so this requires the target manifest\n * to be discoverable at runtime.\n *\n * Empty/null values are always allowed (treated as \"no reference set\").\n *\n * Defaults to `false` — same behavior as a plain string field today.\n */\n validate?: boolean;\n}\n\n/**\n * Resolve a relationship decorator's target argument to a class name.\n *\n * Accepted target forms:\n * - `'Target'` — class name string; resolves lazily, immune to import cycles.\n * - `Target` — class constructor.\n * - `() => Target` — thunk (inline or a named `const`), invoked here to read\n * the name, so its target must already be initialized.\n *\n * A thunk's own `.name` is `''`, so reading `relatedClass.name` used to register\n * `related: ''` (issue #2379): the field kept `type: 'foreignKey'` but lost its\n * target, which silently dropped the relationship edge, `loadRelated()`, and any\n * FK-derived index. Thunks are therefore invoked at decoration time and an\n * unresolvable target throws with the string form as the remedy — an empty\n * `related` is never registered.\n *\n * Resolution runs inside the field-registration callback, which is the latest\n * point in the decorator lifecycle (after the class binding exists for legacy\n * decorators, and at `@smrt()` application time for standard decorators), so a\n * self-referential `() => Self` thunk resolves rather than hitting the TDZ.\n * A thunk pointing at a class declared LATER in the same module is still in\n * that class's temporal dead zone when the decorators of the earlier class run;\n * that now fails loudly, naming the string form, instead of silently\n * registering an empty target.\n */\nfunction resolveRelatedClassName(\n decoratorName: 'foreignKey' | 'oneToMany' | 'manyToMany',\n relatedClass: string | Function,\n className: string,\n propertyKey: string,\n): string {\n const where = `@${decoratorName}() on ${className}.${propertyKey}`;\n const remedy =\n `Pass the target class name as a string instead — ` +\n `\\`@${decoratorName}('Target')\\` resolves lazily and is immune to import cycles.`;\n\n if (typeof relatedClass === 'string') {\n const name = relatedClass.trim();\n if (!name) {\n throw new Error(\n `${where}: target class name is empty. Pass a class, a class name, or a \\`() => Target\\` thunk.`,\n );\n }\n return name;\n }\n\n if (typeof relatedClass !== 'function') {\n throw new Error(\n `${where}: expected a class, a class name, or a \\`() => Target\\` thunk, received ${relatedClass === null ? 'null' : typeof relatedClass}. ${remedy}`,\n );\n }\n\n // Class/function reference — the common `@foreignKey(Target)` form. Arrow\n // functions have no `prototype`, so a *named* thunk (`const lazyTarget = () =>\n // Target`) is still routed to the thunk branch below instead of registering\n // the variable name as the target class.\n if (relatedClass.name && relatedClass.prototype !== undefined) {\n return relatedClass.name;\n }\n\n // Thunk (`() => Target`, named or inline) — invoke it for the target.\n let resolved: unknown;\n try {\n resolved = (relatedClass as () => unknown)();\n } catch (error) {\n throw new Error(\n `${where}: the \\`() => Target\\` thunk threw while resolving its target (${\n error instanceof Error ? error.message : String(error)\n }). ${remedy}`,\n { cause: error },\n );\n }\n\n if (typeof resolved === 'function' && resolved.name) {\n return resolved.name;\n }\n if (typeof resolved === 'string' && resolved.trim()) {\n return resolved.trim();\n }\n\n throw new Error(\n `${where}: the \\`() => Target\\` thunk resolved to ${\n resolved === null ? 'null' : typeof resolved\n } instead of a named class. ${remedy}`,\n );\n}\n\n/**\n * Marks a class property with validation constraints and metadata options.\n *\n * Use `@field()` when you need options beyond what plain TypeScript initializers\n * express — required validation, numeric ranges, string length limits, uniqueness,\n * or transient (non-persisted) computed properties.\n *\n * For plain persisted fields with no constraints, no decorator is needed: just\n * declare the property with a TypeScript initializer and the framework will infer\n * the column type from the default value (`0` → INTEGER, `0.0` → DECIMAL, `''` → TEXT).\n *\n * @param options - Field configuration options\n * @param options.required - If `true`, `save()` throws `ValidationError` when empty/null\n * @param options.unique - Enforces a UNIQUE database constraint\n * @param options.nullable - If `true`, the column accepts NULL (default depends on type)\n * @param options.transient - If `true`, the property is not persisted to the database\n * @param options.default - Default value applied at the database level\n * @param options.description - Human-readable description used in generated API docs\n * @param options.exported - Controls JSON export visibility (see `FieldOptions`)\n * @returns A TypeScript property decorator\n *\n * @example\n * ```typescript\n * @smrt()\n * class Product extends SmrtObject {\n * @field({ required: true, maxLength: 100 })\n * name: string = '';\n *\n * @field({ min: 0 })\n * stock: number = 0;\n *\n * @field({ transient: true })\n * get displayPrice(): string { return `$${this.price.toFixed(2)}`; }\n * }\n * ```\n *\n * @see {@link meta} for STI child-specific fields stored in `_meta_data` JSON\n * @see {@link foreignKey} for typed relationship fields\n */\nexport function field(\n options: FieldOptions | NumericFieldOptions | TextFieldOptions = {},\n) {\n return ((\n targetOrValue: LegacyPropertyDecoratorTarget | undefined,\n propertyKeyOrContext: CompatiblePropertyDecoratorContext<unknown, unknown>,\n ) => {\n registerCompatibleFieldDecorator(\n targetOrValue,\n propertyKeyOrContext,\n (className, propertyKey) => {\n ObjectRegistry.registerFieldDecorator(className, propertyKey, options);\n },\n );\n }) as CompatiblePropertyDecorator;\n}\n\n/**\n * Declares a many-to-one (foreign key) relationship to another `SmrtObject` class.\n *\n * The decorated property stores the UUID of the related object. At runtime, call\n * `instance.loadRelated('fieldName')` to lazy-load (and cache) the related object,\n * or pass `include: ['fieldName']` to `collection.list()` for batch eager loading.\n *\n * Cross-package rule: Use `@foreignKey()` only for same-package references.\n * For cross-package foreign keys, use a plain `string` property instead to avoid\n * circular dependencies between packages.\n *\n * **No DDL constraint is emitted.** SMRT generates no `FOREIGN KEY` clause on\n * any engine — the relationship is enforced by the framework, not the database\n * (#2371). What that buys you is `loadRelated()`, eager `include:` loading, and\n * the `onDelete` behaviour applied by `SmrtObject.delete()`:\n *\n * ```typescript\n * @foreignKey(Order, { onDelete: 'CASCADE' }) // deleted with the order\n * orderId: string = '';\n *\n * @foreignKey(Customer, { onDelete: 'RESTRICT' }) // blocks the customer delete\n * customerId: string = '';\n * ```\n *\n * Without an `onDelete`, a column that is part of this class's\n * `conflictColumns` defaults to `CASCADE` (this is what cleans up junction\n * rows); any other column is left untouched when the target is deleted.\n *\n * @param relatedClass - The target class constructor, its name as a string, or a\n * `() => Target` thunk. A thunk is **invoked at decoration time**, so its\n * target must already be initialized: a class from an already-evaluated module\n * or the decorated class itself. Use the string form for a class declared\n * later in the same module or reached through an import cycle — it is never\n * evaluated, so it cannot hit the temporal dead zone.\n * @param options - Optional field constraints (required, nullable, etc.)\n * @returns A TypeScript property decorator\n * @throws Error when the target cannot be resolved to a class name (an empty\n * string, a thunk that throws — including on an uninitialized target — or a\n * thunk returning an anonymous value)\n *\n * @example\n * ```typescript\n * @smrt()\n * class Order extends SmrtObject {\n * // Same-package FK — enables loadRelated() and eager loading\n * @foreignKey(Customer)\n * customerId: string = '';\n *\n * // Self-reference: the class binding exists when its decorators run\n * @foreignKey(() => Order)\n * parentOrderId: string = '';\n *\n * // Declared later in this module — name string, never evaluated\n * @foreignKey('Invoice')\n * invoiceId: string = '';\n * }\n *\n * // Cross-package: use a plain string instead\n * @smrt()\n * class Post extends SmrtObject {\n * authorId: string = ''; // plain string — no circular dep\n * }\n * ```\n *\n * @see {@link oneToMany} for the inverse (parent) side of the relationship\n * @see SmrtObject.loadRelated for lazy-loading the related object at runtime\n */\nexport function foreignKey(\n relatedClass: string | Function,\n options: Omit<RelationshipFieldOptions, 'related'> = {},\n) {\n return ((\n targetOrValue: LegacyPropertyDecoratorTarget | undefined,\n propertyKeyOrContext: CompatiblePropertyDecoratorContext<unknown, unknown>,\n ) => {\n registerCompatibleFieldDecorator(\n targetOrValue,\n propertyKeyOrContext,\n (className, propertyKey) => {\n ObjectRegistry.registerFieldDecorator(className, propertyKey, {\n ...options,\n type: 'foreignKey',\n related: resolveRelatedClassName(\n 'foreignKey',\n relatedClass,\n className,\n propertyKey,\n ),\n });\n },\n );\n }) as CompatiblePropertyDecorator;\n}\n\n/**\n * Declares a cross-package foreign key reference.\n *\n * Use this for relationships that point to a `SmrtObject` in a *different* package\n * (e.g. `Customer.profileId` pointing at `@happyvertical/smrt-profiles:Profile`).\n *\n * Like `@foreignKey()`, this emits **no** DDL `FOREIGN KEY` constraint — SMRT\n * does not emit them on any engine (#2371). The decorated property is a\n * `UUID` column on PostgreSQL/DuckDB and `TEXT` on SQLite, matching the target's\n * id type; pass `{ idType: 'text' }` when the target declares\n * `@smrt({ idType: 'text' })`.\n *\n * What you get over a plain string field:\n * - The relationship is registered with the `ObjectRegistry`, so `loadRelated()`\n * and `Collection.list({ include })` can resolve it once the target package's\n * manifest is loaded.\n * - Optional save-time validation (`validate: true`) confirms the referenced\n * object exists, catching typos and stale IDs before they hit the database.\n * - `onDelete` is honoured by `SmrtObject.delete()` when the target package's\n * manifest is loaded in the same runtime.\n *\n * The `qualifiedName` is a fully-qualified class identifier in the form\n * `@package/scope:ClassName` — for example `@happyvertical/smrt-profiles:Profile`.\n *\n * @param qualifiedName - Qualified name of the target class\n * @param options - Optional field constraints and `validate` flag\n * @returns A TypeScript property decorator\n *\n * @example\n * ```typescript\n * @smrt()\n * class Customer extends SmrtObject {\n * @crossPackageRef('@happyvertical/smrt-profiles:Profile')\n * profileId: string = '';\n *\n * // With save-time validation\n * @crossPackageRef('@happyvertical/smrt-profiles:Profile', { validate: true })\n * primaryContactId: string = '';\n * }\n * ```\n *\n * @see {@link foreignKey} for same-package relationships\n * @see SmrtObject.loadRelated for runtime resolution\n */\nexport function crossPackageRef(\n qualifiedName: string,\n options: CrossPackageRefOptions = {},\n) {\n return ((\n targetOrValue: LegacyPropertyDecoratorTarget | undefined,\n propertyKeyOrContext: CompatiblePropertyDecoratorContext<unknown, unknown>,\n ) => {\n registerCompatibleFieldDecorator(\n targetOrValue,\n propertyKeyOrContext,\n (className, propertyKey) => {\n ObjectRegistry.registerFieldDecorator(className, propertyKey, {\n ...options,\n type: 'crossPackageRef',\n related: qualifiedName,\n });\n },\n );\n }) as CompatiblePropertyDecorator;\n}\n\n/**\n * Declares a one-to-many relationship from this object to a collection of related objects.\n *\n * The decorated property is `transient` — it is not persisted as a database column.\n * At runtime, call `instance.loadRelatedMany('fieldName')` to load the related objects,\n * or pass `include: ['fieldName']` to `collection.list()` for batch eager loading (issues\n * a single batched query for all instances instead of N individual queries).\n *\n * The inverse side (`@foreignKey`) must exist on the `relatedClass` pointing back to this\n * class. The framework discovers it automatically via `ObjectRegistry.getInverseRelationships()`.\n *\n * **Generated accessor (R10):** registering the class installs a consistent\n * `get<FieldName>()` instance method (e.g. `items` → `order.getItems()`) that\n * delegates to `loadRelatedMany('items')`. Generation is additive — a\n * hand-rolled method of the same name is never overwritten.\n *\n * **Disambiguation:** when `relatedClass` declares more than one `@foreignKey`\n * back to this class, pass `{ foreignKey: '<inverseFieldName>' }` so both\n * `loadRelatedMany` and the generated accessor resolve the intended inverse\n * side. Without it the first matching foreign key is used.\n *\n * **Delete behaviour is declared on the child, not here.** `@oneToMany` is a\n * transient read-side accessor; to have children removed with their parent,\n * put `onDelete: 'CASCADE'` on the inverse `@foreignKey` (#2371):\n *\n * ```typescript\n * class OrderItem extends SmrtObject {\n * @foreignKey(Order, { onDelete: 'CASCADE' })\n * orderId: string = '';\n * }\n * ```\n *\n * @param relatedClass - The class constructor of the child/related objects\n * @param options - Optional relationship options. `foreignKey` selects the\n * inverse foreign-key field on `relatedClass` when it has more than one.\n * @returns A TypeScript property decorator (sets `transient: true` automatically)\n *\n * @example\n * ```typescript\n * @smrt()\n * class Order extends SmrtObject {\n * @oneToMany(OrderItem)\n * items: OrderItem[] = [];\n * }\n *\n * @smrt()\n * class OrderItem extends SmrtObject {\n * @foreignKey(Order)\n * orderId: string = '';\n * }\n *\n * const order = await orders.get({ id });\n * const items = await order.getItems(); // generated; === loadRelatedMany('items')\n * ```\n *\n * @example\n * ```typescript\n * // Multiple inverse foreign keys → disambiguate explicitly.\n * @smrt()\n * class Profile extends SmrtObject {\n * @oneToMany(ProfileRelationship, { foreignKey: 'fromProfileId' })\n * relationshipsFrom: ProfileRelationship[] = [];\n * @oneToMany(ProfileRelationship, { foreignKey: 'toProfileId' })\n * relationshipsTo: ProfileRelationship[] = [];\n * }\n * ```\n *\n * @see {@link foreignKey} for the many-to-one (child) side of the relationship\n * @see SmrtObject.loadRelatedMany for lazy-loading at runtime\n */\nexport function oneToMany(\n relatedClass: string | Function,\n options: Omit<RelationshipFieldOptions, 'related'> = {},\n) {\n return ((\n targetOrValue: LegacyPropertyDecoratorTarget | undefined,\n propertyKeyOrContext: CompatiblePropertyDecoratorContext<unknown, unknown>,\n ) => {\n registerCompatibleFieldDecorator(\n targetOrValue,\n propertyKeyOrContext,\n (className, propertyKey) => {\n ObjectRegistry.registerFieldDecorator(className, propertyKey, {\n ...options,\n type: 'oneToMany',\n related: resolveRelatedClassName(\n 'oneToMany',\n relatedClass,\n className,\n propertyKey,\n ),\n transient: true, // Relationship fields are not database columns\n });\n },\n );\n }) as CompatiblePropertyDecorator;\n}\n\n/**\n * Declares a many-to-many relationship between two `SmrtObject` classes via a join table.\n *\n * The decorated property is `transient` — it is not persisted as a database column.\n * The `through` option specifies the junction table name. The join table model must\n * be decorated with `@smrt({ conflictColumns: ['...', '...'] })` to use the natural\n * key columns for upsert operations.\n *\n * Runtime loading: call `instance.loadRelatedMany('field')` to lazy-load, or\n * pass `include: ['field']` to `collection.list()` for batched eager loading.\n *\n * @param relatedClass - The class constructor of the related objects\n * @param options - Relationship options; `through` specifies the junction table name\n * @returns A TypeScript property decorator (sets `transient: true` automatically)\n *\n * @example\n * ```typescript\n * @smrt()\n * class Product extends SmrtObject {\n * @manyToMany(Tag, { through: 'product_tags' })\n * tags: Tag[] = [];\n * }\n * ```\n *\n * @see {@link oneToMany} for one-to-many relationships\n */\nexport function manyToMany(\n relatedClass: string | Function,\n options: Omit<RelationshipFieldOptions, 'related'> = {},\n) {\n return ((\n targetOrValue: LegacyPropertyDecoratorTarget | undefined,\n propertyKeyOrContext: CompatiblePropertyDecoratorContext<unknown, unknown>,\n ) => {\n registerCompatibleFieldDecorator(\n targetOrValue,\n propertyKeyOrContext,\n (className, propertyKey) => {\n ObjectRegistry.registerFieldDecorator(className, propertyKey, {\n ...options,\n type: 'manyToMany',\n related: resolveRelatedClassName(\n 'manyToMany',\n relatedClass,\n className,\n propertyKey,\n ),\n transient: true, // Relationship fields are not database columns\n });\n },\n );\n }) as CompatiblePropertyDecorator;\n}\n\n/**\n * Marks a field as a Single Table Inheritance (STI) meta field.\n *\n * Meta fields are stored in the `_meta_data` JSONB column on the shared STI\n * table rather than as dedicated table columns. Use this decorator for fields\n * that are specific to an STI child class and should not pollute the shared\n * table schema with child-specific columns.\n *\n * The `@smrt({ tableStrategy: 'sti' })` decorator must be set on the base class.\n * All child-specific fields should use `@meta()` (or the `Meta<T>` type alias).\n *\n * @param options - Standard field options (required, nullable, description, etc.)\n * @returns A TypeScript property decorator (registers field with `type: 'meta'`)\n *\n * @example\n * ```typescript\n * @smrt({ tableStrategy: 'sti' })\n * class Event extends SmrtObject {\n * title: string = ''; // shared column on events table\n * }\n *\n * @smrt()\n * class Meeting extends Event {\n * @meta()\n * roomNumber: string = ''; // stored in _meta_data JSON, not a column\n *\n * @meta({ required: true })\n * durationMinutes: number = 60;\n * }\n * ```\n *\n * @see {@link Meta} for the equivalent type alias approach\n * @see {@link field} for regular (non-STI) field declarations\n */\nexport function meta(options: FieldOptions = {}) {\n return ((\n targetOrValue: LegacyPropertyDecoratorTarget | undefined,\n propertyKeyOrContext: CompatiblePropertyDecoratorContext<unknown, unknown>,\n ) => {\n registerCompatibleFieldDecorator(\n targetOrValue,\n propertyKeyOrContext,\n (className, propertyKey) => {\n ObjectRegistry.registerFieldDecorator(className, propertyKey, {\n ...options,\n type: 'meta', // Mark this field as a meta field for STI\n });\n },\n );\n }) as CompatiblePropertyDecorator;\n}\n"],"mappings":"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"}
|
|
@@ -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;AAgB5D,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;IAyCjC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { resolveDispatchTenantId, resolveDispatchTenantScope } from "./tenant-resolver.js";
|
|
2
2
|
import { CREATE_SMRT_DISPATCH_SUBSCRIPTIONS_TABLE, CREATE_SMRT_DISPATCH_TABLE, getSystemTableDDLForEngine } from "../system/schema.js";
|
|
3
|
+
import { applyPostgresRuntimeTimeouts } from "../postgres-timeouts.js";
|
|
3
4
|
import { assertPostgresSystemTimestampsCurrent, ensureDispatchSubscriptionsSystemTableCompatibility, ensureDispatchSystemTableCompatibility, getDatabaseEngine } from "../system/compatibility.js";
|
|
4
5
|
import { Dispatch } from "./models/Dispatch.js";
|
|
5
6
|
import { DispatchCollection } from "./collections/Dispatches.js";
|
|
@@ -513,12 +514,12 @@ async function createDispatchBus(options = {}) {
|
|
|
513
514
|
const dbConfig = options.db || options.persistence;
|
|
514
515
|
if (!dbConfig) throw new Error("DispatchBus requires a database configuration");
|
|
515
516
|
let db;
|
|
516
|
-
if (typeof dbConfig === "string") db = await getDatabase(dbConfig);
|
|
517
|
+
if (typeof dbConfig === "string") db = await getDatabase(applyPostgresRuntimeTimeouts({ url: dbConfig }));
|
|
517
518
|
else if ("query" in dbConfig) db = dbConfig;
|
|
518
|
-
else if ("type" in dbConfig && "url" in dbConfig) db = await getDatabase({
|
|
519
|
+
else if ("type" in dbConfig && "url" in dbConfig) db = await getDatabase(applyPostgresRuntimeTimeouts({
|
|
519
520
|
type: dbConfig.type,
|
|
520
521
|
url: dbConfig.url
|
|
521
|
-
});
|
|
522
|
+
}));
|
|
522
523
|
else throw new Error("Invalid database configuration for DispatchBus");
|
|
523
524
|
const bus = new DispatchBus(db);
|
|
524
525
|
await bus.initialize();
|
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 {\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 db = await getDatabase(dbConfig);\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 db = await getDatabase({\n type: dbConfig.type as 'sqlite' | 'postgres' | 'duckdb',\n url: dbConfig.url,\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":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA6DA,IAAM,SAAS,aAAa,EAAE,OAAO,OAAO,CAAC;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAoD7C,IAAa,cAAb,MAAa,YAAY;CACvB;CACA,2BAAqD,IAAI,IAAI;CAC7D,cAA+B;;;;;;;CAQ/B,OAAwB,sBAAsB;;CAG9C,OAAwB,oBAAoB;;;;;;;;;;;;;CAc5C,OAAe,eAAe,QAAoC;EAChE,MAAM,OAAO,UAAU,GAAA,CAAI,KAAK;EAChC,IAAI,CAAC,KACH,OAAO;EAET,IAAI,QAAQ,YAAY,qBACtB,MAAM,IAAI,MACR,sBAAsB,YAAY,oBAAoB,8CACxD;EAEF,OAAO,IAAI,MAAM,GAAG,YAAY,iBAAiB;CACnD;;;;CAKA,YAAY,IAAuB;EACjC,KAAK,KAAK;CACZ;;;;CAKA,MAAM,aAA4B;EAChC,IAAI,KAAK,aAAa;EACtB,MAAM,SAAS,kBAAkB,KAAK,EAAE;EAIxC,IAAI,CAAC,MADwB,mBAAmB,YAAY,KAAK,EAAE,GAC9C;GAEnB,MAAM,aAAa,2BACjB,4BACA,MACF,CAAC,CACE,MAAM,GAAG,CAAC,CACV,QAAQ,MAAM,EAAE,KAAK,CAAC;GACzB,KAAK,MAAM,QAAQ,YACjB,MAAM,KAAK,GAAG,MAAM,IAAI;EAE5B;EACA,MAAM,sCAAsC,KAAK,EAAE;EACnD,MAAM,uCAAuC,KAAK,EAAE;EAKpD,IAAI,CAAC,MAHoB,+BAA+B,YACtD,KAAK,EACP,GACiB;GACf,MAAM,aAAa,2BACjB,0CACA,MACF,CAAC,CACE,MAAM,GAAG,CAAC,CACV,QAAQ,MAAM,EAAE,KAAK,CAAC;GACzB,KAAK,MAAM,QAAQ,YACjB,MAAM,KAAK,GAAG,MAAM,IAAI;EAE5B;EACA,MAAM,sCAAsC,KAAK,EAAE;EACnD,MAAM,oDAAoD,KAAK,EAAE;EAEjE,KAAK,cAAc;CACrB;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAuDA,MAAM,KACJ,MACA,SACA,UAA+B,CAAC,GACb;EACnB,MAAM,KAAK,WAAW;EAMtB,MAAM,WAAW,wBAAwB,KAAK;EAG9C,MAAM,SAAS,YAAY,eAAe,QAAQ,MAAM;EAOxD,MAAM,eAAe,MAAM,+BAA+B,iBACxD,KAAK,IACL,MACA,2BAA2B,CAC7B;EAGA,MAAM,aAAa,aAAa,QAAQ,MAAM,EAAE,aAAa,QAAQ;EACrE,MAAM,cAAc,aAAa,QAAQ,MAAM,EAAE,aAAa,QAAQ;EAGtE,MAAM,WAAW,IAAI,SAAS;GAC5B;GACA;GACA,WAAW,QAAQ,YAAY;GAC/B,SAAS,KAAK,UAAU,WAAW,CAAC,CAAC;GACrC,UAAU,KAAK,UAAU,QAAQ,YAAY,CAAC,CAAC;GAC/C,QAAQ;GACR,gBAAgB,QAAQ,iBAAiB;GACzC,WAAW;EACb,CAAC;EAGD,IAAI,iBAAiB;EAGrB,KAAK,MAAM,OAAO,YAAY;GAC5B,MAAM,iBAAiB,IAAI,SAAS;IAClC;IACA;IACA,WAAW,QAAQ,YAAY;IAC/B,SAAS,KAAK,UAAU,WAAW,CAAC,CAAC;IACrC,UAAU,KAAK,UAAU,QAAQ,YAAY,CAAC,CAAC;IAC/C,QAAQ;IACR,mBAAmB,IAAI;IACvB,gBAAgB,QAAQ,iBAAiB;IACzC,WAAW;GACb,CAAC;GACD,MAAM,mBAAmB,OAAO,KAAK,IAAI,cAAc;GAEvD,IAAI,YAAY,WAAW,KAAK,aAAa,SAAS,GACpD,iBAAiB;EAErB;EAIA,IAAI,YAAY,SAAS,KAAK,aAAa,WAAW,GACpD,MAAM,mBAAmB,OAAO,KAAK,IAAI,QAAQ;EAInD,KAAK,eAAe,MAAM,SAAS,SAAS,YAAY,CAAC;EAEzD,OAAO;CACT;;;;;;;;;;;;;;;;;;;;;;;;;;;CA4BA,GAAG,SAAiB,SAAgC;EAOlD,MAAM,aAAgC;GAAE;GAAS;GAAS,cAAA,IANjC,qBAAqB;IAC5C,aAAa;IACb,YAAY;IACZ,SAAS;GACX,CAE0D;EAAa;EAEvE,IAAI,CAAC,KAAK,SAAS,IAAI,OAAO,GAC5B,KAAK,SAAS,IAAI,SAAS,CAAC,CAAC;EAE/B,KAAK,SAAS,IAAI,OAAO,CAAC,EAAE,KAAK,UAAU;CAC7C;;;;;;;;CASA,IAAI,SAAiB,SAAmC;EACtD,MAAM,WAAW,KAAK,SAAS,IAAI,OAAO;EAC1C,IAAI,CAAC,UAAU,OAAO;EAEtB,MAAM,QAAQ,SAAS,WAAW,MAAM,EAAE,YAAY,OAAO;EAC7D,IAAI,UAAU,IAAI,OAAO;EAEzB,SAAS,OAAO,OAAO,CAAC;EACxB,IAAI,SAAS,WAAW,GACtB,KAAK,SAAS,OAAO,OAAO;EAE9B,OAAO;CACT;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAyCA,MAAM,UAAU,SAAkD;EAChE,MAAM,KAAK,WAAW;EAMtB,MAAM,cAAc,QAAQ,cAAc,GAAA,CAAI,KAAK;EACnD,IAAI,CAAC,YACH,MAAM,IAAI,MAAM,uDAAuD;EAEzE,IAAI,eAAe,YAAY,qBAC7B,MAAM,IAAI,MACR,2BAA2B,YAAY,oBAAoB,gCAC7D;EAEF,MAAM,cAAc,QAAQ,cAAc,GAAA,CAAI,KAAK;EACnD,IAAI,CAAC,YACH,MAAM,IAAI,MAAM,uDAAuD;EAGzE,MAAM,eAAe,IAAI,qBAAqB;GAC5C,aAAa;GACb;GACA,SAAS,QAAQ,WAAW;GAC5B,UAAU,QAAQ,YAAY;GAC9B,SAAS,QAAQ,YAAY,QAAQ,IAAI;GAGzC,WAAW,wBAAwB,KAAK;EAC1C,CAAC;EAED,MAAM,+BAA+B,OAAO,KAAK,IAAI,YAAY;CACnE;;;;;;;CAQA,MAAM,YAAY,YAAoB,YAAmC;EACvE,MAAM,KAAK,WAAW;EAGtB,MAAM,+BAA+B,YACnC,KAAK,IACL,YACA,YACA,2BAA2B,CAC7B;CACF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;CAkCA,MAAM,QACJ,YACA,SACA,UAAkC,CAAC,GAClB;EACjB,MAAM,KAAK,WAAW;EAUtB,MAAM,cAAc,2BAA2B;EAI/C,MAAM,gBAAgB,MAAM,+BAA+B,iBACzD,KAAK,IACL,YACA,MACA,WACF;EAEA,IAAI,cAAc,WAAW,GAC3B,OAAO;EAIT,MAAM,cAAc,cAAc,KAAK,MAAM,EAAE,UAAU;EAIzD,MAAM,eAAe,YAAY,MAAM,MAAM,EAAE,SAAS,GAAG,CAAC;EAG5D,MAAM,yBAAyB,aAAgC;GAE7D,IAAI,YAAY;QACV,YAAY,aAAa;SAEvB,SAAS,aAAa,MACxB,OAAO;IAAA,OAEJ,IACL,SAAS,aAAa,QACtB,SAAS,aAAa,YAAY,UAGlC,OAAO;GAAA;GAGX,IAAI,SAAS,qBAAqB,YAEhC,OAAO;GAET,IAAI,SAAS,qBAAqB,MAEhC,OAAO;GAOT,OAHoB,cAAc,MAAM,QACtC,IAAI,QAAQ,SAAS,IAAI,CAEpB,CAAA,EAAa,aAAa;EACnC;EAEA,IAAI;EACJ,IAAI,cAQF,qBAAoB,MANK,mBAAmB,KAAK,KAAK,IAAI;GACxD,QAAQ;GACR,OAAO,QAAQ,SAAS;GACxB;EACF,CAAC,EAAA,CAE8B,QAC5B,aACC,cAAc,MAAM,QAAQ,IAAI,QAAQ,SAAS,IAAI,CAAC,KACtD,sBAAsB,QAAQ,CAClC;OAWA,qBAAoB,MARF,mBAAmB,YACnC,KAAK,IACL,aACA,QAAQ,SAAS,KACjB,YACA,WACF,EAAA,CAEwB,OAAO,qBAAqB;EAItD,IAAI,QAAQ,eAAe,QAAQ,YAAY,SAAS,GACtD,oBAAoB,kBAAkB,QAAQ,MAC5C,QAAQ,aAAa,SAAS,EAAE,IAAI,CACtC;EAGF,IAAI,YAAY;EAEhB,KAAK,MAAM,YAAY,mBAAmB;GAWxC,IAAI,CAAC,MALiB,mBAAmB,MACvC,KAAK,IACL,UACA,WACF,GAEE;GAGF,IAAI;IAEF,MAAM,QAAQ,SAAS,SAAS,SAAS,YAAY,CAAC;IAGtD,SAAS,cAAc,UAAU;IACjC,MAAM,mBAAmB,OAAO,KAAK,IAAI,QAAQ;IACjD;GACF,SAAS,OAAO;IAEd,MAAM,eACJ,iBAAiB,QAAQ,MAAM,UAAU,OAAO,KAAK;IACvD,SAAS,WAAW,YAAY;IAChC,MAAM,mBAAmB,OAAO,KAAK,IAAI,QAAQ;GACnD;EACF;EAEA,OAAO;CACT;;;;;;;;;CAUA,MAAM,MAAM,UAAgC,CAAC,GAAoB;EAC/D,MAAM,KAAK,WAAW;EAMtB,MAAM,YAAY,MAAM,mBAAmB,cACzC,KAAK,IACL,SACA,2BAA2B,CAC7B;EAEA,KAAK,MAAM,YAAY,WAAW;GAChC,SAAS,cAAc;GACvB,MAAM,mBAAmB,OAAO,KAAK,IAAI,QAAQ;EACnD;EAEA,OAAO,UAAU;CACnB;;;;;;;CAQA,MAAM,QACJ,UAAkC,CAAC,GACH;EAChC,MAAM,KAAK,WAAW;EAItB,OAAO,mBAAmB,QACxB,KAAK,IACL,SACA,2BAA2B,CAC7B;CACF;;;;;;;;;;;;;;;;CAiBA,MAAM,KAAK,UAA+B,CAAC,GAAwB;EACjE,MAAM,KAAK,WAAW;EAEtB,MAAM,EAAE,aAAa,qBAAqB,GAAG,gBAAgB;EAC7D,OAAO,mBAAmB,KAAK,KAAK,IAAI;GACtC,GAAG;GACH,aAAa,2BAA2B;EAC1C,CAAC;CACH;;;;;;;;;CAUA,MAAM,IAAI,IAAsC;EAC9C,MAAM,KAAK,WAAW;EACtB,OAAO,mBAAmB,UACxB,KAAK,IACL,IACA,2BAA2B,CAC7B;CACF;;;;CAKA,MAAM,kBACJ,YACiC;EACjC,MAAM,KAAK,WAAW;EAItB,MAAM,cAAc,2BAA2B;EAE/C,IAAI,YACF,OAAO,+BAA+B,iBACpC,KAAK,IACL,YACA,OACA,WACF;EAEF,OAAO,+BAA+B,KAAK,KAAK,IAAI,OAAO,WAAW;CACxE;;;;CAKA,eACE,MACA,SACA,UACM;EAEN,MAAM,mBAAwC,CAAC;EAE/C,KAAK,MAAM,GAAG,aAAa,KAAK,UAC9B,KAAK,MAAM,cAAc,UACvB,IAAI,WAAW,aAAa,QAAQ,IAAI,GACtC,iBAAiB,KAAK,UAAU;EAMtC,KAAK,MAAM,cAAc,kBACvB,IAAI;GACF,MAAM,SAAS,WAAW,QAAQ,SAAS,QAAQ;GAEnD,IAAI,UAAU,OAAO,OAAO,UAAU,YACpC,OAAY,OAAO,UAAmB;IACpC,OAAO,MACL,6BAA6B,WAAW,QAAQ,WAChD,EAAE,MAAM,CACV;GACF,CAAC;EAEL,SAAS,OAAO;GACd,OAAO,MACL,6BAA6B,WAAW,QAAQ,WAChD,EAAE,MAAM,CACV;EACF;CAEJ;AACF;;;;;;;AAQA,eAAsB,kBACpB,UAA8B,CAAC,GACT;CAEtB,MAAM,WAAW,QAAQ,MAAM,QAAQ;CAEvC,IAAI,CAAC,UACH,MAAM,IAAI,MAAM,+CAA+C;CAIjE,IAAI;CACJ,IAAI,OAAO,aAAa,UACtB,KAAK,MAAM,YAAY,QAAQ;MAC1B,IAAI,WAAW,UAEpB,KAAK;MACA,IAAI,UAAU,YAAY,SAAS,UAExC,KAAK,MAAM,YAAY;EACrB,MAAM,SAAS;EACf,KAAK,SAAS;CAChB,CAAC;MAED,MAAM,IAAI,MAAM,gDAAgD;CAGlE,MAAM,MAAM,IAAI,YAAY,EAAE;CAC9B,MAAM,IAAI,WAAW;CAErB,OAAO;AACT"}
|
|
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 +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;AAC5D,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;
|
|
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;AAC5D,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;AAiErB;;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;IAOlB;;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;;OAEG;WACU,WAAW,CAAC,EAAE,EAAE,iBAAiB,GAAG,OAAO,CAAC,OAAO,CAAC;CAQlE"}
|
|
@@ -26,6 +26,23 @@ function pushTenantPredicate(conditions, params, scope, placeholder) {
|
|
|
26
26
|
return false;
|
|
27
27
|
}
|
|
28
28
|
/**
|
|
29
|
+
* Count the rows a cleanup predicate selects, then delete them (#2375).
|
|
30
|
+
*
|
|
31
|
+
* Counting first gives a usable figure on every adapter — `rowCount` is not
|
|
32
|
+
* reliably populated across the engines SMRT supports — and is what lets
|
|
33
|
+
* `dryRun` preview the very same predicate the real cleanup runs. The two
|
|
34
|
+
* statements are not transactional, so the figure is approximate under
|
|
35
|
+
* concurrent writers.
|
|
36
|
+
*/
|
|
37
|
+
async function deleteOrCount(db, conditions, params, dryRun) {
|
|
38
|
+
const where = conditions.join(" AND ");
|
|
39
|
+
const countResult = await db.query(`SELECT COUNT(*) AS total FROM _smrt_dispatch WHERE ${where}`, ...params);
|
|
40
|
+
const total = Number(countResult.rows?.[0]?.total ?? 0);
|
|
41
|
+
if (!Number.isFinite(total) || total <= 0) return 0;
|
|
42
|
+
if (!dryRun) await db.query(`DELETE FROM _smrt_dispatch WHERE ${where}`, ...params);
|
|
43
|
+
return total;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
29
46
|
* Storage operations for dispatches in _smrt_dispatch table
|
|
30
47
|
*/
|
|
31
48
|
var DispatchCollection = class {
|
|
@@ -260,8 +277,7 @@ var DispatchCollection = class {
|
|
|
260
277
|
const conditions = ["status = 'completed'", "processed_at < $1"];
|
|
261
278
|
const params = [cutoff.toISOString()];
|
|
262
279
|
pushTenantPredicate(conditions, params, tenantScope, "$2");
|
|
263
|
-
|
|
264
|
-
result.completedDeleted = rowCount || 0;
|
|
280
|
+
result.completedDeleted = await deleteOrCount(db, conditions, params, options.dryRun ?? false);
|
|
265
281
|
}
|
|
266
282
|
if (options.failedOlderThanDays) {
|
|
267
283
|
const cutoff = /* @__PURE__ */ new Date();
|
|
@@ -269,8 +285,7 @@ var DispatchCollection = class {
|
|
|
269
285
|
const conditions = ["status = 'failed'", "updated_at < $1"];
|
|
270
286
|
const params = [cutoff.toISOString()];
|
|
271
287
|
pushTenantPredicate(conditions, params, tenantScope, "$2");
|
|
272
|
-
|
|
273
|
-
result.failedDeleted = rowCount || 0;
|
|
288
|
+
result.failedDeleted = await deleteOrCount(db, conditions, params, options.dryRun ?? false);
|
|
274
289
|
}
|
|
275
290
|
return result;
|
|
276
291
|
}
|