@cosmicdrift/kumiko-framework 0.2.3 → 0.3.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/CHANGELOG.md +52 -0
- package/package.json +124 -39
- package/src/__tests__/full-stack.integration.ts +2 -2
- package/src/api/auth-routes.ts +5 -5
- package/src/api/jwt.ts +2 -2
- package/src/api/route-registrars.ts +1 -1
- package/src/api/routes.ts +3 -3
- package/src/api/server.ts +6 -7
- package/src/compliance/profiles.ts +8 -8
- package/src/db/assert-exists-in.ts +2 -2
- package/src/db/cursor.ts +3 -3
- package/src/db/event-store-executor.ts +19 -13
- package/src/db/located-timestamp.ts +1 -1
- package/src/db/money.ts +12 -2
- package/src/db/pg-error.ts +1 -1
- package/src/db/row-helpers.ts +1 -1
- package/src/db/table-builder.ts +3 -5
- package/src/db/tenant-db.ts +9 -9
- package/src/engine/__tests__/_pipeline-test-utils.ts +23 -0
- package/src/engine/__tests__/build-target.test.ts +135 -0
- package/src/engine/__tests__/codemod-pipeline.test.ts +551 -0
- package/src/engine/__tests__/entity-handlers.test.ts +3 -3
- package/src/engine/__tests__/event-helpers.test.ts +4 -4
- package/src/engine/__tests__/pipeline-engine.test.ts +215 -0
- package/src/engine/__tests__/pipeline-handler.integration.ts +894 -0
- package/src/engine/__tests__/pipeline-observability.integration.ts +142 -0
- package/src/engine/__tests__/pipeline-performance.integration.ts +152 -0
- package/src/engine/__tests__/pipeline-sub-pipelines.test.ts +288 -0
- package/src/engine/__tests__/raw-table.test.ts +2 -2
- package/src/engine/__tests__/steps-aggregate-append-event.test.ts +115 -0
- package/src/engine/__tests__/steps-aggregate-create.test.ts +92 -0
- package/src/engine/__tests__/steps-aggregate-update.test.ts +127 -0
- package/src/engine/__tests__/steps-call-feature.test.ts +123 -0
- package/src/engine/__tests__/steps-mail-send.test.ts +136 -0
- package/src/engine/__tests__/steps-read.test.ts +142 -0
- package/src/engine/__tests__/steps-resolver-utils.test.ts +50 -0
- package/src/engine/__tests__/steps-unsafe-projection-delete.test.ts +69 -0
- package/src/engine/__tests__/steps-unsafe-projection-upsert.test.ts +117 -0
- package/src/engine/__tests__/steps-webhook-send.test.ts +135 -0
- package/src/engine/__tests__/steps-workflow.test.ts +198 -0
- package/src/engine/__tests__/validate-projection-allowlist.test.ts +491 -0
- package/src/engine/__tests__/visual-tree-patterns.test.ts +251 -0
- package/src/engine/boot-validator/api-ext.ts +77 -0
- package/src/engine/boot-validator/config-deps.ts +163 -0
- package/src/engine/boot-validator/entity-handler.ts +466 -0
- package/src/engine/boot-validator/index.ts +159 -0
- package/src/engine/boot-validator/ownership.ts +198 -0
- package/src/engine/boot-validator/pii-retention.ts +155 -0
- package/src/engine/boot-validator/screens-nav.ts +624 -0
- package/src/engine/boot-validator.ts +1 -1804
- package/src/engine/build-app-schema.ts +1 -1
- package/src/engine/build-target.ts +99 -0
- package/src/engine/codemod/index.ts +15 -0
- package/src/engine/codemod/pipeline-codemod.ts +641 -0
- package/src/engine/config-helpers.ts +9 -19
- package/src/engine/constants.ts +1 -1
- package/src/engine/define-feature.ts +88 -9
- package/src/engine/define-handler.ts +89 -3
- package/src/engine/define-roles.ts +2 -2
- package/src/engine/define-step.ts +28 -0
- package/src/engine/define-workflow.ts +110 -0
- package/src/engine/entity-handlers.ts +10 -9
- package/src/engine/event-helpers.ts +4 -4
- package/src/engine/factories.ts +12 -12
- package/src/engine/feature-ast/__tests__/visual-tree-parse.test.ts +184 -0
- package/src/engine/feature-ast/extractors/index.ts +74 -0
- package/src/engine/feature-ast/extractors/round1.ts +110 -0
- package/src/engine/feature-ast/extractors/round2.ts +253 -0
- package/src/engine/feature-ast/extractors/round3.ts +471 -0
- package/src/engine/feature-ast/extractors/round4.ts +1365 -0
- package/src/engine/feature-ast/extractors/round5.ts +72 -0
- package/src/engine/feature-ast/extractors/round6.ts +66 -0
- package/src/engine/feature-ast/extractors/shared.ts +177 -0
- package/src/engine/feature-ast/parse.ts +7 -0
- package/src/engine/feature-ast/patch.ts +9 -1
- package/src/engine/feature-ast/patcher.ts +10 -3
- package/src/engine/feature-ast/patterns.ts +49 -1
- package/src/engine/feature-ast/render.ts +17 -1
- package/src/engine/index.ts +45 -2
- package/src/engine/pattern-library/__tests__/library.test.ts +6 -0
- package/src/engine/pattern-library/library.ts +42 -2
- package/src/engine/pipeline.ts +88 -0
- package/src/engine/projection-helpers.ts +1 -1
- package/src/engine/read-claim.ts +1 -1
- package/src/engine/registry.ts +30 -2
- package/src/engine/resolve-config-or-param.ts +4 -0
- package/src/engine/run-pipeline.ts +162 -0
- package/src/engine/schema-builder.ts +2 -4
- package/src/engine/state-machine.ts +1 -1
- package/src/engine/steps/_drizzle-boundary.ts +19 -0
- package/src/engine/steps/_duration-utils.ts +33 -0
- package/src/engine/steps/_no-return-guard.ts +21 -0
- package/src/engine/steps/_resolver-utils.ts +42 -0
- package/src/engine/steps/_step-dispatch-constants.ts +38 -0
- package/src/engine/steps/aggregate-append-event.ts +56 -0
- package/src/engine/steps/aggregate-create.ts +56 -0
- package/src/engine/steps/aggregate-update.ts +68 -0
- package/src/engine/steps/branch.ts +84 -0
- package/src/engine/steps/call-feature.ts +49 -0
- package/src/engine/steps/compute.ts +41 -0
- package/src/engine/steps/for-each.ts +111 -0
- package/src/engine/steps/mail-send.ts +44 -0
- package/src/engine/steps/read-find-many.ts +51 -0
- package/src/engine/steps/read-find-one.ts +58 -0
- package/src/engine/steps/retry.ts +87 -0
- package/src/engine/steps/return.ts +34 -0
- package/src/engine/steps/unsafe-projection-delete.ts +46 -0
- package/src/engine/steps/unsafe-projection-upsert.ts +69 -0
- package/src/engine/steps/wait-for-event.ts +71 -0
- package/src/engine/steps/wait.ts +69 -0
- package/src/engine/steps/webhook-send.ts +71 -0
- package/src/engine/system-user.ts +1 -1
- package/src/engine/types/feature.ts +92 -1
- package/src/engine/types/handlers.ts +18 -10
- package/src/engine/types/identifiers.ts +1 -0
- package/src/engine/types/index.ts +12 -1
- package/src/engine/types/step.ts +334 -0
- package/src/engine/types/target-ref.ts +21 -0
- package/src/engine/types/tree-node.ts +130 -0
- package/src/engine/types/workspace.ts +7 -0
- package/src/engine/validate-projection-allowlist.ts +161 -0
- package/src/event-store/snapshot.ts +1 -1
- package/src/event-store/upcaster-dead-letter.ts +1 -1
- package/src/event-store/upcaster.ts +1 -1
- package/src/files/file-routes.ts +1 -1
- package/src/files/types.ts +2 -2
- package/src/jobs/job-runner.ts +10 -10
- package/src/lifecycle/lifecycle.ts +0 -3
- package/src/logging/index.ts +1 -0
- package/src/logging/pino-logger.ts +11 -7
- package/src/logging/utils.ts +24 -0
- package/src/observability/prometheus-meter.ts +7 -5
- package/src/pipeline/__tests__/archive-stream.integration.ts +1 -1
- package/src/pipeline/__tests__/causation-chain.integration.ts +1 -1
- package/src/pipeline/__tests__/domain-events-projections.integration.ts +3 -3
- package/src/pipeline/__tests__/event-define-event-strict.integration.ts +4 -4
- package/src/pipeline/__tests__/load-aggregate-query.integration.ts +1 -1
- package/src/pipeline/__tests__/msp-multi-hop.integration.ts +3 -3
- package/src/pipeline/__tests__/msp-rebuild.integration.ts +3 -3
- package/src/pipeline/__tests__/multi-stream-projection.integration.ts +2 -2
- package/src/pipeline/__tests__/query-projection.integration.ts +5 -5
- package/src/pipeline/append-event-core.ts +22 -6
- package/src/pipeline/dispatcher-utils.ts +188 -0
- package/src/pipeline/dispatcher.ts +63 -283
- package/src/pipeline/distributed-lock.ts +1 -1
- package/src/pipeline/entity-cache.ts +2 -2
- package/src/pipeline/event-consumer-state.ts +0 -13
- package/src/pipeline/event-dispatcher.ts +4 -4
- package/src/pipeline/index.ts +0 -2
- package/src/pipeline/lifecycle-pipeline.ts +6 -12
- package/src/pipeline/msp-rebuild.ts +5 -5
- package/src/pipeline/multi-stream-apply-context.ts +6 -7
- package/src/pipeline/projection-rebuild.ts +2 -2
- package/src/pipeline/projection-state.ts +0 -12
- package/src/rate-limit/__tests__/resolver.integration.ts +8 -4
- package/src/rate-limit/resolver.ts +1 -1
- package/src/search/in-memory-adapter.ts +1 -1
- package/src/search/meilisearch-adapter.ts +3 -3
- package/src/search/types.ts +1 -1
- package/src/secrets/leak-guard.ts +2 -2
- package/src/stack/request-helper.ts +9 -5
- package/src/stack/test-stack.ts +1 -1
- package/src/testing/handler-context.ts +4 -4
- package/src/testing/http-cookies.ts +1 -1
- package/src/time/tz-context.ts +1 -2
- package/src/ui-types/index.ts +4 -0
- 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
|
-
|
|
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
|
|
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?.
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
//
|
|
1305
|
-
|
|
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
|
-
|
|
1359
|
-
|
|
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
|
-
|
|
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
|
|
1448
|
-
|
|
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()
|
package/src/pipeline/index.ts
CHANGED
|
@@ -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";
|