@cosmicdrift/kumiko-framework 0.146.2 → 0.146.3

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.
@@ -0,0 +1,451 @@
1
+ import type { DbRow, DbTx } from "../db/connection";
2
+ import { selectRowForUpdateById } from "../db/queries/entity-read";
3
+ import { asEntityTableMeta, selectMany } from "../db/query";
4
+ import { buildEntityTable, toSnakeCase } from "../db/table-builder";
5
+ import { hasAccess } from "../engine/access";
6
+ import { checkWriteFieldRoles } from "../engine/field-access";
7
+ import { defineTransitions, guardTransition } from "../engine/state-machine";
8
+ import type { HandlerContext, SessionUser, WriteResult } from "../engine/types";
9
+ import { HookPhases } from "../engine/types";
10
+ import { runValidation } from "../engine/validation";
11
+ import {
12
+ AccessDeniedError,
13
+ FrameworkReasons,
14
+ InternalError,
15
+ isKumikoError,
16
+ NotFoundError,
17
+ ValidationError,
18
+ validationErrorFromZod,
19
+ writeFailure,
20
+ } from "../errors";
21
+ import { assertNoSecretLeak } from "../secrets";
22
+ import type { DispatchContext } from "./dispatch-shared";
23
+ import {
24
+ buildHandlerContext,
25
+ checkFeatureEnabled,
26
+ enforceRateLimit,
27
+ runHandlerInstrumented,
28
+ } from "./dispatch-shared";
29
+ import {
30
+ type AfterCommitHook,
31
+ describeShape,
32
+ extractNestedSpecs,
33
+ isLifecycleResult,
34
+ isWriteResultShape,
35
+ prefixValidationPath,
36
+ wrapToKumiko,
37
+ } from "./dispatcher-utils";
38
+ import { runProjections } from "./projections-runner";
39
+
40
+ function getTable(
41
+ ctx: DispatchContext,
42
+ entityName: string,
43
+ ): ReturnType<typeof buildEntityTable> | undefined {
44
+ const { registry, tableCache } = ctx;
45
+ if (tableCache.has(entityName)) return tableCache.get(entityName);
46
+ const entity = registry.getEntity(entityName);
47
+ if (!entity) return undefined;
48
+ const table = buildEntityTable(entityName, entity, {
49
+ relations: registry.getRelations(entityName),
50
+ });
51
+ tableCache.set(entityName, table);
52
+ return table;
53
+ }
54
+
55
+ function getTransitions(
56
+ ctx: DispatchContext,
57
+ args: {
58
+ entityName: string;
59
+ fieldName: string;
60
+ map: Record<string, readonly string[]>;
61
+ },
62
+ ): ReturnType<typeof defineTransitions> {
63
+ const { transitionCache } = ctx;
64
+ // Scope by entity — `fieldName` alone collides across entities (e.g. both
65
+ // `invoice.status` and `driverOrder.status` exist with different maps),
66
+ // which would apply the wrong transition rules to whichever entity arrives
67
+ // second.
68
+ const key = `${args.entityName}:${args.fieldName}`;
69
+ const cached = transitionCache.get(key);
70
+ if (cached) return cached;
71
+ const transitions = defineTransitions(args.map);
72
+ transitionCache.set(key, transitions);
73
+ return transitions;
74
+ }
75
+
76
+ // Runs lifecycle hooks for a handler result. inTransaction hooks fire NOW
77
+ // (they see the tx via ctx.db when batch/write opens a transaction).
78
+ // afterCommit hooks are queued into `afterCommitHooks` for the caller to
79
+ // flush after commit.
80
+ async function runLifecycle(
81
+ ctx: DispatchContext,
82
+ type: string,
83
+ data: unknown,
84
+ handlerContext: HandlerContext,
85
+ afterCommitHooks: AfterCommitHook[],
86
+ ): Promise<void> {
87
+ const { lifecycle } = ctx;
88
+ if (!lifecycle) {
89
+ handlerContext.log?.debug(`runLifecycle: skipping ${type} — no lifecycle pipeline`);
90
+ return;
91
+ }
92
+ if (!isLifecycleResult(data)) {
93
+ handlerContext.log?.debug(`runLifecycle: skipping ${type} — result is not a lifecycle kind`);
94
+ return;
95
+ }
96
+ const result = data;
97
+
98
+ // Projections run FIRST, inside the tx, before any user postSave/postDelete
99
+ // hooks. If a projection apply() throws, the whole tx rolls back — the
100
+ // event and the auto-projection row go with it. Running before the hooks
101
+ // keeps projection state consistent with what the hooks observe.
102
+ await runProjections(result, handlerContext);
103
+
104
+ if (result.kind === "save") {
105
+ await lifecycle.runPostSave(type, result, handlerContext, HookPhases.inTransaction);
106
+ afterCommitHooks.push(() =>
107
+ lifecycle.runPostSave(type, result, handlerContext, HookPhases.afterCommit),
108
+ );
109
+ } else if (result.kind === "delete") {
110
+ await lifecycle.runPreDelete(type, result, handlerContext);
111
+ await lifecycle.runPostDelete(type, result, handlerContext, HookPhases.inTransaction);
112
+ afterCommitHooks.push(() =>
113
+ lifecycle.runPostDelete(type, result, handlerContext, HookPhases.afterCommit),
114
+ );
115
+ }
116
+ }
117
+
118
+ // Shared write pipeline: validates, executes handler, runs lifecycle + side effects.
119
+ // Used by runBatch (which opens a transaction and flushes afterCommitHooks on commit).
120
+ //
121
+ // Contract:
122
+ // - `tx` is the active Drizzle transaction handle (or undefined for the no-DB
123
+ // fallback path used by tests without a Postgres connection).
124
+ // - `afterCommitHooks` collects deferred side-effects that must only fire
125
+ // after the transaction commits. The caller flushes them on commit, drops
126
+ // them on rollback. executeWrite never fires them directly.
127
+ export async function executeWrite(
128
+ ctx: DispatchContext,
129
+ type: string,
130
+ payload: unknown,
131
+ user: SessionUser,
132
+ tx: DbTx | undefined,
133
+ afterCommitHooks: AfterCommitHook[],
134
+ ): Promise<WriteResult> {
135
+ return runHandlerInstrumented(ctx, type, "write", user, () =>
136
+ executeWriteInner(ctx, type, payload, user, tx, afterCommitHooks),
137
+ );
138
+ }
139
+
140
+ // Nested-write orchestration (v1: depth=1, create-only, hasMany-only).
141
+ //
142
+ // When a parent `:create` handler's payload carries values under keys
143
+ // declared as `hasMany` relations with `nestedWrite: true`, those values
144
+ // are expanded into child writes: parent first (so its new id exists),
145
+ // then each nested entry as a separate `<target>:create` write with the
146
+ // foreign key set by the framework — never taken from the client. All of
147
+ // this runs inside the caller's transaction, so a child failure rolls the
148
+ // parent (and any earlier children) back together.
149
+ //
150
+ // This wrapper is what runBatch calls, not executeWrite. Single writes
151
+ // (`dispatcher.write`) flow through runBatch as batch-of-one, so they get
152
+ // nested-expansion too for free. A batch with N heterogeneous commands
153
+ // can each independently carry nested-children — all still one TX.
154
+ export async function executeNestedWrite(
155
+ ctx: DispatchContext,
156
+ type: string,
157
+ payload: unknown,
158
+ user: SessionUser,
159
+ tx: DbTx | undefined,
160
+ afterCommitHooks: AfterCommitHook[],
161
+ ): Promise<WriteResult> {
162
+ const { registry } = ctx;
163
+ const nested = extractNestedSpecs(type, payload, registry);
164
+ if (!nested) return executeWrite(ctx, type, payload, user, tx, afterCommitHooks);
165
+
166
+ // Pre-flight client-shape checks. Merge non-array issues (collected up
167
+ // front by extractNestedSpecs) with fk-injection issues into one error
168
+ // so the client sees every problem in a single round-trip.
169
+ //
170
+ // Security rail: the client MUST NOT supply the foreign key on nested
171
+ // items. The framework binds it from the parent's new id. Silent-overwrite
172
+ // would mask an attempt to attach children to a different parent — fail
173
+ // loud with a ValidationError carrying a client-mappable path.
174
+ const issues: Array<{ path: string; code: string; i18nKey: string }> = [...nested.typeIssues];
175
+ for (const spec of nested.specs) {
176
+ for (let i = 0; i < spec.items.length; i++) {
177
+ const item = spec.items[i];
178
+ if (item && typeof item === "object" && spec.foreignKey in item) {
179
+ issues.push({
180
+ path: `${spec.key}.${i}.${spec.foreignKey}`,
181
+ code: "unexpected_field",
182
+ i18nKey: "errors.validation.unexpected_field",
183
+ });
184
+ }
185
+ }
186
+ }
187
+ if (issues.length > 0) {
188
+ return writeFailure(new ValidationError({ fields: issues }));
189
+ }
190
+
191
+ const parentResult = await executeWrite(
192
+ ctx,
193
+ type,
194
+ nested.cleanPayload,
195
+ user,
196
+ tx,
197
+ afterCommitHooks,
198
+ );
199
+ if (!parentResult.isSuccess) return parentResult;
200
+
201
+ // Handlers built on the CRUD executor return a SaveContext wrapper —
202
+ // `{ kind: "save", id, data: <row>, changes, previous, event, ... }`.
203
+ // The wrapper is load-bearing for batch-level hooks downstream (see
204
+ // flushBatchHooks), so we mutate in place: nested children land on the
205
+ // inner `data` (which mirrors the entity shape the client expects) while
206
+ // the wrapper keeps its SaveContext semantics intact for the lifecycle
207
+ // pipeline. For handlers that return a bare row (no wrapper), children
208
+ // land directly on that object.
209
+ //
210
+ // Hook-ordering note: per-entity postSave hooks already ran inside the
211
+ // parent's executeWrite call above — they never saw `tasks`, which is
212
+ // the right semantic (postSave gets the entity's own columns, not
213
+ // synthetic relation keys). A future postSaveBatch subscriber that
214
+ // enumerates columns generically WOULD see `tasks`; no such subscriber
215
+ // exists today. If you add one that iterates `Object.keys(save.data)`,
216
+ // filter by `entity.fields` membership to stay correct.
217
+ // handler-Result.data ist generic über alle Entity-Handler; nested-
218
+ // write inspiziert die shape strukturell.
219
+ const parentWrapper = parentResult.data as Record<string, unknown>; // @cast-boundary engine-payload
220
+ const parentRow = (parentWrapper["data"] ?? parentWrapper) as Record<string, unknown>; // @cast-boundary engine-payload
221
+ const parentId = parentRow["id"];
222
+ if (typeof parentId !== "string") {
223
+ return writeFailure(
224
+ new InternalError({
225
+ message: `nested-write: parent handler "${type}" returned no string "id" — cannot attach children`,
226
+ }),
227
+ );
228
+ }
229
+
230
+ for (const spec of nested.specs) {
231
+ const subRows: Record<string, unknown>[] = [];
232
+ for (let i = 0; i < spec.items.length; i++) {
233
+ const rawItem = spec.items[i];
234
+ const itemObj = (rawItem ?? {}) as Record<string, unknown>; // @cast-boundary engine-payload
235
+ const subPayload = { ...itemObj, [spec.foreignKey]: parentId };
236
+ const subResult = await executeWrite(
237
+ ctx,
238
+ spec.subType,
239
+ subPayload,
240
+ user,
241
+ tx,
242
+ afterCommitHooks,
243
+ );
244
+ if (!subResult.isSuccess) {
245
+ return {
246
+ isSuccess: false,
247
+ error: prefixValidationPath(subResult.error, `${spec.key}.${i}`),
248
+ };
249
+ }
250
+ const subWrapper = subResult.data as Record<string, unknown>; // @cast-boundary engine-payload
251
+ const subRow = (subWrapper["data"] ?? subWrapper) as Record<string, unknown>; // @cast-boundary engine-payload
252
+ subRows.push(subRow);
253
+ }
254
+ parentRow[spec.key] = subRows;
255
+ }
256
+
257
+ return parentResult;
258
+ }
259
+
260
+ async function executeWriteInner(
261
+ ctx: DispatchContext,
262
+ type: string,
263
+ payload: unknown,
264
+ user: SessionUser,
265
+ tx: DbTx | undefined,
266
+ afterCommitHooks: AfterCommitHook[],
267
+ ): Promise<WriteResult> {
268
+ const { registry, jobRunner } = ctx;
269
+ const handler = registry.getWriteHandler(type);
270
+ if (!handler) return writeFailure(new NotFoundError("handler", type));
271
+
272
+ // Feature-toggle gate: disabled handlers must short-circuit before any
273
+ // rate-limit/access/validation work — see executeQueryInner comment.
274
+ const disabledErr = await checkFeatureEnabled(ctx, type, user.tenantId);
275
+ if (disabledErr) return writeFailure(disabledErr);
276
+
277
+ // Rate-limit gate before access (same reasoning as in executeQueryInner).
278
+ // Throws RateLimitError; the outer wrapper turns it into a 429
279
+ // WriteFailure via toWriteErrorInfo. Inline-skip when no opt-in —
280
+ // hot path stays zero-cost.
281
+ if (handler.rateLimit !== undefined) {
282
+ try {
283
+ await enforceRateLimit(ctx, handler.rateLimit, type, user);
284
+ } catch (e) {
285
+ if (isKumikoError(e)) return writeFailure(e);
286
+ throw e;
287
+ }
288
+ }
289
+
290
+ // Default-deny: missing access rule is treated as "no one has access".
291
+ // The registry boot-validator refuses to register handlers without one,
292
+ // so in normal boots this branch shouldn't fire — the guard is belt-and-
293
+ // suspenders in case a handler sneaks through (e.g. runtime injection).
294
+ if (!hasAccess(user, handler.access)) {
295
+ return writeFailure(
296
+ new AccessDeniedError({
297
+ message: `access denied for ${type}`,
298
+ details: { handler: type },
299
+ }),
300
+ );
301
+ }
302
+
303
+ const parsed = handler.schema.safeParse(payload);
304
+ if (!parsed.success) {
305
+ return writeFailure(validationErrorFromZod(parsed.error));
306
+ }
307
+
308
+ const hookErrors = runValidation(registry, type, parsed.data as DbRow); // @cast-boundary engine-payload
309
+ if (hookErrors) {
310
+ return writeFailure(
311
+ new ValidationError({
312
+ fields: hookErrors.map((e) => ({
313
+ path: e.field,
314
+ code: e.error,
315
+ i18nKey: `errors.validation.${e.error}`,
316
+ })),
317
+ }),
318
+ );
319
+ }
320
+
321
+ // Field-level write access check
322
+ const entityName = registry.getHandlerEntity(type);
323
+ if (entityName) {
324
+ const entity = registry.getEntity(entityName);
325
+ if (entity) {
326
+ const fieldsToCheck = (parsed.data as DbRow)["changes"] as
327
+ | Record<string, unknown>
328
+ | undefined; // @cast-boundary engine-payload
329
+ const writePayload = fieldsToCheck ?? (parsed.data as DbRow); // @cast-boundary engine-payload
330
+ // Pre-handler check: role-only gate. Ownership-level row-match runs
331
+ // later in the executor where oldRow is loaded — that split lets
332
+ // updates with partial changes still pass the pre-handler check and
333
+ // get their full evaluation at save time.
334
+ const deniedField = checkWriteFieldRoles(entity, writePayload, user);
335
+ if (deniedField) {
336
+ return writeFailure(
337
+ new AccessDeniedError({
338
+ message: `field access denied: ${deniedField}`,
339
+ i18nKey: "errors.access.fieldDenied",
340
+ details: {
341
+ reason: FrameworkReasons.fieldAccessDenied,
342
+ field: deniedField,
343
+ handler: type,
344
+ },
345
+ }),
346
+ );
347
+ }
348
+ }
349
+ }
350
+
351
+ const handlerContext = buildHandlerContext(ctx, type, user, tx, afterCommitHooks);
352
+
353
+ // Auto transition guard: if entity has transitions and handler doesn't skip it
354
+ if (entityName && !handler.unsafeSkipTransitionGuard) {
355
+ const entity = registry.getEntity(entityName);
356
+ if (entity?.transitions && handlerContext.db) {
357
+ const parsedData = parsed.data as DbRow; // @cast-boundary engine-payload
358
+ const changes = (parsedData["changes"] as DbRow) ?? parsedData; // @cast-boundary engine-payload
359
+ const id = (parsedData["id"] as number) ?? undefined; // @cast-boundary engine-payload
360
+
361
+ for (const [fieldName, transitionMap] of Object.entries(entity.transitions)) {
362
+ const newValue = changes[fieldName] as string | undefined; // @cast-boundary engine-bridge
363
+ if (!newValue || !id) continue;
364
+
365
+ const table = getTable(ctx, entityName);
366
+ if (!table) continue;
367
+
368
+ // SELECT FOR UPDATE inside the surrounding transaction — locks the
369
+ // row so a concurrent handler can't mutate `status` between our
370
+ // guard check and the handler's UPDATE. Without this lock the guard
371
+ // can false-pass; optimistic locking would catch it later, but with
372
+ // a less specific error. Falls back to a plain SELECT if no tx is
373
+ // active (tests without a DB connection).
374
+ const tableName = asEntityTableMeta(table)?.tableName ?? "";
375
+ const rows = tx
376
+ ? await selectRowForUpdateById(handlerContext.db, tableName, id)
377
+ : await selectMany(handlerContext.db, table, { id });
378
+ const row = rows[0];
379
+
380
+ if (!row) continue;
381
+ // Skip guard for soft-deleted rows — they shouldn't be transitioning
382
+ // at all; a handler that wants to move a deleted row should use
383
+ // unsafeSkipTransitionGuard or restore first.
384
+ const rowAsRow = row as DbRow; // @cast-boundary engine-payload
385
+ const isDeleted = rowAsRow["isDeleted"] ?? rowAsRow["is_deleted"];
386
+ if (entity.softDelete && isDeleted === true) {
387
+ continue;
388
+ }
389
+ const currentValue =
390
+ ((row as DbRow)[fieldName] as string | undefined) ??
391
+ ((row as DbRow)[toSnakeCase(fieldName)] as string); // @cast-boundary engine-bridge
392
+ guardTransition(
393
+ getTransitions(ctx, { entityName, fieldName, map: transitionMap }),
394
+ currentValue,
395
+ newValue,
396
+ );
397
+ }
398
+ }
399
+ }
400
+
401
+ // The handler itself plus the lifecycle pipeline run under the same
402
+ // try-wrapper: any KumikoError bubbles up as a typed WriteErrorInfo, any
403
+ // other throw gets wrapped in InternalError so the Prod contract holds
404
+ // ("unexpected throw → 500 with sanitized body"). We intentionally do NOT
405
+ // catch further out (runBatch still sees these as exceptions via
406
+ // writeFailure, not via a rethrow) so batches roll back naturally.
407
+ let result: WriteResult;
408
+ try {
409
+ result = await handler.handler({ type, payload: parsed.data, user }, handlerContext);
410
+ } catch (e) {
411
+ return writeFailure(wrapToKumiko(e));
412
+ }
413
+
414
+ // Runtime shape-guard. The compile-time type WriteHandlerFn already
415
+ // requires `Promise<WriteResult>`, but custom handlers wired through
416
+ // r.writeHandler(name, schema, fn, opts) sometimes slip through with
417
+ // `Promise<{id: string}>` — TypeScript misses it under structural-
418
+ // widening, the dispatcher then reads .isSuccess on undefined and
419
+ // crashes obscure. Surface a clear actionable message instead.
420
+ if (!isWriteResultShape(result)) {
421
+ return writeFailure(
422
+ new InternalError({
423
+ message:
424
+ `Write handler "${type}" returned an invalid shape. Expected WriteResult ` +
425
+ `({ isSuccess: true, data: ... } or writeFailure(err)), got ${describeShape(result)}. ` +
426
+ `Use defineWriteHandler() or wrap the return as { isSuccess: true as const, data: ... }.`,
427
+ }),
428
+ );
429
+ }
430
+
431
+ if (result.isSuccess) {
432
+ try {
433
+ await runLifecycle(ctx, type, result.data, handlerContext, afterCommitHooks);
434
+ } catch (e) {
435
+ return writeFailure(wrapToKumiko(e));
436
+ }
437
+
438
+ // jobRunner has external side-effects (BullMQ enqueue) — must NOT
439
+ // fire for rolled-back writes. Defer to afterCommit.
440
+ if (jobRunner) {
441
+ const eventData = (parsed.data ?? {}) as DbRow; // @cast-boundary engine-payload
442
+ afterCommitHooks.push(() => jobRunner.handleEvent(type, eventData, user));
443
+ }
444
+ }
445
+
446
+ // Response-guard: block Secret<> leaks in write responses (SaveContext
447
+ // data / previous / changes). Feature code that fed a plaintext through
448
+ // to the return payload fails here instead of hitting the client.
449
+ if (result.isSuccess) assertNoSecretLeak(result.data);
450
+ return result;
451
+ }