@cosmicdrift/kumiko-framework 0.146.1 → 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.
- package/package.json +2 -2
- package/src/api/auth-routes.ts +4 -5
- package/src/engine/__tests__/codemod-pipeline.test.ts +13 -11
- package/src/engine/__tests__/pipeline-engine.test.ts +9 -9
- package/src/engine/__tests__/pipeline-handler.integration.test.ts +18 -18
- package/src/engine/__tests__/pipeline-observability.integration.test.ts +2 -2
- package/src/engine/__tests__/pipeline-performance.integration.test.ts +3 -3
- package/src/engine/__tests__/pipeline-sub-pipelines.test.ts +9 -9
- package/src/engine/__tests__/validate-projection-allowlist.test.ts +15 -15
- package/src/engine/codemod/pipeline-codemod.ts +5 -5
- package/src/engine/define-handler.ts +2 -2
- package/src/engine/define-workflow.ts +1 -1
- package/src/engine/index.ts +7 -5
- package/src/engine/membership-roles.ts +13 -0
- package/src/engine/pipeline.ts +6 -11
- package/src/engine/run-pipeline.ts +1 -1
- package/src/engine/types/handlers.ts +1 -1
- package/src/engine/types/step.ts +4 -4
- package/src/engine/validate-projection-allowlist.ts +1 -1
- package/src/pipeline/dispatch-batch.ts +187 -0
- package/src/pipeline/dispatch-query.ts +165 -0
- package/src/pipeline/dispatch-shared.ts +685 -0
- package/src/pipeline/dispatch-write.ts +451 -0
- package/src/pipeline/dispatcher.ts +30 -1374
|
@@ -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
|
+
}
|