@cosmicdrift/kumiko-framework 0.199.2 → 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/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":
|
|
@@ -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
|