@cosmicdrift/kumiko-framework 0.199.1 → 0.200.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/package.json +3 -3
- package/src/engine/entity-handlers.ts +45 -20
- package/src/engine/feature-ast/__tests__/fixtures/cross-file-name-const/constants.ts +1 -0
- package/src/engine/feature-ast/__tests__/fixtures/cross-file-name-const/feature.ts +8 -0
- package/src/engine/feature-ast/__tests__/parse.test.ts +32 -1
- package/src/engine/feature-ast/extractors/events.ts +13 -12
- package/src/engine/feature-ast/extractors/handlers.ts +5 -4
- package/src/engine/feature-ast/extractors/hooks.ts +4 -4
- package/src/engine/feature-ast/extractors/jobs-routes.ts +5 -4
- package/src/engine/feature-ast/extractors/round2.ts +10 -8
- package/src/engine/feature-ast/extractors/round3.ts +9 -8
- package/src/engine/feature-ast/extractors/round5.ts +16 -14
- package/src/engine/feature-ast/extractors/shared.ts +57 -1
- package/src/pipeline/__tests__/ctx-systemdb.integration.test.ts +33 -16
- package/src/pipeline/cascade-handler.ts +6 -2
- package/src/pipeline/dispatch-shared.ts +27 -2
- package/src/pipeline/dispatch-write.ts +13 -4
- package/src/pipeline/projections-runner.ts +9 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.200.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -186,7 +186,7 @@
|
|
|
186
186
|
"./package.json": "./package.json"
|
|
187
187
|
},
|
|
188
188
|
"dependencies": {
|
|
189
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
189
|
+
"@cosmicdrift/kumiko-types": "0.200.0",
|
|
190
190
|
"bullmq": "^5.76.7",
|
|
191
191
|
"bun-types": "^1.3.13",
|
|
192
192
|
"hono": "^4.13.1",
|
|
@@ -202,7 +202,7 @@
|
|
|
202
202
|
"zod": "^4.4.3"
|
|
203
203
|
},
|
|
204
204
|
"devDependencies": {
|
|
205
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
205
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.200.0",
|
|
206
206
|
"bun-types": "^1.3.13",
|
|
207
207
|
"pino-pretty": "^13.1.3"
|
|
208
208
|
},
|
|
@@ -176,6 +176,19 @@ export function defineEntityWriteHandler(
|
|
|
176
176
|
const table = buildEntityTable(entityName, entity);
|
|
177
177
|
const executor = createEventStoreExecutor(table, entity, { entityName });
|
|
178
178
|
|
|
179
|
+
// r.systemScope() features (tenant, config, secrets, ...) hand their
|
|
180
|
+
// entity-convention handlers a fail-closed ctx.db — this generic layer
|
|
181
|
+
// has no per-call business reason to give assertTenantMatch, so it
|
|
182
|
+
// acknowledges cross-tenant access on the feature's behalf. This is
|
|
183
|
+
// behavior-preserving: ctx.db was already an unfiltered "system"-mode
|
|
184
|
+
// TenantDb for these handlers before the ctx.db cutover.
|
|
185
|
+
const dbFor = (ctx: HandlerContext): TenantDb =>
|
|
186
|
+
ctx.systemDb
|
|
187
|
+
? ctx.systemDb.acknowledgeCrossTenant(
|
|
188
|
+
`entity convention handler for r.systemScope() feature (${name})`,
|
|
189
|
+
)
|
|
190
|
+
: ctx.db;
|
|
191
|
+
|
|
179
192
|
let schema: ZodType;
|
|
180
193
|
let handler: WriteHandlerDef["handler"];
|
|
181
194
|
|
|
@@ -184,7 +197,7 @@ export function defineEntityWriteHandler(
|
|
|
184
197
|
schema = buildInsertSchema(entity);
|
|
185
198
|
handler = async (event, ctx) => {
|
|
186
199
|
const { runPreSave } = ctx;
|
|
187
|
-
return executor.create(event.payload as DbRow, event.user, ctx
|
|
200
|
+
return executor.create(event.payload as DbRow, event.user, dbFor(ctx), {
|
|
188
201
|
preSave:
|
|
189
202
|
runPreSave &&
|
|
190
203
|
((changes, previous, isNew) => runPreSave(event.type, changes, previous, isNew)),
|
|
@@ -205,7 +218,7 @@ export function defineEntityWriteHandler(
|
|
|
205
218
|
// KEK-rotation, the user-data-rights #494 backfill) don't go through
|
|
206
219
|
// this handler and keep today's always-re-encrypt behavior, which
|
|
207
220
|
// they rely on to intentionally force a fresh event/ciphertext.
|
|
208
|
-
return executor.update(event.payload as UpdatePayload, event.user, ctx
|
|
221
|
+
return executor.update(event.payload as UpdatePayload, event.user, dbFor(ctx), {
|
|
209
222
|
skipUnchanged: true,
|
|
210
223
|
preSave:
|
|
211
224
|
runPreSave &&
|
|
@@ -216,12 +229,12 @@ export function defineEntityWriteHandler(
|
|
|
216
229
|
case "delete":
|
|
217
230
|
schema = idSchema;
|
|
218
231
|
handler = async (event, ctx) =>
|
|
219
|
-
executor.delete(event.payload as IdPayload, event.user, ctx
|
|
232
|
+
executor.delete(event.payload as IdPayload, event.user, dbFor(ctx)); // @cast-boundary engine-payload
|
|
220
233
|
break;
|
|
221
234
|
case "restore":
|
|
222
235
|
schema = idSchema;
|
|
223
236
|
handler = async (event, ctx) =>
|
|
224
|
-
executor.restore(event.payload as IdPayload, event.user, ctx
|
|
237
|
+
executor.restore(event.payload as IdPayload, event.user, dbFor(ctx)); // @cast-boundary engine-payload
|
|
225
238
|
break;
|
|
226
239
|
default:
|
|
227
240
|
assertUnreachable(verb, "write verb");
|
|
@@ -271,24 +284,36 @@ export function defineEntityQueryHandler(
|
|
|
271
284
|
let schema: ZodType;
|
|
272
285
|
let handler: QueryHandlerDef["handler"];
|
|
273
286
|
|
|
274
|
-
// Tier 2.7e
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
//
|
|
278
|
-
//
|
|
279
|
-
// Wrapper zu nutzen).
|
|
287
|
+
// Tier 2.7e server-eagerload: when the entity has reference fields, the
|
|
288
|
+
// handler resolves the UUIDs against the referenced entities after the
|
|
289
|
+
// main query. The `_refs` property lands on every row; the renderer-side
|
|
290
|
+
// useReferenceLookup stays as a fallback (for apps that write custom
|
|
291
|
+
// handlers by hand without this wrapper).
|
|
280
292
|
const hasRefFields = collectReferenceFields(entity).length > 0;
|
|
281
293
|
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
//
|
|
285
|
-
//
|
|
286
|
-
//
|
|
287
|
-
// .
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
|
|
291
|
-
|
|
294
|
+
// Preference order:
|
|
295
|
+
// 1. ctx.systemDb — the feature declared r.systemScope() (whole-feature
|
|
296
|
+
// cutover, dispatch-shared.ts), so ctx.db is fail-closed. This is
|
|
297
|
+
// behavior-preserving: ctx.db was already an unfiltered "system"-mode
|
|
298
|
+
// TenantDb for these handlers before that cutover.
|
|
299
|
+
// 2. options.crossTenant — this ONE handler reads across every tenant
|
|
300
|
+
// (e.g. a SystemAdmin-only operator inspector) without making the
|
|
301
|
+
// whole feature r.systemScope() — that would drop tenant isolation
|
|
302
|
+
// from every OTHER handler the feature registers too. The executor's
|
|
303
|
+
// list()/detail() only add a tenant filter when db.mode === "tenant"
|
|
304
|
+
// (see event-store-executor.ts), so handing them a "system"-mode
|
|
305
|
+
// TenantDb built from the same raw connection is enough to skip it —
|
|
306
|
+
// access-control (who may call this handler at all) is unaffected,
|
|
307
|
+
// still gated by `options.access`.
|
|
308
|
+
// 3. plain ctx.db — the common case, tenant-filtered.
|
|
309
|
+
const dbFor = (ctx: HandlerContext): TenantDb => {
|
|
310
|
+
if (ctx.systemDb) {
|
|
311
|
+
return ctx.systemDb.acknowledgeCrossTenant(
|
|
312
|
+
`entity convention handler for r.systemScope() feature (${entityName}:${verb})`,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
return options?.crossTenant ? createTenantDb(ctx.db.raw, ctx.db.tenantId, "system") : ctx.db;
|
|
316
|
+
};
|
|
292
317
|
|
|
293
318
|
switch (verb) {
|
|
294
319
|
case "list":
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const EXT_AUDIT = "audit" as const;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { EXT_AUDIT } from "./constants";
|
|
2
|
+
|
|
3
|
+
// biome-ignore lint/suspicious/noExplicitAny: structural parser test fixture, never executed or type-checked at runtime
|
|
4
|
+
declare function defineFeature(name: string, setup: (r: any) => void): void;
|
|
5
|
+
|
|
6
|
+
defineFeature("cross-file-name-const", (r) => {
|
|
7
|
+
r.extendsRegistrar(EXT_AUDIT, {});
|
|
8
|
+
});
|
|
@@ -473,7 +473,7 @@ defineFeature("f", (r) => {
|
|
|
473
473
|
expect(result.errors[0]?.methodName).toBe("entity");
|
|
474
474
|
});
|
|
475
475
|
|
|
476
|
-
test("
|
|
476
|
+
test("resolves the name when it's an identifier initialized to a string literal", () => {
|
|
477
477
|
const result = parseInline(`
|
|
478
478
|
const ENTITY = "task";
|
|
479
479
|
defineFeature("f", (r) => {
|
|
@@ -481,6 +481,22 @@ defineFeature("f", (r) => {
|
|
|
481
481
|
});
|
|
482
482
|
`);
|
|
483
483
|
|
|
484
|
+
expect(result.errors).toEqual([]);
|
|
485
|
+
expect(result.patterns[0]).toMatchObject({
|
|
486
|
+
kind: "entity",
|
|
487
|
+
entityName: "task",
|
|
488
|
+
});
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
test("emits a ParseError when the name identifier does not resolve to a string literal", () => {
|
|
492
|
+
const result = parseInline(`
|
|
493
|
+
const ENTITY = computeEntityName();
|
|
494
|
+
defineFeature("f", (r) => {
|
|
495
|
+
r.entity(ENTITY, { fields: {} });
|
|
496
|
+
});
|
|
497
|
+
`);
|
|
498
|
+
|
|
499
|
+
expect(result.patterns).toEqual([]);
|
|
484
500
|
expect(result.errors[0]?.methodName).toBe("entity");
|
|
485
501
|
});
|
|
486
502
|
|
|
@@ -2366,3 +2382,18 @@ describe("cross-file registrar-wrapper resolution against a real filesystem Proj
|
|
|
2366
2382
|
expect(navPattern?.source.file).not.toBe(fixture);
|
|
2367
2383
|
});
|
|
2368
2384
|
});
|
|
2385
|
+
|
|
2386
|
+
// #1746 — registrar-call name args authored as an imported constant
|
|
2387
|
+
// (`r.useExtension(EXT_TENANT_DATA, ...)`) instead of a string literal.
|
|
2388
|
+
// This is the dominant real-world style across the framework's own
|
|
2389
|
+
// bundled-features (see extension-names.ts), and previously ParseErrored
|
|
2390
|
+
// on every such call.
|
|
2391
|
+
describe("cross-file imported-constant name resolution against a real filesystem Project (#1746)", () => {
|
|
2392
|
+
const fixture = resolve(__dirname, "fixtures/cross-file-name-const/feature.ts");
|
|
2393
|
+
const result = parseFeatureFile(fixture);
|
|
2394
|
+
|
|
2395
|
+
test("resolves the identifier to the imported const's string value", () => {
|
|
2396
|
+
expect(result.errors).toEqual([]);
|
|
2397
|
+
expect(result.patterns).toMatchObject([{ kind: "extendsRegistrar", extensionName: "audit" }]);
|
|
2398
|
+
});
|
|
2399
|
+
});
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
findFunctionLiteral,
|
|
10
10
|
ok,
|
|
11
11
|
readDataLiteralNode,
|
|
12
|
+
readNameLiteral,
|
|
12
13
|
readNameOrRef,
|
|
13
14
|
readPropertyKey,
|
|
14
15
|
} from "./shared";
|
|
@@ -146,12 +147,12 @@ export function extractDefineEvent(
|
|
|
146
147
|
});
|
|
147
148
|
}
|
|
148
149
|
|
|
149
|
-
const
|
|
150
|
-
if (
|
|
150
|
+
const eventName = readNameLiteral(first);
|
|
151
|
+
if (eventName === undefined) {
|
|
151
152
|
return fail(
|
|
152
153
|
"defineEvent",
|
|
153
154
|
sourceLocationFromNode(call, sourceFile),
|
|
154
|
-
"first argument must be a string literal event name (or use the object form)",
|
|
155
|
+
"first argument must be a string literal event name, or an identifier resolving to one (or use the object form)",
|
|
155
156
|
);
|
|
156
157
|
}
|
|
157
158
|
const schemaArg = args[1];
|
|
@@ -186,7 +187,7 @@ export function extractDefineEvent(
|
|
|
186
187
|
return ok({
|
|
187
188
|
kind: "defineEvent",
|
|
188
189
|
source: sourceLocationFromNode(call, sourceFile),
|
|
189
|
-
eventName
|
|
190
|
+
eventName,
|
|
190
191
|
schemaSource: sourceLocationFromNode(schemaArg, sourceFile),
|
|
191
192
|
...(version !== undefined && { version }),
|
|
192
193
|
...(migrations !== undefined && { migrations }),
|
|
@@ -207,31 +208,32 @@ export function extractNotification(
|
|
|
207
208
|
);
|
|
208
209
|
}
|
|
209
210
|
|
|
210
|
-
let
|
|
211
|
+
let notificationName: string | undefined;
|
|
211
212
|
let defObj: ReturnType<typeof first.asKind<SyntaxKind.ObjectLiteralExpression>>;
|
|
212
213
|
|
|
213
214
|
const firstObj = first.asKind(SyntaxKind.ObjectLiteralExpression);
|
|
214
215
|
if (firstObj && args.length === 1) {
|
|
215
|
-
|
|
216
|
+
const nameInit = firstObj
|
|
216
217
|
.getProperty("name")
|
|
217
218
|
?.asKind(SyntaxKind.PropertyAssignment)
|
|
218
219
|
?.getInitializer()
|
|
219
220
|
?.asKind(SyntaxKind.StringLiteral);
|
|
220
|
-
if (!
|
|
221
|
+
if (!nameInit) {
|
|
221
222
|
return fail(
|
|
222
223
|
"notification",
|
|
223
224
|
sourceLocationFromNode(call, sourceFile),
|
|
224
225
|
"object form requires a string-literal `name` property",
|
|
225
226
|
);
|
|
226
227
|
}
|
|
228
|
+
notificationName = nameInit.getLiteralValue();
|
|
227
229
|
defObj = firstObj;
|
|
228
230
|
} else {
|
|
229
|
-
|
|
230
|
-
if (
|
|
231
|
+
notificationName = readNameLiteral(first);
|
|
232
|
+
if (notificationName === undefined) {
|
|
231
233
|
return fail(
|
|
232
234
|
"notification",
|
|
233
235
|
sourceLocationFromNode(call, sourceFile),
|
|
234
|
-
"first argument must be a string literal notification name (or use the object form)",
|
|
236
|
+
"first argument must be a string literal notification name, or an identifier resolving to one (or use the object form)",
|
|
235
237
|
);
|
|
236
238
|
}
|
|
237
239
|
defObj = args[1]?.asKind(SyntaxKind.ObjectLiteralExpression);
|
|
@@ -243,7 +245,6 @@ export function extractNotification(
|
|
|
243
245
|
);
|
|
244
246
|
}
|
|
245
247
|
}
|
|
246
|
-
const nameArg = nameLiteral;
|
|
247
248
|
const triggerObj = defObj
|
|
248
249
|
.getProperty("trigger")
|
|
249
250
|
?.asKind(SyntaxKind.PropertyAssignment)
|
|
@@ -313,7 +314,7 @@ export function extractNotification(
|
|
|
313
314
|
return ok({
|
|
314
315
|
kind: "notification",
|
|
315
316
|
source: sourceLocationFromNode(call, sourceFile),
|
|
316
|
-
notificationName
|
|
317
|
+
notificationName,
|
|
317
318
|
trigger: { on: onName },
|
|
318
319
|
recipientBody: sourceLocationFromNode(recipientFn, sourceFile),
|
|
319
320
|
dataBody: sourceLocationFromNode(dataFn, sourceFile),
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
ok,
|
|
15
15
|
readBooleanProperty,
|
|
16
16
|
readDataLiteralNode,
|
|
17
|
+
readNameLiteral,
|
|
17
18
|
} from "./shared";
|
|
18
19
|
|
|
19
20
|
export type ParsedHandlerCall = {
|
|
@@ -134,12 +135,12 @@ export function parseHandlerCall(
|
|
|
134
135
|
if (args.length === 1 && isRawRefSentinel(readDataLiteralNode(first))) {
|
|
135
136
|
return ok({ source: sourceLocationFromNode(call, sourceFile) });
|
|
136
137
|
}
|
|
137
|
-
const
|
|
138
|
-
if (
|
|
138
|
+
const handlerName = readNameLiteral(first);
|
|
139
|
+
if (handlerName === undefined) {
|
|
139
140
|
return fail(
|
|
140
141
|
methodName,
|
|
141
142
|
sourceLocationFromNode(call, sourceFile),
|
|
142
|
-
"first argument must be a string literal handler name (or use the object form)",
|
|
143
|
+
"first argument must be a string literal handler name, or an identifier resolving to one (or use the object form)",
|
|
143
144
|
);
|
|
144
145
|
}
|
|
145
146
|
const schemaArg = args[1];
|
|
@@ -178,7 +179,7 @@ export function parseHandlerCall(
|
|
|
178
179
|
}
|
|
179
180
|
return ok({
|
|
180
181
|
source: sourceLocationFromNode(call, sourceFile),
|
|
181
|
-
handlerName
|
|
182
|
+
handlerName,
|
|
182
183
|
schemaSource: sourceLocationFromNode(schemaArg, sourceFile),
|
|
183
184
|
handlerBody: sourceLocationFromNode(fn, sourceFile),
|
|
184
185
|
...(access !== undefined && { access }),
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
isPlainObject,
|
|
13
13
|
ok,
|
|
14
14
|
readDataLiteralNode,
|
|
15
|
+
readNameLiteral,
|
|
15
16
|
readNameOrRef,
|
|
16
17
|
readNameOrRefOrList,
|
|
17
18
|
} from "./shared";
|
|
@@ -155,15 +156,14 @@ export function extractHook(
|
|
|
155
156
|
});
|
|
156
157
|
}
|
|
157
158
|
|
|
158
|
-
const
|
|
159
|
-
if (
|
|
159
|
+
const hookType = readNameLiteral(first);
|
|
160
|
+
if (hookType === undefined) {
|
|
160
161
|
return fail(
|
|
161
162
|
"hook",
|
|
162
163
|
sourceLocationFromNode(call, sourceFile),
|
|
163
|
-
"first argument must be a string literal hook type (or use the object form)",
|
|
164
|
+
"first argument must be a string literal hook type, or an identifier resolving to one (or use the object form)",
|
|
164
165
|
);
|
|
165
166
|
}
|
|
166
|
-
const hookType = typeArg.getLiteralValue();
|
|
167
167
|
if (!isHookType(hookType)) {
|
|
168
168
|
return fail(
|
|
169
169
|
"hook",
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
ok,
|
|
13
13
|
readBooleanProperty,
|
|
14
14
|
readDataLiteralNode,
|
|
15
|
+
readNameLiteral,
|
|
15
16
|
readPropertyKey,
|
|
16
17
|
} from "./shared";
|
|
17
18
|
|
|
@@ -97,12 +98,12 @@ export function extractJob(
|
|
|
97
98
|
});
|
|
98
99
|
}
|
|
99
100
|
|
|
100
|
-
const
|
|
101
|
-
if (
|
|
101
|
+
const jobName = readNameLiteral(first);
|
|
102
|
+
if (jobName === undefined) {
|
|
102
103
|
return fail(
|
|
103
104
|
"job",
|
|
104
105
|
sourceLocationFromNode(call, sourceFile),
|
|
105
|
-
"first argument must be a string literal job name (or use the object form)",
|
|
106
|
+
"first argument must be a string literal job name, or an identifier resolving to one (or use the object form)",
|
|
106
107
|
);
|
|
107
108
|
}
|
|
108
109
|
const optionsArg = args[1];
|
|
@@ -140,7 +141,7 @@ export function extractJob(
|
|
|
140
141
|
return ok({
|
|
141
142
|
kind: "job",
|
|
142
143
|
source: sourceLocationFromNode(call, sourceFile),
|
|
143
|
-
jobName
|
|
144
|
+
jobName,
|
|
144
145
|
options: options as Omit<JobDefinition, "name" | "handler">,
|
|
145
146
|
handlerBody: sourceLocationFromNode(fn, sourceFile),
|
|
146
147
|
});
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
isPlainObject,
|
|
13
13
|
ok,
|
|
14
14
|
readDataLiteralNode,
|
|
15
|
+
readNameLiteral,
|
|
15
16
|
readNameOrRef,
|
|
16
17
|
} from "./shared";
|
|
17
18
|
|
|
@@ -60,12 +61,12 @@ export function extractEntity(
|
|
|
60
61
|
});
|
|
61
62
|
}
|
|
62
63
|
|
|
63
|
-
const
|
|
64
|
-
if (
|
|
64
|
+
const entityName = readNameLiteral(first);
|
|
65
|
+
if (entityName === undefined) {
|
|
65
66
|
return fail(
|
|
66
67
|
"entity",
|
|
67
68
|
sourceLocationFromNode(call, sourceFile),
|
|
68
|
-
"first argument must be a string literal name (or use the object form)",
|
|
69
|
+
"first argument must be a string literal name, or an identifier resolving to one (or use the object form)",
|
|
69
70
|
);
|
|
70
71
|
}
|
|
71
72
|
const defArg = args[1];
|
|
@@ -87,7 +88,7 @@ export function extractEntity(
|
|
|
87
88
|
return ok({
|
|
88
89
|
kind: "entity",
|
|
89
90
|
source: sourceLocationFromNode(call, sourceFile),
|
|
90
|
-
entityName
|
|
91
|
+
entityName,
|
|
91
92
|
definition: definition as EntityDefinition,
|
|
92
93
|
});
|
|
93
94
|
}
|
|
@@ -165,12 +166,13 @@ export function extractRelation(
|
|
|
165
166
|
'first argument must be a string literal or an inline { name: "..." } object (or use the object form)',
|
|
166
167
|
);
|
|
167
168
|
}
|
|
168
|
-
const
|
|
169
|
-
|
|
169
|
+
const relationNameArg = args[1];
|
|
170
|
+
const relationName = relationNameArg && readNameLiteral(relationNameArg);
|
|
171
|
+
if (!relationName) {
|
|
170
172
|
return fail(
|
|
171
173
|
"relation",
|
|
172
174
|
sourceLocationFromNode(call, sourceFile),
|
|
173
|
-
"second argument must be a string literal relation name",
|
|
175
|
+
"second argument must be a string literal relation name, or an identifier resolving to one",
|
|
174
176
|
);
|
|
175
177
|
}
|
|
176
178
|
const defArg = args[2];
|
|
@@ -193,7 +195,7 @@ export function extractRelation(
|
|
|
193
195
|
kind: "relation",
|
|
194
196
|
source: sourceLocationFromNode(call, sourceFile),
|
|
195
197
|
entityName,
|
|
196
|
-
relationName
|
|
198
|
+
relationName,
|
|
197
199
|
definition: definition as RelationDefinition,
|
|
198
200
|
});
|
|
199
201
|
}
|
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
isPlainObject,
|
|
21
21
|
ok,
|
|
22
22
|
readDataLiteralNode,
|
|
23
|
+
readNameLiteral,
|
|
23
24
|
readNameOrRef,
|
|
24
25
|
} from "./shared";
|
|
25
26
|
|
|
@@ -68,12 +69,12 @@ export function readNamedOptions(
|
|
|
68
69
|
return { kind: "ok", name: nameInit.getLiteralValue(), options: optionsWithoutName };
|
|
69
70
|
}
|
|
70
71
|
|
|
71
|
-
const
|
|
72
|
-
if (
|
|
72
|
+
const name = readNameLiteral(first);
|
|
73
|
+
if (name === undefined) {
|
|
73
74
|
return fail(
|
|
74
75
|
methodName,
|
|
75
76
|
sourceLocationFromNode(call, sourceFile),
|
|
76
|
-
"first argument must be a string literal name (or use the object form)",
|
|
77
|
+
"first argument must be a string literal name, or an identifier resolving to one (or use the object form)",
|
|
77
78
|
);
|
|
78
79
|
}
|
|
79
80
|
const optionsArg = args[1];
|
|
@@ -92,7 +93,7 @@ export function readNamedOptions(
|
|
|
92
93
|
"options could not be read as a plain object",
|
|
93
94
|
);
|
|
94
95
|
}
|
|
95
|
-
return { kind: "ok", name
|
|
96
|
+
return { kind: "ok", name, options };
|
|
96
97
|
}
|
|
97
98
|
|
|
98
99
|
export function extractConfig(
|
|
@@ -424,12 +425,12 @@ export function extractUseExtension(
|
|
|
424
425
|
});
|
|
425
426
|
}
|
|
426
427
|
|
|
427
|
-
const
|
|
428
|
-
if (
|
|
428
|
+
const extensionName = readNameLiteral(first);
|
|
429
|
+
if (extensionName === undefined) {
|
|
429
430
|
return fail(
|
|
430
431
|
"useExtension",
|
|
431
432
|
sourceLocationFromNode(call, sourceFile),
|
|
432
|
-
"first argument must be a string literal extension name (or use the object form)",
|
|
433
|
+
"first argument must be a string literal extension name, or an identifier resolving to one (or use the object form)",
|
|
433
434
|
);
|
|
434
435
|
}
|
|
435
436
|
const entityRefArg = args[1];
|
|
@@ -464,7 +465,7 @@ export function extractUseExtension(
|
|
|
464
465
|
return ok({
|
|
465
466
|
kind: "useExtension",
|
|
466
467
|
source: sourceLocationFromNode(call, sourceFile),
|
|
467
|
-
extensionName
|
|
468
|
+
extensionName,
|
|
468
469
|
entityName,
|
|
469
470
|
...(options !== undefined && { options }),
|
|
470
471
|
});
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import type { CallExpression, SourceFile } from "ts-morph";
|
|
2
|
-
import { SyntaxKind } from "ts-morph";
|
|
3
2
|
import type {
|
|
4
3
|
EnvSchemaPattern,
|
|
5
4
|
ExposesApiPattern,
|
|
@@ -7,7 +6,7 @@ import type {
|
|
|
7
6
|
UsesApiPattern,
|
|
8
7
|
} from "../patterns";
|
|
9
8
|
import { sourceLocationFromNode } from "../source-location";
|
|
10
|
-
import { type ExtractOutput, fail, ok } from "./shared";
|
|
9
|
+
import { type ExtractOutput, fail, ok, readNameLiteral } from "./shared";
|
|
11
10
|
|
|
12
11
|
export function extractEnvSchema(
|
|
13
12
|
call: CallExpression,
|
|
@@ -33,12 +32,13 @@ export function extractExtendsRegistrar(
|
|
|
33
32
|
sourceFile: SourceFile,
|
|
34
33
|
): ExtractOutput<ExtendsRegistrarPattern> {
|
|
35
34
|
const args = call.getArguments();
|
|
36
|
-
const
|
|
37
|
-
|
|
35
|
+
const first = args[0];
|
|
36
|
+
const extensionName = first && readNameLiteral(first);
|
|
37
|
+
if (!extensionName) {
|
|
38
38
|
return fail(
|
|
39
39
|
"extendsRegistrar",
|
|
40
40
|
sourceLocationFromNode(call, sourceFile),
|
|
41
|
-
"first argument must be a string literal extension name",
|
|
41
|
+
"first argument must be a string literal extension name, or an identifier resolving to one",
|
|
42
42
|
);
|
|
43
43
|
}
|
|
44
44
|
const defArg = args[1];
|
|
@@ -52,7 +52,7 @@ export function extractExtendsRegistrar(
|
|
|
52
52
|
return ok({
|
|
53
53
|
kind: "extendsRegistrar",
|
|
54
54
|
source: sourceLocationFromNode(call, sourceFile),
|
|
55
|
-
extensionName
|
|
55
|
+
extensionName,
|
|
56
56
|
defBody: sourceLocationFromNode(defArg, sourceFile),
|
|
57
57
|
});
|
|
58
58
|
}
|
|
@@ -61,18 +61,19 @@ export function extractUsesApi(
|
|
|
61
61
|
call: CallExpression,
|
|
62
62
|
sourceFile: SourceFile,
|
|
63
63
|
): ExtractOutput<UsesApiPattern> {
|
|
64
|
-
const arg = call.getArguments()[0]
|
|
65
|
-
|
|
64
|
+
const arg = call.getArguments()[0];
|
|
65
|
+
const apiName = arg && readNameLiteral(arg);
|
|
66
|
+
if (!apiName) {
|
|
66
67
|
return fail(
|
|
67
68
|
"usesApi",
|
|
68
69
|
sourceLocationFromNode(call, sourceFile),
|
|
69
|
-
'expected a single string-literal API name (e.g. "sessions.revokeAllForUser")',
|
|
70
|
+
'expected a single string-literal API name (e.g. "sessions.revokeAllForUser"), or an identifier resolving to one',
|
|
70
71
|
);
|
|
71
72
|
}
|
|
72
73
|
return ok({
|
|
73
74
|
kind: "usesApi",
|
|
74
75
|
source: sourceLocationFromNode(call, sourceFile),
|
|
75
|
-
apiName
|
|
76
|
+
apiName,
|
|
76
77
|
});
|
|
77
78
|
}
|
|
78
79
|
|
|
@@ -80,18 +81,19 @@ export function extractExposesApi(
|
|
|
80
81
|
call: CallExpression,
|
|
81
82
|
sourceFile: SourceFile,
|
|
82
83
|
): ExtractOutput<ExposesApiPattern> {
|
|
83
|
-
const arg = call.getArguments()[0]
|
|
84
|
-
|
|
84
|
+
const arg = call.getArguments()[0];
|
|
85
|
+
const apiName = arg && readNameLiteral(arg);
|
|
86
|
+
if (!apiName) {
|
|
85
87
|
return fail(
|
|
86
88
|
"exposesApi",
|
|
87
89
|
sourceLocationFromNode(call, sourceFile),
|
|
88
|
-
'expected a single string-literal API name (e.g. "sessions.revokeAllForUser")',
|
|
90
|
+
'expected a single string-literal API name (e.g. "sessions.revokeAllForUser"), or an identifier resolving to one',
|
|
89
91
|
);
|
|
90
92
|
}
|
|
91
93
|
return ok({
|
|
92
94
|
kind: "exposesApi",
|
|
93
95
|
source: sourceLocationFromNode(call, sourceFile),
|
|
94
|
-
apiName
|
|
96
|
+
apiName,
|
|
95
97
|
});
|
|
96
98
|
}
|
|
97
99
|
|
|
@@ -175,9 +175,65 @@ export function readPropertyKey(propAssign: import("ts-morph").PropertyAssignmen
|
|
|
175
175
|
return propAssign.getName();
|
|
176
176
|
}
|
|
177
177
|
|
|
178
|
-
|
|
178
|
+
function unwrapLiteralInitializer(node: Node): string | undefined {
|
|
179
|
+
const literal =
|
|
180
|
+
node.asKind(SyntaxKind.StringLiteral) ?? node.asKind(SyntaxKind.NoSubstitutionTemplateLiteral);
|
|
181
|
+
if (literal) return literal.getLiteralValue();
|
|
182
|
+
const asExpr = node.asKind(SyntaxKind.AsExpression);
|
|
183
|
+
if (asExpr) return unwrapLiteralInitializer(asExpr.getExpression());
|
|
184
|
+
const satisfiesExpr = node.asKind(SyntaxKind.SatisfiesExpression);
|
|
185
|
+
if (satisfiesExpr) return unwrapLiteralInitializer(satisfiesExpr.getExpression());
|
|
186
|
+
const paren = node.asKind(SyntaxKind.ParenthesizedExpression);
|
|
187
|
+
if (paren) return unwrapLiteralInitializer(paren.getExpression());
|
|
188
|
+
return undefined;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Resolves a bare Identifier to the string value of its declaration's
|
|
193
|
+
* initializer (`export const EXT_TENANT_DATA = "tenant-data" as const`),
|
|
194
|
+
* following imports via ts-morph's definition lookup — works across files
|
|
195
|
+
* and packages against a real-filesystem Project (see #1008 precedent in
|
|
196
|
+
* parse.ts). Only descends into VariableDeclaration initializers; a
|
|
197
|
+
* function/class/type definition or an unresolvable import (external
|
|
198
|
+
* package, ambient declaration) yields undefined, never a throw.
|
|
199
|
+
*/
|
|
200
|
+
function resolveIdentifierToStringLiteral(identifier: Node): string | undefined {
|
|
201
|
+
const id = identifier.asKind(SyntaxKind.Identifier);
|
|
202
|
+
if (!id) return undefined;
|
|
203
|
+
let defs: readonly Node[];
|
|
204
|
+
try {
|
|
205
|
+
defs = id.getDefinitionNodes();
|
|
206
|
+
} catch {
|
|
207
|
+
return undefined;
|
|
208
|
+
}
|
|
209
|
+
for (const def of defs) {
|
|
210
|
+
const init = def.asKind(SyntaxKind.VariableDeclaration)?.getInitializer();
|
|
211
|
+
if (!init) continue;
|
|
212
|
+
const value = unwrapLiteralInitializer(init);
|
|
213
|
+
if (value !== undefined) return value;
|
|
214
|
+
}
|
|
215
|
+
return undefined;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* A node's string value when it's a string literal, or when it's a bare
|
|
220
|
+
* Identifier that resolves to one via a `const X = "..."` declaration
|
|
221
|
+
* (same-file or imported) — the pattern used throughout the framework's
|
|
222
|
+
* own bundled-features for registrar-call names (`EXT_TENANT_DATA`,
|
|
223
|
+
* `TENANT_SECRET_READ_EVENT`, ...) instead of repeating string literals.
|
|
224
|
+
* undefined for anything unresolvable (factory call, member access,
|
|
225
|
+
* external/ambient identifier) — callers keep their existing ParseError
|
|
226
|
+
* fallback, no crash.
|
|
227
|
+
*/
|
|
228
|
+
export function readNameLiteral(node: Node): string | undefined {
|
|
179
229
|
const literal = node.asKind(SyntaxKind.StringLiteral);
|
|
180
230
|
if (literal) return literal.getLiteralValue();
|
|
231
|
+
return resolveIdentifierToStringLiteral(node);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function readNameOrRef(node: Node): string | undefined {
|
|
235
|
+
const literal = readNameLiteral(node);
|
|
236
|
+
if (literal !== undefined) return literal;
|
|
181
237
|
const obj = readDataLiteralNode(node);
|
|
182
238
|
if (isPlainObject(obj) && typeof obj["name"] === "string") return obj["name"];
|
|
183
239
|
return undefined;
|
|
@@ -13,13 +13,25 @@ const systemScopedFeature = defineFeature("ctxsystemdb-system", (r) => {
|
|
|
13
13
|
"check",
|
|
14
14
|
z.object({}),
|
|
15
15
|
async (query, ctx) => {
|
|
16
|
-
if (!ctx.systemDb)
|
|
17
|
-
|
|
18
|
-
// is
|
|
19
|
-
//
|
|
20
|
-
//
|
|
16
|
+
if (!ctx.systemDb)
|
|
17
|
+
return { present: false as const, tenantIdMatches: false, dbThrows: false };
|
|
18
|
+
// ctx.db is fail-closed for r.systemScope() handlers — dispatch-shared.ts
|
|
19
|
+
// builds `as HandlerContext`, so a mis-wired property wouldn't be caught
|
|
20
|
+
// by tsc. Prove it at runtime: assertTenantMatch must hand back a
|
|
21
|
+
// working, correctly-scoped TenantDb, and touching ctx.db itself must
|
|
22
|
+
// throw instead of silently returning an unfiltered db.
|
|
21
23
|
const checked = ctx.systemDb.assertTenantMatch(query.user.tenantId);
|
|
22
|
-
|
|
24
|
+
let dbThrows = false;
|
|
25
|
+
try {
|
|
26
|
+
void ctx.db.tenantId;
|
|
27
|
+
} catch {
|
|
28
|
+
dbThrows = true;
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
present: true as const,
|
|
32
|
+
tenantIdMatches: checked.tenantId === query.user.tenantId,
|
|
33
|
+
dbThrows,
|
|
34
|
+
};
|
|
23
35
|
},
|
|
24
36
|
{ access: { roles: ["Admin"] } },
|
|
25
37
|
);
|
|
@@ -29,7 +41,10 @@ const tenantScopedFeature = defineFeature("ctxsystemdb-tenant", (r) => {
|
|
|
29
41
|
r.queryHandler(
|
|
30
42
|
"check",
|
|
31
43
|
z.object({}),
|
|
32
|
-
async (
|
|
44
|
+
async (query, ctx) => ({
|
|
45
|
+
present: ctx.systemDb !== undefined,
|
|
46
|
+
dbWorks: ctx.db.tenantId === query.user.tenantId,
|
|
47
|
+
}),
|
|
33
48
|
{ access: { roles: ["Admin"] } },
|
|
34
49
|
);
|
|
35
50
|
});
|
|
@@ -46,22 +61,24 @@ afterAll(async () => {
|
|
|
46
61
|
});
|
|
47
62
|
|
|
48
63
|
describe("ctx.systemDb", () => {
|
|
49
|
-
test("is present
|
|
50
|
-
const result = await stack.http.queryOk<{
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
);
|
|
64
|
+
test("is present for r.systemScope() handlers; ctx.db is fail-closed there", async () => {
|
|
65
|
+
const result = await stack.http.queryOk<{
|
|
66
|
+
present: boolean;
|
|
67
|
+
tenantIdMatches: boolean;
|
|
68
|
+
dbThrows: boolean;
|
|
69
|
+
}>("ctxsystemdb-system:query:check", {}, admin);
|
|
55
70
|
expect(result.present).toBe(true);
|
|
56
|
-
expect(result.
|
|
71
|
+
expect(result.tenantIdMatches).toBe(true);
|
|
72
|
+
expect(result.dbThrows).toBe(true);
|
|
57
73
|
});
|
|
58
74
|
|
|
59
|
-
test("is absent for non-system-scoped handlers", async () => {
|
|
60
|
-
const result = await stack.http.queryOk<{ present: boolean }>(
|
|
75
|
+
test("is absent for non-system-scoped handlers; ctx.db works normally there", async () => {
|
|
76
|
+
const result = await stack.http.queryOk<{ present: boolean; dbWorks: boolean }>(
|
|
61
77
|
"ctxsystemdb-tenant:query:check",
|
|
62
78
|
{},
|
|
63
79
|
admin,
|
|
64
80
|
);
|
|
65
81
|
expect(result.present).toBe(false);
|
|
82
|
+
expect(result.dbWorks).toBe(true);
|
|
66
83
|
});
|
|
67
84
|
});
|
|
@@ -17,13 +17,17 @@ export function createCascadeDeleteHook(
|
|
|
17
17
|
priority: SystemHookPriorities.cascadeDelete,
|
|
18
18
|
fn: async (payload, ctx) => {
|
|
19
19
|
const entityName = payload.entityName;
|
|
20
|
-
|
|
20
|
+
const db = ctx.systemDb
|
|
21
|
+
? ctx.systemDb.acknowledgeCrossTenant(
|
|
22
|
+
`cascade delete for r.systemScope() write handler (${entityName ?? "unknown"})`,
|
|
23
|
+
)
|
|
24
|
+
: ctx.db;
|
|
25
|
+
if (!entityName || !db) {
|
|
21
26
|
ctx.log?.debug(
|
|
22
27
|
`cascadeDelete: skipping — ${!entityName ? "no entityName" : "no db"} on payload ${payload.id}`,
|
|
23
28
|
);
|
|
24
29
|
return;
|
|
25
30
|
}
|
|
26
|
-
const db = ctx.db;
|
|
27
31
|
|
|
28
32
|
// Cascade applies to outgoing hasMany / manyToMany relations only —
|
|
29
33
|
// the parent side of the link is where `onDelete` lives (see
|
|
@@ -3,7 +3,7 @@ import type { SseBroker } from "../api/sse-broker";
|
|
|
3
3
|
import type { DbConnection, DbRunner, DbTx } from "../db/connection";
|
|
4
4
|
import { runInSavepoint, selectMany } from "../db/query";
|
|
5
5
|
import type { buildEntityTable } from "../db/table-builder";
|
|
6
|
-
import { createTenantDb, createUncheckedSystemDb } from "../db/tenant-db";
|
|
6
|
+
import { createTenantDb, createUncheckedSystemDb, type TenantDb } from "../db/tenant-db";
|
|
7
7
|
import { createDerivativesContext } from "../derivatives/derivatives-context";
|
|
8
8
|
import type { defineTransitions } from "../engine/state-machine";
|
|
9
9
|
import type { EffectiveFeaturesResolver } from "../engine/tier-resolver-extension";
|
|
@@ -155,6 +155,27 @@ async function appendDomainEvent(
|
|
|
155
155
|
);
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
+
// r.systemScope() handlers must go through ctx.systemDb's guarded methods
|
|
159
|
+
// (assertTenantMatch / acknowledgeCrossTenant) instead of plain ctx.db —
|
|
160
|
+
// "system" mode has no tenant filter at all, so silent ctx.db use there is a
|
|
161
|
+
// cross-tenant leak. HandlerContext.db has no way to become optional per
|
|
162
|
+
// handler (isSystem is a runtime-only registry lookup, not a type-level
|
|
163
|
+
// discriminant), so instead of omitting the key we hand back a Proxy that
|
|
164
|
+
// throws on first touch, naming the handler and pointing at ctx.systemDb.
|
|
165
|
+
function createSystemScopedDbGuard(handlerType: string): TenantDb {
|
|
166
|
+
return new Proxy({} as TenantDb, {
|
|
167
|
+
get(_target, prop) {
|
|
168
|
+
throw new InternalError({
|
|
169
|
+
message:
|
|
170
|
+
`Handler "${handlerType}" is r.systemScope()'d — ctx.db is unavailable ` +
|
|
171
|
+
`(it would be unfiltered across every tenant). Use ` +
|
|
172
|
+
`ctx.systemDb.assertTenantMatch(...) or ctx.systemDb.acknowledgeCrossTenant(...) ` +
|
|
173
|
+
`instead (attempted to read "${String(prop)}").`,
|
|
174
|
+
});
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
158
179
|
export async function buildHandlerContext(
|
|
159
180
|
ctx: DispatchContext,
|
|
160
181
|
type: string,
|
|
@@ -184,6 +205,10 @@ export async function buildHandlerContext(
|
|
|
184
205
|
// the rest of the chain instead of burning DB-CPU for results no one reads.
|
|
185
206
|
const db = dbSource ? buildTenantScopedDb(dbSource, reqCtx?.signal) : undefined;
|
|
186
207
|
const systemDb = isSystem && db ? createUncheckedSystemDb(db) : undefined;
|
|
208
|
+
// Exposed as ctx.db below — the internal `db` above stays the real,
|
|
209
|
+
// working TenantDb for this function's own use (config/derivatives
|
|
210
|
+
// accessors, systemDb construction).
|
|
211
|
+
const exposedDb = isSystem && db ? createSystemScopedDbGuard(type) : db;
|
|
187
212
|
// Unbound pool, tenant-scoped like `db` but never tx-bound — writes
|
|
188
213
|
// through it survive a rollback of the handler's own transaction. No
|
|
189
214
|
// AbortSignal here: a client disconnect must not abort a durability write
|
|
@@ -572,7 +597,7 @@ export async function buildHandlerContext(
|
|
|
572
597
|
return {
|
|
573
598
|
...context,
|
|
574
599
|
registry,
|
|
575
|
-
db,
|
|
600
|
+
db: exposedDb,
|
|
576
601
|
dbOutsideTransaction,
|
|
577
602
|
...(systemDb && { systemDb }),
|
|
578
603
|
log,
|
|
@@ -350,10 +350,19 @@ async function executeWriteInner(
|
|
|
350
350
|
|
|
351
351
|
const handlerContext = await buildHandlerContext(ctx, type, user, tx, afterCommitHooks);
|
|
352
352
|
|
|
353
|
-
// Auto transition guard: if entity has transitions and handler doesn't skip it
|
|
353
|
+
// Auto transition guard: if entity has transitions and handler doesn't skip it.
|
|
354
|
+
// Reads via the guard's own db handle — for r.systemScope() handlers
|
|
355
|
+
// handlerContext.db is fail-closed, so this reaches for
|
|
356
|
+
// systemDb.acknowledgeCrossTenant like any other framework-internal
|
|
357
|
+
// mechanism that touches a system-scoped handler's data.
|
|
358
|
+
const transitionGuardDb = handlerContext.systemDb
|
|
359
|
+
? handlerContext.systemDb.acknowledgeCrossTenant(
|
|
360
|
+
`auto transition guard for r.systemScope() write handler (${type})`,
|
|
361
|
+
)
|
|
362
|
+
: handlerContext.db;
|
|
354
363
|
if (entityName && !handler.unsafeSkipTransitionGuard) {
|
|
355
364
|
const entity = registry.getEntity(entityName);
|
|
356
|
-
if (entity?.transitions &&
|
|
365
|
+
if (entity?.transitions && transitionGuardDb) {
|
|
357
366
|
const parsedData = parsed.data as DbRow; // @cast-boundary engine-payload
|
|
358
367
|
const changes = (parsedData["changes"] as DbRow) ?? parsedData; // @cast-boundary engine-payload
|
|
359
368
|
const id = (parsedData["id"] as number) ?? undefined; // @cast-boundary engine-payload
|
|
@@ -373,8 +382,8 @@ async function executeWriteInner(
|
|
|
373
382
|
// active (tests without a DB connection).
|
|
374
383
|
const tableName = asEntityTableMeta(table)?.tableName ?? "";
|
|
375
384
|
const rows = tx
|
|
376
|
-
? await selectRowForUpdateById(
|
|
377
|
-
: await selectMany(
|
|
385
|
+
? await selectRowForUpdateById(transitionGuardDb, tableName, id)
|
|
386
|
+
: await selectMany(transitionGuardDb, table, { id });
|
|
378
387
|
const row = rows[0];
|
|
379
388
|
|
|
380
389
|
if (!row) continue;
|
|
@@ -26,7 +26,15 @@ import type { StoredEvent } from "../event-store";
|
|
|
26
26
|
export async function runProjections(result: LifecycleResult, ctx: HandlerContext): Promise<void> {
|
|
27
27
|
// skip: hand-crafted result with no event — nothing to project
|
|
28
28
|
if (!result.event) return;
|
|
29
|
-
|
|
29
|
+
// r.systemScope() handlers: ctx.db is fail-closed — custom projections are
|
|
30
|
+
// framework-wired, not per-handler opt-in, so this reaches for systemDb
|
|
31
|
+
// itself rather than pushing the concern onto every r.projection() author.
|
|
32
|
+
const tx = ctx.systemDb
|
|
33
|
+
? ctx.systemDb.acknowledgeCrossTenant(
|
|
34
|
+
`inline projection apply for r.systemScope() write (${result.event.aggregateType})`,
|
|
35
|
+
).raw
|
|
36
|
+
: ctx.db.raw;
|
|
37
|
+
await runProjectionsForEvent(result.event, ctx.registry, tx);
|
|
30
38
|
}
|
|
31
39
|
|
|
32
40
|
// Fire every projection whose source matches the event's aggregate type AND
|