@cosmicdrift/kumiko-framework 0.2.3 → 0.4.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.
Files changed (166) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/package.json +124 -39
  3. package/src/__tests__/full-stack.integration.ts +2 -2
  4. package/src/api/auth-routes.ts +5 -5
  5. package/src/api/jwt.ts +2 -2
  6. package/src/api/route-registrars.ts +1 -1
  7. package/src/api/routes.ts +3 -3
  8. package/src/api/server.ts +6 -7
  9. package/src/compliance/profiles.ts +8 -8
  10. package/src/db/assert-exists-in.ts +2 -2
  11. package/src/db/cursor.ts +3 -3
  12. package/src/db/event-store-executor.ts +19 -13
  13. package/src/db/located-timestamp.ts +1 -1
  14. package/src/db/money.ts +12 -2
  15. package/src/db/pg-error.ts +1 -1
  16. package/src/db/row-helpers.ts +1 -1
  17. package/src/db/table-builder.ts +3 -5
  18. package/src/db/tenant-db.ts +9 -9
  19. package/src/engine/__tests__/_pipeline-test-utils.ts +23 -0
  20. package/src/engine/__tests__/build-target.test.ts +135 -0
  21. package/src/engine/__tests__/codemod-pipeline.test.ts +551 -0
  22. package/src/engine/__tests__/entity-handlers.test.ts +3 -3
  23. package/src/engine/__tests__/event-helpers.test.ts +4 -4
  24. package/src/engine/__tests__/pipeline-engine.test.ts +215 -0
  25. package/src/engine/__tests__/pipeline-handler.integration.ts +894 -0
  26. package/src/engine/__tests__/pipeline-observability.integration.ts +142 -0
  27. package/src/engine/__tests__/pipeline-performance.integration.ts +152 -0
  28. package/src/engine/__tests__/pipeline-sub-pipelines.test.ts +288 -0
  29. package/src/engine/__tests__/raw-table.test.ts +2 -2
  30. package/src/engine/__tests__/steps-aggregate-append-event.test.ts +115 -0
  31. package/src/engine/__tests__/steps-aggregate-create.test.ts +92 -0
  32. package/src/engine/__tests__/steps-aggregate-update.test.ts +127 -0
  33. package/src/engine/__tests__/steps-call-feature.test.ts +123 -0
  34. package/src/engine/__tests__/steps-mail-send.test.ts +136 -0
  35. package/src/engine/__tests__/steps-read.test.ts +142 -0
  36. package/src/engine/__tests__/steps-resolver-utils.test.ts +50 -0
  37. package/src/engine/__tests__/steps-unsafe-projection-delete.test.ts +69 -0
  38. package/src/engine/__tests__/steps-unsafe-projection-upsert.test.ts +117 -0
  39. package/src/engine/__tests__/steps-webhook-send.test.ts +135 -0
  40. package/src/engine/__tests__/steps-workflow.test.ts +198 -0
  41. package/src/engine/__tests__/validate-projection-allowlist.test.ts +491 -0
  42. package/src/engine/__tests__/visual-tree-patterns.test.ts +251 -0
  43. package/src/engine/boot-validator/api-ext.ts +77 -0
  44. package/src/engine/boot-validator/config-deps.ts +163 -0
  45. package/src/engine/boot-validator/entity-handler.ts +466 -0
  46. package/src/engine/boot-validator/index.ts +159 -0
  47. package/src/engine/boot-validator/ownership.ts +198 -0
  48. package/src/engine/boot-validator/pii-retention.ts +155 -0
  49. package/src/engine/boot-validator/screens-nav.ts +624 -0
  50. package/src/engine/boot-validator.ts +1 -1804
  51. package/src/engine/build-app-schema.ts +1 -1
  52. package/src/engine/build-target.ts +99 -0
  53. package/src/engine/codemod/index.ts +15 -0
  54. package/src/engine/codemod/pipeline-codemod.ts +641 -0
  55. package/src/engine/config-helpers.ts +9 -19
  56. package/src/engine/constants.ts +1 -1
  57. package/src/engine/define-feature.ts +88 -9
  58. package/src/engine/define-handler.ts +89 -3
  59. package/src/engine/define-roles.ts +2 -2
  60. package/src/engine/define-step.ts +28 -0
  61. package/src/engine/define-workflow.ts +110 -0
  62. package/src/engine/entity-handlers.ts +10 -9
  63. package/src/engine/event-helpers.ts +4 -4
  64. package/src/engine/factories.ts +12 -12
  65. package/src/engine/feature-ast/__tests__/visual-tree-parse.test.ts +184 -0
  66. package/src/engine/feature-ast/extractors/index.ts +74 -0
  67. package/src/engine/feature-ast/extractors/round1.ts +110 -0
  68. package/src/engine/feature-ast/extractors/round2.ts +253 -0
  69. package/src/engine/feature-ast/extractors/round3.ts +471 -0
  70. package/src/engine/feature-ast/extractors/round4.ts +1365 -0
  71. package/src/engine/feature-ast/extractors/round5.ts +72 -0
  72. package/src/engine/feature-ast/extractors/round6.ts +66 -0
  73. package/src/engine/feature-ast/extractors/shared.ts +177 -0
  74. package/src/engine/feature-ast/parse.ts +7 -0
  75. package/src/engine/feature-ast/patch.ts +9 -1
  76. package/src/engine/feature-ast/patcher.ts +10 -3
  77. package/src/engine/feature-ast/patterns.ts +49 -1
  78. package/src/engine/feature-ast/render.ts +17 -1
  79. package/src/engine/index.ts +44 -2
  80. package/src/engine/pattern-library/__tests__/library.test.ts +6 -0
  81. package/src/engine/pattern-library/library.ts +42 -2
  82. package/src/engine/pipeline.ts +88 -0
  83. package/src/engine/projection-helpers.ts +1 -1
  84. package/src/engine/read-claim.ts +1 -1
  85. package/src/engine/registry.ts +30 -2
  86. package/src/engine/resolve-config-or-param.ts +4 -0
  87. package/src/engine/run-pipeline.ts +162 -0
  88. package/src/engine/schema-builder.ts +2 -4
  89. package/src/engine/state-machine.ts +1 -1
  90. package/src/engine/steps/_drizzle-boundary.ts +19 -0
  91. package/src/engine/steps/_duration-utils.ts +33 -0
  92. package/src/engine/steps/_no-return-guard.ts +21 -0
  93. package/src/engine/steps/_resolver-utils.ts +42 -0
  94. package/src/engine/steps/_step-dispatch-constants.ts +38 -0
  95. package/src/engine/steps/aggregate-append-event.ts +56 -0
  96. package/src/engine/steps/aggregate-create.ts +56 -0
  97. package/src/engine/steps/aggregate-update.ts +68 -0
  98. package/src/engine/steps/branch.ts +84 -0
  99. package/src/engine/steps/call-feature.ts +49 -0
  100. package/src/engine/steps/compute.ts +41 -0
  101. package/src/engine/steps/for-each.ts +111 -0
  102. package/src/engine/steps/mail-send.ts +44 -0
  103. package/src/engine/steps/read-find-many.ts +51 -0
  104. package/src/engine/steps/read-find-one.ts +58 -0
  105. package/src/engine/steps/retry.ts +87 -0
  106. package/src/engine/steps/return.ts +34 -0
  107. package/src/engine/steps/unsafe-projection-delete.ts +46 -0
  108. package/src/engine/steps/unsafe-projection-upsert.ts +69 -0
  109. package/src/engine/steps/wait-for-event.ts +71 -0
  110. package/src/engine/steps/wait.ts +69 -0
  111. package/src/engine/steps/webhook-send.ts +71 -0
  112. package/src/engine/system-user.ts +1 -1
  113. package/src/engine/types/feature.ts +93 -1
  114. package/src/engine/types/handlers.ts +18 -10
  115. package/src/engine/types/index.ts +11 -1
  116. package/src/engine/types/step.ts +334 -0
  117. package/src/engine/types/target-ref.ts +21 -0
  118. package/src/engine/types/tree-node.ts +132 -0
  119. package/src/engine/types/workspace.ts +7 -0
  120. package/src/engine/validate-projection-allowlist.ts +161 -0
  121. package/src/event-store/snapshot.ts +1 -1
  122. package/src/event-store/upcaster-dead-letter.ts +1 -1
  123. package/src/event-store/upcaster.ts +1 -1
  124. package/src/files/file-routes.ts +1 -1
  125. package/src/files/types.ts +2 -2
  126. package/src/jobs/job-runner.ts +10 -10
  127. package/src/lifecycle/lifecycle.ts +0 -3
  128. package/src/logging/index.ts +1 -0
  129. package/src/logging/pino-logger.ts +11 -7
  130. package/src/logging/utils.ts +24 -0
  131. package/src/observability/prometheus-meter.ts +7 -5
  132. package/src/pipeline/__tests__/archive-stream.integration.ts +1 -1
  133. package/src/pipeline/__tests__/causation-chain.integration.ts +1 -1
  134. package/src/pipeline/__tests__/domain-events-projections.integration.ts +3 -3
  135. package/src/pipeline/__tests__/event-define-event-strict.integration.ts +4 -4
  136. package/src/pipeline/__tests__/load-aggregate-query.integration.ts +1 -1
  137. package/src/pipeline/__tests__/msp-multi-hop.integration.ts +3 -3
  138. package/src/pipeline/__tests__/msp-rebuild.integration.ts +3 -3
  139. package/src/pipeline/__tests__/multi-stream-projection.integration.ts +2 -2
  140. package/src/pipeline/__tests__/query-projection.integration.ts +5 -5
  141. package/src/pipeline/append-event-core.ts +22 -6
  142. package/src/pipeline/dispatcher-utils.ts +188 -0
  143. package/src/pipeline/dispatcher.ts +63 -283
  144. package/src/pipeline/distributed-lock.ts +1 -1
  145. package/src/pipeline/entity-cache.ts +2 -2
  146. package/src/pipeline/event-consumer-state.ts +0 -13
  147. package/src/pipeline/event-dispatcher.ts +4 -4
  148. package/src/pipeline/index.ts +0 -2
  149. package/src/pipeline/lifecycle-pipeline.ts +6 -12
  150. package/src/pipeline/msp-rebuild.ts +5 -5
  151. package/src/pipeline/multi-stream-apply-context.ts +6 -7
  152. package/src/pipeline/projection-rebuild.ts +2 -2
  153. package/src/pipeline/projection-state.ts +0 -12
  154. package/src/rate-limit/__tests__/resolver.integration.ts +8 -4
  155. package/src/rate-limit/resolver.ts +1 -1
  156. package/src/search/in-memory-adapter.ts +1 -1
  157. package/src/search/meilisearch-adapter.ts +3 -3
  158. package/src/search/types.ts +1 -1
  159. package/src/secrets/leak-guard.ts +2 -2
  160. package/src/stack/request-helper.ts +9 -5
  161. package/src/stack/test-stack.ts +1 -1
  162. package/src/testing/handler-context.ts +4 -4
  163. package/src/testing/http-cookies.ts +1 -1
  164. package/src/time/tz-context.ts +1 -2
  165. package/src/ui-types/index.ts +4 -0
  166. package/src/engine/feature-ast/extractors.ts +0 -2602
@@ -5,7 +5,6 @@ import { buildDrizzleTable } from "../db/table-builder";
5
5
  import { createTenantDb } from "../db/tenant-db";
6
6
  import { hasAccess } from "../engine/access";
7
7
  import { checkWriteFieldRoles, filterReadFields } from "../engine/field-access";
8
- import { parseQn, qn } from "../engine/qualified-name";
9
8
  import { defineTransitions, guardTransition } from "../engine/state-machine";
10
9
  import type { EffectiveFeaturesResolver } from "../engine/tier-resolver-extension";
11
10
  import type {
@@ -17,9 +16,7 @@ import type {
17
16
  DeleteContext,
18
17
  FetchForWritingArgs,
19
18
  HandlerContext,
20
- HandlerRef,
21
19
  JobRunnerRef,
22
- LifecycleResult,
23
20
  Registry,
24
21
  SaveContext,
25
22
  SessionUser,
@@ -27,6 +24,7 @@ import type {
27
24
  } from "../engine/types";
28
25
  import { HookPhases } from "../engine/types";
29
26
  import type { TenantId } from "../engine/types/identifiers";
27
+ import { createFallbackLogger } from "../logging/utils";
30
28
 
31
29
  // Re-export for callers that reach for dispatcher-adjacent types (tests,
32
30
  // HTTP-layer stubs) — dispatch consumes these, grouping the type-surface
@@ -40,7 +38,6 @@ import {
40
38
  FrameworkReasons,
41
39
  InternalError,
42
40
  isKumikoError,
43
- type KumikoError,
44
41
  NotFoundError,
45
42
  reraiseAsKumikoError,
46
43
  toWriteErrorInfo,
@@ -83,225 +80,24 @@ import { createTzContext } from "../time";
83
80
  import { parseJsonSafe } from "../utils/safe-json";
84
81
  import { appendDomainEventCore } from "./append-event-core";
85
82
  import { resolveAuthClaims as runAuthClaimsResolver } from "./auth-claims-resolver";
83
+ import {
84
+ type AfterCommitHook,
85
+ BatchRollback,
86
+ describeShape,
87
+ dispatcherSpanAttributes,
88
+ extractNestedSpecs,
89
+ type HandlerType,
90
+ isFailedWriteResult,
91
+ isLifecycleResult,
92
+ isWriteResultShape,
93
+ prefixValidationPath,
94
+ resolveType,
95
+ wrapToKumiko,
96
+ } from "./dispatcher-utils";
86
97
  import type { IdempotencyGuard } from "./idempotency";
87
98
  import type { LifecycleHooks } from "./lifecycle-pipeline";
88
99
  import { runProjections } from "./projections-runner";
89
100
 
90
- type FailedWriteResult = Extract<WriteResult, { isSuccess: false }>;
91
-
92
- // Write handlers report failure via `WriteResult.isSuccess === false`. Query
93
- // handlers return arbitrary shapes, so `result` is typed as `unknown` here.
94
- function isFailedWriteResult(result: unknown): result is FailedWriteResult {
95
- return (
96
- !!result && typeof result === "object" && "isSuccess" in result && result.isSuccess === false
97
- );
98
- }
99
-
100
- // Handler result is a lifecycle payload when it's an object carrying `kind`
101
- // (save/delete). Query handlers return arbitrary shapes that don't match.
102
- function isLifecycleResult(data: unknown): data is LifecycleResult {
103
- return !!data && typeof data === "object" && "kind" in data;
104
- }
105
-
106
- // Shape-check for write-handler returns. The compile-time type already
107
- // requires WriteResult, but the inline form (r.writeHandler(name, schema,
108
- // fn, opts)) sometimes lets a wrong shape through structural widening —
109
- // the runtime guard below turns the obscure crash that follows into a
110
- // clear, actionable error message.
111
- function isWriteResultShape(result: unknown): boolean {
112
- return (
113
- !!result &&
114
- typeof result === "object" &&
115
- "isSuccess" in result &&
116
- typeof result.isSuccess === "boolean"
117
- );
118
- }
119
-
120
- // Compact, log-safe shape description for the shape-guard error message.
121
- // We don't dump JSON of arbitrary user data — just the keys + type so the
122
- // developer can spot the missing isSuccess at a glance.
123
- function describeShape(result: unknown): string {
124
- if (result === null) return "null";
125
- if (result === undefined) return "undefined";
126
- if (typeof result !== "object") return typeof result;
127
- return `object with keys [${Object.keys(result).slice(0, 6).join(", ")}]`;
128
- }
129
-
130
- // Standard span attributes for a dispatcher call. Feature may be undefined
131
- // for internal handlers that weren't registered via defineFeature.
132
- function dispatcherSpanAttributes(
133
- type: string,
134
- operation: "query" | "write",
135
- user: SessionUser,
136
- feature: string | undefined,
137
- ) {
138
- const attrs: Record<string, string | number | boolean> = {
139
- "kumiko.handler": type,
140
- "kumiko.operation": operation,
141
- "kumiko.user_id": user.id,
142
- "kumiko.tenant_id": user.tenantId,
143
- };
144
- if (feature) attrs["kumiko.feature"] = feature;
145
- return attrs;
146
- }
147
-
148
- // Deferred afterCommit callback — collected during transaction execution,
149
- // fired sequentially once the transaction commits successfully.
150
- type AfterCommitHook = () => Promise<void>;
151
-
152
- // Specification for one nested-write expansion. The parent write's payload
153
- // carries items under `key`; each is dispatched as a separate write against
154
- // `subType`, with the foreign-key column `foreignKey` bound to the parent's
155
- // new id. Built by extractNestedSpecs from the parent payload + registry
156
- // relations. See executeNestedWrite for orchestration.
157
- type NestedSpec = {
158
- readonly key: string;
159
- readonly subType: string;
160
- readonly foreignKey: string;
161
- readonly items: readonly unknown[];
162
- };
163
-
164
- // Field-level issue collected by extractNestedSpecs and surfaced as a
165
- // ValidationError by the caller. Shape matches ValidationFieldIssue so we
166
- // can hand it directly to `new ValidationError({ fields })`.
167
- type NestedTypeIssue = {
168
- readonly path: string;
169
- readonly code: string;
170
- readonly i18nKey: string;
171
- };
172
-
173
- // Separates a parent payload into a "clean" shape (without nested-relation
174
- // keys) plus the list of expansion specs. Returns null when the payload has
175
- // no nested relations to expand — callers short-circuit to the regular write
176
- // path without paying the overhead of nested orchestration.
177
- //
178
- // Expansion only applies to `:create` handlers (v1). For `:update` / `:delete`
179
- // we return null so the parent write runs unchanged. When a future iteration
180
- // adds update/delete-nested, this is the single point to extend.
181
- //
182
- // Sub-writes run through regular executeWrite, NOT recursively through
183
- // executeNestedWrite — deeper nesting (`tasks[0].subtasks`) is out of scope
184
- // for v1. Those keys reach the sub-handler's zod schema and are silently
185
- // stripped by default zod semantics. Documented limitation; a sub-handler
186
- // that wants to reject depth-2 payloads can use `.strict()` on its schema.
187
- function extractNestedSpecs(
188
- parentType: string,
189
- payload: unknown,
190
- registry: Registry,
191
- ): {
192
- cleanPayload: Record<string, unknown>;
193
- specs: readonly NestedSpec[];
194
- typeIssues: readonly NestedTypeIssue[];
195
- } | null {
196
- if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;
197
-
198
- let parsed: ReturnType<typeof parseQn>;
199
- try {
200
- parsed = parseQn(parentType);
201
- } catch {
202
- return null;
203
- }
204
- // v1 scope: only create. Update/delete-nested are explicit future work —
205
- // they'd need different sub-types and id-handling semantics.
206
- if (!parsed.name.endsWith(":create")) return null;
207
-
208
- const entityName = registry.getHandlerEntity(parentType);
209
- if (!entityName) return null;
210
-
211
- const relations = registry.getRelations(entityName);
212
- const source = payload as Record<string, unknown>; // @cast-boundary engine-payload — generic dispatch über alle Entity-Types
213
- const clean: Record<string, unknown> = { ...source };
214
- const specs: NestedSpec[] = [];
215
- const typeIssues: NestedTypeIssue[] = [];
216
-
217
- for (const [relKey, rel] of Object.entries(relations)) {
218
- if (rel.type !== "hasMany" || !rel.nestedWrite) continue;
219
- if (!(relKey in source)) continue;
220
- const value = source[relKey];
221
-
222
- // Non-array under a nested-write key is a client shape error. Silent
223
- // strip (via default zod stripping) would hide it — a client sending
224
- // `tasks: "bogus"` or `tasks: null` has to know the field was ignored,
225
- // or they'll wonder why their data never showed up. Fail loud.
226
- if (!Array.isArray(value)) {
227
- typeIssues.push({
228
- path: relKey,
229
- code: "invalid_type",
230
- i18nKey: "errors.validation.invalid_type",
231
- });
232
- // Still strip from clean payload — we're not letting the parent handler
233
- // see a malformed value either.
234
- delete clean[relKey];
235
- continue;
236
- }
237
-
238
- // Strip the relation key from the clean payload — the parent handler
239
- // only sees columns it actually owns.
240
- delete clean[relKey];
241
-
242
- // Sub-type composition: derive scope + operation from the parent qn,
243
- // swap the entity segment. "feat:write:project:create" → "feat:write:task:create".
244
- // Assumes target entity has a `:create` handler in the SAME feature scope
245
- // as the parent. Cross-feature nested-writes are out of scope for v1;
246
- // when needed, the registry would have to carry a back-pointer from
247
- // entity → defining feature.
248
- const subType = qn(parsed.scope, parsed.type, `${rel.target}:create`);
249
-
250
- specs.push({
251
- key: relKey,
252
- subType,
253
- foreignKey: rel.foreignKey,
254
- items: value,
255
- });
256
- }
257
-
258
- if (specs.length === 0 && typeIssues.length === 0) return null;
259
- return { cleanPayload: clean, specs, typeIssues };
260
- }
261
-
262
- // Prefix ValidationError paths so a failure on a nested sub-write maps back
263
- // to the client-visible field path. Example: sub-write fails on `title` with
264
- // path="title"; this prefixes to "tasks.2.title" so the form-controller in
265
- // the UI can highlight the right sub-line's field.
266
- //
267
- // Non-validation errors pass through unchanged — they carry no field paths.
268
- function prefixValidationPath(info: WriteErrorInfo, prefix: string): WriteErrorInfo {
269
- if (info.code !== "validation_error") return info;
270
- const details = info.details as
271
- | {
272
- fields?: readonly {
273
- path: string;
274
- code: string;
275
- i18nKey: string;
276
- params?: Readonly<Record<string, unknown>>;
277
- }[];
278
- }
279
- | undefined;
280
- const fields = details?.fields;
281
- if (!fields) return info;
282
- return {
283
- ...info,
284
- details: {
285
- ...details,
286
- fields: fields.map((f) => ({ ...f, path: `${prefix}.${f.path}` })),
287
- },
288
- };
289
- }
290
-
291
- // Sentinel thrown inside a Drizzle transaction to force a rollback while
292
- // carrying the command failure context back out. Drizzle rolls back iff the
293
- // transaction callback throws — this class lets us distinguish an expected
294
- // rollback (command returned isSuccess: false) from an unexpected error.
295
- class BatchRollback extends Error {
296
- constructor(
297
- readonly failedIndex: number,
298
- readonly failureError: WriteErrorInfo,
299
- ) {
300
- super(`batch rollback at command ${failedIndex}: ${failureError.code}`);
301
- this.name = "BatchRollback";
302
- }
303
- }
304
-
305
101
  export type BatchCommand = {
306
102
  readonly type: string;
307
103
  readonly payload: unknown;
@@ -342,12 +138,6 @@ export type DispatcherOptions = {
342
138
  effectiveFeatures?: EffectiveFeaturesResolver;
343
139
  };
344
140
 
345
- type HandlerType = string | HandlerRef;
346
-
347
- function resolveType(type: HandlerType): string {
348
- return typeof type === "string" ? type : type.name;
349
- }
350
-
351
141
  export type Dispatcher = {
352
142
  write(
353
143
  type: HandlerType,
@@ -426,7 +216,7 @@ export function createDispatcher(
426
216
  callerFeature: string | undefined,
427
217
  ): Promise<void> {
428
218
  const dbSource: DbConnection | DbTx | undefined =
429
- tx ?? (context.db as DbConnection | undefined);
219
+ tx ?? (context.db as DbConnection | undefined); // @cast-boundary db-operator
430
220
  if (!dbSource) {
431
221
  throw new InternalError({
432
222
  message: `ctx.appendEvent("${args.type}") requires a database connection — none is configured.`,
@@ -456,7 +246,7 @@ export function createDispatcher(
456
246
  // AppContext's `db` union also allows TenantDb (for downstream hook calls),
457
247
  // but at this point we're the root of the pipeline — cast is safe.
458
248
  const dbSource: DbConnection | DbTx | undefined =
459
- tx ?? (context.db as DbConnection | undefined);
249
+ tx ?? (context.db as DbConnection | undefined); // @cast-boundary db-operator
460
250
  const reqCtx = requestContext.get();
461
251
  const db = dbSource
462
252
  ? createTenantDb(
@@ -516,16 +306,15 @@ export function createDispatcher(
516
306
  // Strict + unsafe share the same runtime — only the type-surface
517
307
  // differs. The strict signature is what's exposed to typed callers;
518
308
  // unsafe is the explicit escape-hatch for runtime-pluggable events.
519
- // @cast-boundary engine-bridge — concrete impl conforms to AppendEventFn overload
520
309
  appendEvent: (async (args: AppendEventArgs) => {
521
310
  await appendDomainEvent(args, user, tx, registry.getHandlerFeature(type));
522
- }) as AppendEventFn,
523
- appendEventUnsafe: async (args: AppendEventArgs) => {
311
+ }) as AppendEventFn, // @cast-boundary engine-bridge
312
+ unsafeAppendEvent: async (args: AppendEventArgs) => {
524
313
  await appendDomainEvent(args, user, tx, registry.getHandlerFeature(type));
525
314
  },
526
315
  fetchForWriting: async (args: FetchForWritingArgs): Promise<AggregateStreamHandle> => {
527
316
  const dbSource: DbConnection | DbTx | undefined =
528
- tx ?? (context.db as DbConnection | undefined);
317
+ tx ?? (context.db as DbConnection | undefined); // @cast-boundary db-operator
529
318
  if (!dbSource) {
530
319
  throw new InternalError({
531
320
  message: `ctx.fetchForWriting("${args.aggregateId}") requires a database connection — none is configured.`,
@@ -589,7 +378,7 @@ export function createDispatcher(
589
378
  loadOptions?: { readonly asOf?: Temporal.Instant },
590
379
  ): Promise<readonly StoredEvent[]> => {
591
380
  const dbSource: DbConnection | DbTx | undefined =
592
- tx ?? (context.db as DbConnection | undefined);
381
+ tx ?? (context.db as DbConnection | undefined); // @cast-boundary db-operator
593
382
  if (!dbSource) {
594
383
  throw new InternalError({
595
384
  message: `ctx.loadAggregate("${aggregateId}") requires a database connection — none is configured.`,
@@ -608,7 +397,7 @@ export function createDispatcher(
608
397
  archiveArgs: { readonly aggregateType: string; readonly reason?: string },
609
398
  ): Promise<void> => {
610
399
  const dbSource: DbConnection | DbTx | undefined =
611
- tx ?? (context.db as DbConnection | undefined);
400
+ tx ?? (context.db as DbConnection | undefined); // @cast-boundary db-operator
612
401
  if (!dbSource) {
613
402
  throw new InternalError({
614
403
  message: `ctx.archiveStream("${aggregateId}") requires a database connection — none is configured.`,
@@ -624,7 +413,7 @@ export function createDispatcher(
624
413
  },
625
414
  restoreStream: async (aggregateId: string): Promise<void> => {
626
415
  const dbSource: DbConnection | DbTx | undefined =
627
- tx ?? (context.db as DbConnection | undefined);
416
+ tx ?? (context.db as DbConnection | undefined); // @cast-boundary db-operator
628
417
  if (!dbSource) {
629
418
  throw new InternalError({
630
419
  message: `ctx.restoreStream("${aggregateId}") requires a database connection — none is configured.`,
@@ -634,7 +423,7 @@ export function createDispatcher(
634
423
  },
635
424
  isStreamArchived: async (aggregateId: string): Promise<boolean> => {
636
425
  const dbSource: DbConnection | DbTx | undefined =
637
- tx ?? (context.db as DbConnection | undefined);
426
+ tx ?? (context.db as DbConnection | undefined); // @cast-boundary db-operator
638
427
  if (!dbSource) {
639
428
  throw new InternalError({
640
429
  message: `ctx.isStreamArchived("${aggregateId}") requires a database connection — none is configured.`,
@@ -649,7 +438,7 @@ export function createDispatcher(
649
438
  readonly state: Record<string, unknown>;
650
439
  }): Promise<void> => {
651
440
  const dbSource: DbConnection | DbTx | undefined =
652
- tx ?? (context.db as DbConnection | undefined);
441
+ tx ?? (context.db as DbConnection | undefined); // @cast-boundary db-operator
653
442
  if (!dbSource) {
654
443
  throw new InternalError({
655
444
  message: `ctx.snapshotAggregate("${snapshotArgs.aggregateId}") requires a database connection — none is configured.`,
@@ -669,7 +458,7 @@ export function createDispatcher(
669
458
  initial: TState,
670
459
  ): Promise<LoadAggregateWithSnapshotResult<TState>> => {
671
460
  const dbSource: DbConnection | DbTx | undefined =
672
- tx ?? (context.db as DbConnection | undefined);
461
+ tx ?? (context.db as DbConnection | undefined); // @cast-boundary db-operator
673
462
  if (!dbSource) {
674
463
  throw new InternalError({
675
464
  message: `ctx.loadAggregateWithSnapshot("${aggregateId}") requires a database connection — none is configured.`,
@@ -693,7 +482,7 @@ export function createDispatcher(
693
482
  },
694
483
  queryProjection: async <T = Record<string, unknown>>(
695
484
  qualifiedName: string,
696
- queryOptions?: { readonly allTenants?: boolean },
485
+ queryOptions?: { readonly unsafeAllTenants?: boolean },
697
486
  ): Promise<readonly T[]> => {
698
487
  // queryProjection works against both single-stream and multi-stream
699
488
  // projections. MSPs without a table cannot be queried — those are
@@ -714,7 +503,7 @@ export function createDispatcher(
714
503
  });
715
504
  }
716
505
  const dbSource: DbConnection | DbTx | undefined =
717
- tx ?? (context.db as DbConnection | undefined);
506
+ tx ?? (context.db as DbConnection | undefined); // @cast-boundary db-operator
718
507
  if (!dbSource) {
719
508
  throw new InternalError({
720
509
  message: `ctx.queryProjection("${qualifiedName}") requires a database connection — none is configured.`,
@@ -725,9 +514,9 @@ export function createDispatcher(
725
514
  // opts in. Works with any drizzle-table whose tenant column is named
726
515
  // tenantId on the JS side.
727
516
  // @cast-boundary dynamic-key — drizzle's PgTable columns are schema-dependent
728
- const tenantCol = (projTable as Record<string, AnyColumn | undefined>)["tenantId"];
517
+ const tenantCol = (projTable as Record<string, AnyColumn | undefined>)["tenantId"]; // @cast-boundary dynamic-key
729
518
  let rows: readonly Record<string, unknown>[];
730
- if (tenantCol && !queryOptions?.allTenants) {
519
+ if (tenantCol && !queryOptions?.unsafeAllTenants) {
731
520
  rows = (await dbSource
732
521
  .select()
733
522
  .from(projTable)
@@ -735,8 +524,7 @@ export function createDispatcher(
735
524
  } else {
736
525
  rows = (await dbSource.select().from(projTable)) as readonly Record<string, unknown>[]; // @cast-boundary db-row
737
526
  }
738
- // @cast-boundary engine-payload — generic queryProjection<T> return
739
- return rows as readonly T[];
527
+ return rows as readonly T[]; // @cast-boundary engine-payload
740
528
  },
741
529
  // Thin pass-through: one resolve impl lives on the dispatcher, the
742
530
  // handler surface just forwards the call so both entry points (login
@@ -761,7 +549,6 @@ export function createDispatcher(
761
549
  // from the dispatcher's own closure to win.
762
550
  // ctx.tz ist immer da. Tenant + User-Defaults kommen aus dem
763
551
  // SessionUser sobald die Felder existieren — bis dahin "UTC".
764
- // TODO(Iteration 6): tenant.timezone + user.timezone aus session/db lesen.
765
552
  const tz = createTzContext();
766
553
 
767
554
  return {
@@ -792,7 +579,7 @@ export function createDispatcher(
792
579
  _tenantId: user.tenantId,
793
580
  _handlerType: type,
794
581
  ...bridge,
795
- } as HandlerContext;
582
+ } as HandlerContext; // @cast-boundary engine-bridge
796
583
  }
797
584
 
798
585
  const dispatcherTracer = context.tracer ?? getFallbackTracer();
@@ -996,15 +783,18 @@ export function createDispatcher(
996
783
  result = result.map((row: Record<string, unknown>) =>
997
784
  filterReadFields(entity, row, user),
998
785
  );
999
- } else if ("rows" in (result as DbRow)) {
1000
- // @cast-boundary engine-payload — generic handler-result shape narrow
1001
- const r = result as { rows: Record<string, unknown>[]; nextCursor: string | null };
1002
- result = {
1003
- ...r,
1004
- rows: r.rows.map((row) => filterReadFields(entity, row, user)),
1005
- };
1006
786
  } else {
1007
- result = filterReadFields(entity, result as DbRow, user);
787
+ const resultAsDbRow = result as DbRow; // @cast-boundary engine-payload
788
+ if ("rows" in resultAsDbRow) {
789
+ // generic handler-result shape narrow
790
+ const r = result as { rows: Record<string, unknown>[]; nextCursor: string | null }; // @cast-boundary engine-payload
791
+ result = {
792
+ ...r,
793
+ rows: r.rows.map((row) => filterReadFields(entity, row, user)),
794
+ };
795
+ } else {
796
+ result = filterReadFields(entity, result as DbRow, user); // @cast-boundary engine-payload
797
+ }
1008
798
  }
1009
799
  }
1010
800
  }
@@ -1227,7 +1017,7 @@ export function createDispatcher(
1227
1017
  return writeFailure(validationErrorFromZod(parsed.error));
1228
1018
  }
1229
1019
 
1230
- const hookErrors = runValidation(registry, type, parsed.data as DbRow);
1020
+ const hookErrors = runValidation(registry, type, parsed.data as DbRow); // @cast-boundary engine-payload
1231
1021
  if (hookErrors) {
1232
1022
  return writeFailure(
1233
1023
  new ValidationError({
@@ -1247,8 +1037,8 @@ export function createDispatcher(
1247
1037
  if (entity) {
1248
1038
  const fieldsToCheck = (parsed.data as DbRow)["changes"] as
1249
1039
  | Record<string, unknown>
1250
- | undefined;
1251
- const writePayload = fieldsToCheck ?? (parsed.data as DbRow);
1040
+ | undefined; // @cast-boundary engine-payload
1041
+ const writePayload = fieldsToCheck ?? (parsed.data as DbRow); // @cast-boundary engine-payload
1252
1042
  // Pre-handler check: role-only gate. Ownership-level row-match runs
1253
1043
  // later in the executor where oldRow is loaded — that split lets
1254
1044
  // updates with partial changes still pass the pre-handler check and
@@ -1273,15 +1063,15 @@ export function createDispatcher(
1273
1063
  const handlerContext = buildHandlerContext(type, user, tx, afterCommitHooks);
1274
1064
 
1275
1065
  // Auto transition guard: if entity has transitions and handler doesn't skip it
1276
- if (entityName && !handler.skipTransitionGuard) {
1066
+ if (entityName && !handler.unsafeSkipTransitionGuard) {
1277
1067
  const entity = registry.getEntity(entityName);
1278
1068
  if (entity?.transitions && handlerContext.db) {
1279
- const parsedData = parsed.data as DbRow;
1280
- const changes = (parsedData["changes"] as DbRow) ?? parsedData;
1281
- const id = (parsedData["id"] as number) ?? undefined;
1069
+ const parsedData = parsed.data as DbRow; // @cast-boundary engine-payload
1070
+ const changes = (parsedData["changes"] as DbRow) ?? parsedData; // @cast-boundary engine-payload
1071
+ const id = (parsedData["id"] as number) ?? undefined; // @cast-boundary engine-payload
1282
1072
 
1283
1073
  for (const [fieldName, transitionMap] of Object.entries(entity.transitions)) {
1284
- const newValue = changes[fieldName] as string | undefined;
1074
+ const newValue = changes[fieldName] as string | undefined; // @cast-boundary engine-bridge
1285
1075
  if (!newValue || !id) continue;
1286
1076
 
1287
1077
  const table = getTable(entityName);
@@ -1301,11 +1091,12 @@ export function createDispatcher(
1301
1091
  if (!row) continue;
1302
1092
  // Skip guard for soft-deleted rows — they shouldn't be transitioning
1303
1093
  // at all; a handler that wants to move a deleted row should use
1304
- // skipTransitionGuard or restore first.
1305
- if (entity.softDelete && (row as DbRow)["isDeleted"] === true) {
1094
+ // unsafeSkipTransitionGuard or restore first.
1095
+ const rowAsRow = row as DbRow; // @cast-boundary engine-payload
1096
+ if (entity.softDelete && rowAsRow["isDeleted"] === true) {
1306
1097
  continue;
1307
1098
  }
1308
- const currentValue = (row as DbRow)[fieldName] as string;
1099
+ const currentValue = (row as DbRow)[fieldName] as string; // @cast-boundary engine-bridge
1309
1100
  guardTransition(
1310
1101
  getTransitions({ entityName, fieldName, map: transitionMap }),
1311
1102
  currentValue,
@@ -1355,9 +1146,8 @@ export function createDispatcher(
1355
1146
  // jobRunner has external side-effects (BullMQ enqueue) — must NOT
1356
1147
  // fire for rolled-back writes. Defer to afterCommit.
1357
1148
  if (jobRunner) {
1358
- afterCommitHooks.push(() =>
1359
- jobRunner.handleEvent(type, (parsed.data ?? {}) as DbRow, user),
1360
- );
1149
+ const eventData = (parsed.data ?? {}) as DbRow; // @cast-boundary engine-payload
1150
+ afterCommitHooks.push(() => jobRunner.handleEvent(type, eventData, user));
1361
1151
  }
1362
1152
  }
1363
1153
 
@@ -1413,14 +1203,13 @@ export function createDispatcher(
1413
1203
  // (one hook pushing multiple sub-calls) rather than relying on the
1414
1204
  // flush-loop order.
1415
1205
  const flushAfterCommit = async () => {
1206
+ const logError = createFallbackLogger("dispatcher", context.log);
1416
1207
  const outcomes = await Promise.allSettled(afterCommitHooks.map((hook) => hook()));
1417
1208
  for (const outcome of outcomes) {
1418
1209
  if (outcome.status === "rejected") {
1419
1210
  const detail =
1420
1211
  outcome.reason instanceof Error ? outcome.reason.message : String(outcome.reason);
1421
- const msg = "afterCommit hook failed";
1422
- if (context.log) context.log.error(msg, { error: detail });
1423
- else console.error(`[dispatcher] ${msg}: ${detail}`);
1212
+ logError.error("afterCommit hook failed", { error: detail });
1424
1213
  }
1425
1214
  }
1426
1215
  };
@@ -1444,13 +1233,12 @@ export function createDispatcher(
1444
1233
  // Batch hooks must never fail the batch — the commit already happened.
1445
1234
  // Pass the raw error so the logger preserves stack + cause chain;
1446
1235
  // collapsing to .message hides exactly what ops needs to debug.
1447
- const msg = "batch hook flush failed";
1448
- if (context.log) context.log.error(msg, { error: e });
1449
- else console.error(`[dispatcher] ${msg}:`, e);
1236
+ const logError = createFallbackLogger("dispatcher", context.log);
1237
+ logError.error("batch hook flush failed", { error: e });
1450
1238
  }
1451
1239
  };
1452
1240
 
1453
- const db = context.db as DbConnection | undefined;
1241
+ const db = context.db as DbConnection | undefined; // @cast-boundary db-operator
1454
1242
  if (!db) {
1455
1243
  // Without a DB connection there is no transaction to open. Fall back to
1456
1244
  // sequential execution — useful for unit tests that don't touch the DB.
@@ -1540,7 +1328,7 @@ export function createDispatcher(
1540
1328
  // scoped as "tenant" and no tx is threaded through. Hooks that need
1541
1329
  // cross-tenant lookups opt in explicitly via queryAs(systemUser, ...).
1542
1330
  function buildAuthClaimsContext(user: SessionUser): AuthClaimsContext {
1543
- const dbSource: DbConnection | undefined = context.db as DbConnection | undefined;
1331
+ const dbSource: DbConnection | undefined = context.db as DbConnection | undefined; // @cast-boundary db-operator
1544
1332
  if (!dbSource) {
1545
1333
  throw new InternalError({
1546
1334
  message:
@@ -1595,11 +1383,3 @@ export function createDispatcher(
1595
1383
  resolveAuthClaims: resolveAuthClaimsFn,
1596
1384
  };
1597
1385
  }
1598
-
1599
- // Non-KumikoError → InternalError with cause preserved for the log. Kumiko
1600
- // errors pass through untouched so their code/httpStatus survives.
1601
- function wrapToKumiko(e: unknown): KumikoError {
1602
- if (isKumikoError(e)) return e;
1603
- if (e instanceof Error) return new InternalError({ cause: e });
1604
- return new InternalError({ message: String(e) });
1605
- }
@@ -30,7 +30,7 @@ export function createDistributedLock(
30
30
 
31
31
  async release(key, token) {
32
32
  // Atomic: only release if we own the lock (compare token via Lua)
33
- const result = (await redis.eval(releaseScript, 1, `${prefix}${key}`, token)) as number;
33
+ const result = (await redis.eval(releaseScript, 1, `${prefix}${key}`, token)) as number; // @cast-boundary db-operator
34
34
  return result === 1;
35
35
  },
36
36
  };
@@ -1,5 +1,5 @@
1
- import type { EntityId, TenantId } from "@cosmicdrift/kumiko-framework/engine";
2
1
  import type Redis from "ioredis";
2
+ import type { EntityId, TenantId } from "../engine/types/identifiers";
3
3
  import { RedisKeys } from "./redis-keys";
4
4
 
5
5
  // JSON.stringify turns Date into an ISO string, but DB reads return Date
@@ -86,7 +86,7 @@ export function createEntityCache(redis: Redis, options: EntityCacheOptions = {}
86
86
  const raw = values[i];
87
87
  if (raw) {
88
88
  const parsed = parseCached(raw);
89
- if (parsed) result.set(ids[i] as EntityId, parsed);
89
+ if (parsed) result.set(ids[i] as EntityId, parsed); // @cast-boundary engine-payload
90
90
  }
91
91
  }
92
92
  return result;
@@ -83,19 +83,6 @@ export const ConsumerStatuses = {
83
83
  } as const;
84
84
  export type ConsumerStatus = (typeof ConsumerStatuses)[keyof typeof ConsumerStatuses];
85
85
 
86
- /**
87
- * @deprecated Use `ConsumerStatuses` (object form) or the `ConsumerStatus`
88
- * union type. The tuple form is kept as a back-compat alias for callers
89
- * that were using it with `z.enum(...)` or runtime iteration — scheduled
90
- * for removal once downstream consumers have migrated.
91
- */
92
- export const CONSUMER_STATUSES = [
93
- "idle",
94
- "processing",
95
- "dead",
96
- "disabled",
97
- ] as const satisfies readonly ConsumerStatus[];
98
-
99
86
  // Idempotent bootstrap. Called by setupTestStack + production boot path —
100
87
  // same pattern as createProjectionStateTable / createEventsTable. If the
101
88
  // table is already present (second stack in the same test DB, prod boot
@@ -189,7 +189,7 @@ async function acquireConsumerState(
189
189
  eq(eventConsumerStateTable.instanceId, instanceId),
190
190
  ),
191
191
  )
192
- .for("update", { skipLocked: true })) as [ConsumerStateRow | undefined];
192
+ .for("update", { skipLocked: true })) as [ConsumerStateRow | undefined]; // @cast-boundary db-row
193
193
 
194
194
  if (!state) {
195
195
  // Either the row never existed (no pre-reg, no ensureRegistered) or
@@ -270,7 +270,7 @@ async function fetchPendingEvents(
270
270
  .from(eventsTable)
271
271
  .where(gt(eventsTable.id, cursor))
272
272
  .orderBy(asc(eventsTable.id))
273
- .limit(batchSize)) as ReadonlyArray<typeof eventsTable.$inferSelect>;
273
+ .limit(batchSize)) as ReadonlyArray<typeof eventsTable.$inferSelect>; // @cast-boundary db-row
274
274
  }
275
275
 
276
276
  type DeliveryOutcome = {
@@ -407,7 +407,7 @@ async function emitLagFromTx(
407
407
  sql`SELECT COALESCE(MAX(id), 0)::bigint AS head FROM kumiko_events`,
408
408
  );
409
409
  // @cast-boundary db-row — raw drizzle.execute() COALESCE-aggregate row
410
- const rows = Array.isArray(result) ? (result as Array<{ head?: bigint | string | null }>) : [];
410
+ const rows = Array.isArray(result) ? (result as Array<{ head?: bigint | string | null }>) : []; // @cast-boundary db-row
411
411
  const raw = rows[0]?.head;
412
412
  const head = typeof raw === "bigint" ? raw : BigInt(raw ?? 0);
413
413
  const lag = head > cursor ? Number(head - cursor) : 0;
@@ -852,7 +852,7 @@ export async function skipPoisonEvent(
852
852
  .from(eventsTable)
853
853
  .where(gt(eventsTable.id, before.lastProcessedEventId))
854
854
  .orderBy(asc(eventsTable.id))
855
- .limit(1)) as ReadonlyArray<{ id: bigint }>;
855
+ .limit(1)) as ReadonlyArray<{ id: bigint }>; // @cast-boundary db-row
856
856
  if (!poison) {
857
857
  const [unchanged] = await tx
858
858
  .select()
@@ -9,7 +9,6 @@ export type { EntityCache, EntityCacheOptions } from "./entity-cache";
9
9
  export { createEntityCache } from "./entity-cache";
10
10
  export type { ConsumerStatus } from "./event-consumer-state";
11
11
  export {
12
- CONSUMER_STATUSES,
13
12
  ConsumerStatuses,
14
13
  createEventConsumerStateTable,
15
14
  eventConsumerStateTable,
@@ -53,7 +52,6 @@ export {
53
52
  export type { ProjectionStatus } from "./projection-state";
54
53
  export {
55
54
  createProjectionStateTable,
56
- PROJECTION_STATUSES,
57
55
  ProjectionStatuses,
58
56
  projectionStateTable,
59
57
  } from "./projection-state";