@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.
- package/CHANGELOG.md +93 -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 +44 -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 +93 -1
- package/src/engine/types/handlers.ts +18 -10
- package/src/engine/types/index.ts +11 -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 +132 -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
|
@@ -115,5 +115,5 @@ export async function listDeadLetters(
|
|
|
115
115
|
.from(upcasterDeadLetterTable)
|
|
116
116
|
.orderBy(desc(upcasterDeadLetterTable.createdAt))
|
|
117
117
|
.limit(limit);
|
|
118
|
-
return rows as readonly DeadLetterRow[];
|
|
118
|
+
return rows as readonly DeadLetterRow[]; // @cast-boundary db-row
|
|
119
119
|
}
|
|
@@ -88,7 +88,7 @@ async function upcastStoredEventWithPolicy(
|
|
|
88
88
|
if (!info) return event;
|
|
89
89
|
if (event.eventVersion >= info.currentVersion) return event;
|
|
90
90
|
|
|
91
|
-
let payload = event.payload as unknown;
|
|
91
|
+
let payload = event.payload as unknown; // @cast-boundary engine-payload
|
|
92
92
|
let v = event.eventVersion;
|
|
93
93
|
const startVersion = event.eventVersion;
|
|
94
94
|
while (v < info.currentVersion) {
|
package/src/files/file-routes.ts
CHANGED
|
@@ -346,7 +346,7 @@ export function createFileRoutes(options: FileRoutesOptions): Hono {
|
|
|
346
346
|
.select()
|
|
347
347
|
.from(fileRefsTable)
|
|
348
348
|
.where(and(eq(fileRefsTable.id, id), eq(fileRefsTable.tenantId, tenantId)));
|
|
349
|
-
return (row as FileRef | undefined) ?? null;
|
|
349
|
+
return (row as FileRef | undefined) ?? null; // @cast-boundary db-row
|
|
350
350
|
}
|
|
351
351
|
|
|
352
352
|
return api;
|
package/src/files/types.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { TenantId } from "
|
|
1
|
+
import type { TenantId } from "../engine/types/identifiers";
|
|
2
2
|
|
|
3
3
|
export type FileMetadata = {
|
|
4
4
|
readonly fileName: string;
|
|
@@ -99,7 +99,7 @@ const EXTENSION_MIME_WHITELIST: Record<string, readonly string[]> = {
|
|
|
99
99
|
csv: ["text/csv", "application/csv", "text/plain"],
|
|
100
100
|
json: ["application/json", "text/json"],
|
|
101
101
|
md: ["text/markdown", "text/plain"],
|
|
102
|
-
}
|
|
102
|
+
} satisfies Record<string, readonly string[]>;
|
|
103
103
|
|
|
104
104
|
export function validateFile(
|
|
105
105
|
metadata: FileMetadata,
|
package/src/jobs/job-runner.ts
CHANGED
|
@@ -104,7 +104,7 @@ const TRACE_CONTEXT_KEY = "_traceContext";
|
|
|
104
104
|
function readTraceContext(data: Record<string, unknown>): SerializedTraceContext | undefined {
|
|
105
105
|
const raw = data[TRACE_CONTEXT_KEY];
|
|
106
106
|
if (!raw || typeof raw !== "object") return undefined;
|
|
107
|
-
const ctx = raw as Partial<SerializedTraceContext>;
|
|
107
|
+
const ctx = raw as Partial<SerializedTraceContext>; // @cast-boundary engine-payload
|
|
108
108
|
if (!ctx.traceId || !ctx.spanId) return undefined;
|
|
109
109
|
return { traceId: ctx.traceId, spanId: ctx.spanId };
|
|
110
110
|
}
|
|
@@ -198,7 +198,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
198
198
|
for (const list of results) {
|
|
199
199
|
for (const j of list) {
|
|
200
200
|
if (j.name !== jobName) continue;
|
|
201
|
-
const t = (j.data as { _tenantId?: string } | undefined)?._tenantId;
|
|
201
|
+
const t = (j.data as { _tenantId?: string } | undefined)?._tenantId; // @cast-boundary dynamic-key
|
|
202
202
|
if (t === tenantId) {
|
|
203
203
|
count += 1;
|
|
204
204
|
if (count >= max) return true;
|
|
@@ -274,8 +274,8 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
274
274
|
// without peeking at BullMQ internals.
|
|
275
275
|
const rawData = bullJob.data as DbRow;
|
|
276
276
|
const meta: JobMeta = {
|
|
277
|
-
triggeredById: rawData["_triggeredById"] as string | undefined,
|
|
278
|
-
payload: rawData["_payload"] as string | undefined,
|
|
277
|
+
triggeredById: rawData["_triggeredById"] as string | undefined, // @cast-boundary dynamic-key
|
|
278
|
+
payload: rawData["_payload"] as string | undefined, // @cast-boundary dynamic-key
|
|
279
279
|
attempt: bullJob.attemptsMade + 1,
|
|
280
280
|
};
|
|
281
281
|
|
|
@@ -287,16 +287,16 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
287
287
|
|
|
288
288
|
// Determine tenantId and triggeredBy from meta
|
|
289
289
|
const tenantId =
|
|
290
|
-
(rawData["_tenantId"] as string | undefined) ??
|
|
291
|
-
(payload["tenantId"] as string | undefined) ??
|
|
290
|
+
(rawData["_tenantId"] as string | undefined) ?? // @cast-boundary dynamic-key
|
|
291
|
+
(payload["tenantId"] as string | undefined) ?? // @cast-boundary dynamic-key
|
|
292
292
|
SYSTEM_TENANT_ID;
|
|
293
|
-
const triggeredById = (rawData["_triggeredById"] as string | undefined) ?? null;
|
|
293
|
+
const triggeredById = (rawData["_triggeredById"] as string | undefined) ?? null; // @cast-boundary dynamic-key
|
|
294
294
|
|
|
295
295
|
// _triggerName aus rawData übernehmen falls gesetzt — handleEvent
|
|
296
296
|
// packt das beim Multi-Trigger-Dispatch rein (siehe unten). Über
|
|
297
297
|
// jobContext.triggerName freigegeben damit der Handler nicht selbst
|
|
298
298
|
// im rohen Payload kramen muss.
|
|
299
|
-
const triggerName = rawData["_triggerName"] as string | undefined;
|
|
299
|
+
const triggerName = rawData["_triggerName"] as string | undefined; // @cast-boundary dynamic-key
|
|
300
300
|
const jobContext: AppContext = {
|
|
301
301
|
...context,
|
|
302
302
|
systemUser: createSystemUser(tenantId),
|
|
@@ -317,7 +317,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
317
317
|
// so event writes during this job stamp the same correlation as the
|
|
318
318
|
// request that scheduled it. Cron/boot jobs (no scheduler) start fresh
|
|
319
319
|
// — correlationId = new requestId, no parent causation.
|
|
320
|
-
const inheritedCorrelationId = (rawData["_correlationId"] as string | undefined) ?? undefined;
|
|
320
|
+
const inheritedCorrelationId = (rawData["_correlationId"] as string | undefined) ?? undefined; // @cast-boundary dynamic-key
|
|
321
321
|
const jobRequestId = requestContext.generateId();
|
|
322
322
|
const jobCorrelationId = inheritedCorrelationId ?? jobRequestId;
|
|
323
323
|
|
|
@@ -460,7 +460,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
460
460
|
// dispatch). Fan-out children of perTenant jobs land here on their
|
|
461
461
|
// recursive queue.add and DO carry _tenantId.
|
|
462
462
|
if (jobDef.maxPerTenant !== undefined) {
|
|
463
|
-
const tenantId = (payload as { _tenantId?: string } | undefined)?._tenantId;
|
|
463
|
+
const tenantId = (payload as { _tenantId?: string } | undefined)?._tenantId; // @cast-boundary dynamic-key
|
|
464
464
|
if (
|
|
465
465
|
tenantId !== undefined &&
|
|
466
466
|
(await isOverPerTenantLimit(jobName, tenantId, jobDef.maxPerTenant))
|
|
@@ -146,9 +146,6 @@ export function createLifecycle(opts: LifecycleOptions = {}): Lifecycle {
|
|
|
146
146
|
};
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
-
// Builds a single error-log closure once per lifecycle instance. Structured
|
|
150
|
-
// logger wins when present; otherwise plain stderr via console.error so we
|
|
151
|
-
// never eat a failure silently.
|
|
152
149
|
function makeErrorLogger(
|
|
153
150
|
logger: Pick<Logger, "error"> | undefined,
|
|
154
151
|
): (msg: string, err: unknown) => void {
|
package/src/logging/index.ts
CHANGED
|
@@ -11,7 +11,7 @@ export type LoggerOptions = {
|
|
|
11
11
|
};
|
|
12
12
|
|
|
13
13
|
export function createLogger(options: LoggerOptions = {}): Logger {
|
|
14
|
-
const level = options.level ?? (process.env["LOG_LEVEL"] as LoggerOptions["level"]) ?? "info";
|
|
14
|
+
const level = options.level ?? (process.env["LOG_LEVEL"] as LoggerOptions["level"]) ?? "info"; // @cast-boundary dynamic-key
|
|
15
15
|
const pretty = options.pretty ?? process.env["LOG_FORMAT"] === "pretty";
|
|
16
16
|
|
|
17
17
|
const pinoConfig = {
|
|
@@ -43,22 +43,26 @@ function wrapPino(p: pino.Logger): Logger {
|
|
|
43
43
|
return {
|
|
44
44
|
info(msg, data) {
|
|
45
45
|
const merged = mergeTraceFields(data);
|
|
46
|
-
merged
|
|
46
|
+
if (merged) p.info(merged, msg);
|
|
47
|
+
else p.info(msg);
|
|
47
48
|
},
|
|
48
49
|
warn(msg, data) {
|
|
49
50
|
const merged = mergeTraceFields(data);
|
|
50
|
-
merged
|
|
51
|
+
if (merged) p.warn(merged, msg);
|
|
52
|
+
else p.warn(msg);
|
|
51
53
|
},
|
|
52
54
|
error(msg, data) {
|
|
53
55
|
const merged = mergeTraceFields(data);
|
|
54
|
-
merged
|
|
56
|
+
if (merged) p.error(merged, msg);
|
|
57
|
+
else p.error(msg);
|
|
55
58
|
},
|
|
56
59
|
debug(msg, data) {
|
|
57
60
|
const merged = mergeTraceFields(data);
|
|
58
|
-
merged
|
|
61
|
+
if (merged) p.debug(merged, msg);
|
|
62
|
+
else p.debug(msg);
|
|
59
63
|
},
|
|
60
|
-
child(
|
|
61
|
-
return wrapPino(p.child(
|
|
64
|
+
child(ctx) {
|
|
65
|
+
return wrapPino(p.child(ctx));
|
|
62
66
|
},
|
|
63
67
|
};
|
|
64
68
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Logger } from "./types";
|
|
2
|
+
|
|
3
|
+
type FallbackLogger = {
|
|
4
|
+
error(msg: string, data?: Record<string, unknown>): void;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export function createFallbackLogger(
|
|
8
|
+
namespace: string,
|
|
9
|
+
logger?: Pick<Logger, "error"> | undefined,
|
|
10
|
+
): FallbackLogger {
|
|
11
|
+
if (logger) {
|
|
12
|
+
return {
|
|
13
|
+
error(msg, data) {
|
|
14
|
+
logger.error(`[${namespace}] ${msg}`, data);
|
|
15
|
+
},
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
return {
|
|
19
|
+
error(msg, data) {
|
|
20
|
+
// biome-ignore lint/suspicious/noConsole: ops-visible fallback when no logger is wired
|
|
21
|
+
console.error(`[${namespace}] ${msg}:`, data);
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -244,21 +244,23 @@ export function serializeOpenMetrics(meter: PrometheusMeter): string {
|
|
|
244
244
|
for (const name of names) {
|
|
245
245
|
const entry = snap.get(name);
|
|
246
246
|
if (!entry) continue;
|
|
247
|
-
const { def
|
|
247
|
+
const { def } = entry;
|
|
248
248
|
if (def.description) lines.push(`# HELP ${name} ${def.description}`);
|
|
249
249
|
lines.push(`# TYPE ${name} ${def.type}`);
|
|
250
250
|
|
|
251
|
-
// @cast-boundary engine-bridge — slots union narrows by def.type
|
|
252
251
|
if (def.type === "counter") {
|
|
253
|
-
|
|
252
|
+
const counterSlots = entry.slots as CounterState[]; // @cast-boundary engine-bridge
|
|
253
|
+
for (const s of counterSlots) {
|
|
254
254
|
lines.push(`${name}${renderLabels(s.labels)} ${s.value}`);
|
|
255
255
|
}
|
|
256
256
|
} else if (def.type === "gauge") {
|
|
257
|
-
|
|
257
|
+
const gaugeSlots = entry.slots as GaugeState[]; // @cast-boundary engine-bridge
|
|
258
|
+
for (const s of gaugeSlots) {
|
|
258
259
|
lines.push(`${name}${renderLabels(s.labels)} ${s.value}`);
|
|
259
260
|
}
|
|
260
261
|
} else {
|
|
261
|
-
|
|
262
|
+
const histSlots = entry.slots as HistogramState[]; // @cast-boundary engine-bridge
|
|
263
|
+
for (const s of histSlots) {
|
|
262
264
|
// Cumulative bucket counts + +Inf terminator + sum/count suffixes.
|
|
263
265
|
let cumulative = 0;
|
|
264
266
|
for (let i = 0; i < s.boundaries.length; i++) {
|
|
@@ -49,7 +49,7 @@ const archFeature = defineFeature("archtest", (r) => {
|
|
|
49
49
|
"item:relabel",
|
|
50
50
|
z.object({ id: z.uuid(), label: z.string() }),
|
|
51
51
|
async (event, ctx) => {
|
|
52
|
-
await ctx.
|
|
52
|
+
await ctx.unsafeAppendEvent({
|
|
53
53
|
aggregateId: event.payload.id,
|
|
54
54
|
aggregateType: "arch-item",
|
|
55
55
|
type: labelChanged.name,
|
|
@@ -65,7 +65,7 @@ const causationFeature = defineFeature("causation", (r) => {
|
|
|
65
65
|
async (event, ctx) => {
|
|
66
66
|
const created = await orderExecutor.create({ item: event.payload.item }, event.user, ctx.db);
|
|
67
67
|
if (!created.isSuccess) return created;
|
|
68
|
-
await ctx.
|
|
68
|
+
await ctx.unsafeAppendEvent({
|
|
69
69
|
aggregateId: String(created.data.id),
|
|
70
70
|
aggregateType: "causation-order",
|
|
71
71
|
type: placed.name,
|
|
@@ -105,7 +105,7 @@ const shippingFeature = defineFeature("shipping", (r) => {
|
|
|
105
105
|
"shipment:bill",
|
|
106
106
|
z.object({ id: z.uuid(), cost: z.number() }),
|
|
107
107
|
async (event, ctx) => {
|
|
108
|
-
await ctx.
|
|
108
|
+
await ctx.unsafeAppendEvent({
|
|
109
109
|
aggregateId: event.payload.id,
|
|
110
110
|
aggregateType: "domain-shipment",
|
|
111
111
|
type: shipmentBilled.name,
|
|
@@ -137,7 +137,7 @@ const shippingFeature = defineFeature("shipping", (r) => {
|
|
|
137
137
|
"shipment:bill-unregistered",
|
|
138
138
|
z.object({ id: z.uuid() }),
|
|
139
139
|
async (event, ctx) => {
|
|
140
|
-
await ctx.
|
|
140
|
+
await ctx.unsafeAppendEvent({
|
|
141
141
|
aggregateId: event.payload.id,
|
|
142
142
|
aggregateType: "domain-shipment",
|
|
143
143
|
type: "shipping:event:ghost", // never defined via r.defineEvent
|
|
@@ -152,7 +152,7 @@ const shippingFeature = defineFeature("shipping", (r) => {
|
|
|
152
152
|
"shipment:bill-bad-payload",
|
|
153
153
|
z.object({ id: z.uuid() }),
|
|
154
154
|
async (event, ctx) => {
|
|
155
|
-
await ctx.
|
|
155
|
+
await ctx.unsafeAppendEvent({
|
|
156
156
|
aggregateId: event.payload.id,
|
|
157
157
|
aggregateType: "domain-shipment",
|
|
158
158
|
type: shipmentBilled.name,
|
|
@@ -49,7 +49,7 @@ const emitterFeature = defineFeature("emitter", (r) => {
|
|
|
49
49
|
"emit:valid",
|
|
50
50
|
z.object({ userId: z.uuid(), email: z.email() }),
|
|
51
51
|
async (cmd, ctx) => {
|
|
52
|
-
await ctx.
|
|
52
|
+
await ctx.unsafeAppendEvent({
|
|
53
53
|
aggregateId: cmd.payload.userId,
|
|
54
54
|
aggregateType: "user",
|
|
55
55
|
type: welcome.name,
|
|
@@ -66,7 +66,7 @@ const emitterFeature = defineFeature("emitter", (r) => {
|
|
|
66
66
|
async (cmd, ctx) => {
|
|
67
67
|
// Deliberately NOT passing welcome.name — "emitter:event:not-registered"
|
|
68
68
|
// was never registered. ctx.appendEvent must reject at the append site.
|
|
69
|
-
await ctx.
|
|
69
|
+
await ctx.unsafeAppendEvent({
|
|
70
70
|
aggregateId: cmd.payload.userId,
|
|
71
71
|
aggregateType: "user",
|
|
72
72
|
type: "emitter:event:not-registered",
|
|
@@ -82,7 +82,7 @@ const emitterFeature = defineFeature("emitter", (r) => {
|
|
|
82
82
|
z.object({ userId: z.uuid() }),
|
|
83
83
|
async (cmd, ctx) => {
|
|
84
84
|
// userId is correct but email is missing / not an email string.
|
|
85
|
-
await ctx.
|
|
85
|
+
await ctx.unsafeAppendEvent({
|
|
86
86
|
aggregateId: cmd.payload.userId,
|
|
87
87
|
aggregateType: "user",
|
|
88
88
|
type: welcome.name,
|
|
@@ -100,7 +100,7 @@ const emitterFeature = defineFeature("emitter", (r) => {
|
|
|
100
100
|
// "neighbor:event:neighbor-signal" is owned by the neighbor feature.
|
|
101
101
|
// The ownership guard in appendDomainEventCore must reject this append
|
|
102
102
|
// at emit-site — cross-feature emission silently breaks encapsulation.
|
|
103
|
-
await ctx.
|
|
103
|
+
await ctx.unsafeAppendEvent({
|
|
104
104
|
aggregateId: cmd.payload.userId,
|
|
105
105
|
aggregateType: "user",
|
|
106
106
|
type: foreignEventName,
|
|
@@ -66,7 +66,7 @@ const asOfFeature = defineFeature("asoftest", (r) => {
|
|
|
66
66
|
"invoice:approve",
|
|
67
67
|
z.object({ id: z.uuid(), amount: z.number().int(), approvedBy: z.string() }),
|
|
68
68
|
async (event, ctx) => {
|
|
69
|
-
await ctx.
|
|
69
|
+
await ctx.unsafeAppendEvent({
|
|
70
70
|
aggregateId: event.payload.id,
|
|
71
71
|
aggregateType: "asof-invoice",
|
|
72
72
|
type: approved.name,
|
|
@@ -55,7 +55,7 @@ const mmhFeature = defineFeature("mmh", (r) => {
|
|
|
55
55
|
async (event, ctx) => {
|
|
56
56
|
const created = await orderExecutor.create({ item: event.payload.item }, event.user, ctx.db);
|
|
57
57
|
if (!created.isSuccess) return created;
|
|
58
|
-
await ctx.
|
|
58
|
+
await ctx.unsafeAppendEvent({
|
|
59
59
|
aggregateId: String(created.data.id),
|
|
60
60
|
aggregateType: "mmh-order",
|
|
61
61
|
type: placed.name,
|
|
@@ -75,7 +75,7 @@ const mmhFeature = defineFeature("mmh", (r) => {
|
|
|
75
75
|
if (!ctx) throw new Error("MSP-apply ctx missing — regression of C.2b wiring");
|
|
76
76
|
const history = await ctx.loadAggregate(event.aggregateId);
|
|
77
77
|
confirmLoadCounts.push(history.length);
|
|
78
|
-
await ctx.
|
|
78
|
+
await ctx.unsafeAppendEvent({
|
|
79
79
|
aggregateId: event.aggregateId,
|
|
80
80
|
aggregateType: "mmh-order",
|
|
81
81
|
type: confirmed.name,
|
|
@@ -91,7 +91,7 @@ const mmhFeature = defineFeature("mmh", (r) => {
|
|
|
91
91
|
apply: {
|
|
92
92
|
[confirmed.name]: async (event, _tx, ctx) => {
|
|
93
93
|
if (!ctx) throw new Error("MSP-apply ctx missing — regression of C.2b wiring");
|
|
94
|
-
await ctx.
|
|
94
|
+
await ctx.unsafeAppendEvent({
|
|
95
95
|
aggregateId: event.aggregateId,
|
|
96
96
|
aggregateType: "mmh-order",
|
|
97
97
|
type: shipped.name,
|
|
@@ -139,7 +139,7 @@ const feature = defineFeature("mspreb", (r) => {
|
|
|
139
139
|
apply: {
|
|
140
140
|
[invoiceBilled.name]: async (event, _tx, ctx) => {
|
|
141
141
|
const p = event.payload as { customer: string };
|
|
142
|
-
await ctx.
|
|
142
|
+
await ctx.unsafeAppendEvent({
|
|
143
143
|
aggregateId: p.customer,
|
|
144
144
|
aggregateType: "msp-reb-invoice",
|
|
145
145
|
type: escalationTriggered.name,
|
|
@@ -166,7 +166,7 @@ const feature = defineFeature("mspreb", (r) => {
|
|
|
166
166
|
ctx.db,
|
|
167
167
|
);
|
|
168
168
|
if (!res.isSuccess) return res;
|
|
169
|
-
await ctx.
|
|
169
|
+
await ctx.unsafeAppendEvent({
|
|
170
170
|
aggregateId: String(res.data.id),
|
|
171
171
|
aggregateType: "msp-reb-invoice",
|
|
172
172
|
type: invoiceBilled.name,
|
|
@@ -187,7 +187,7 @@ const feature = defineFeature("mspreb", (r) => {
|
|
|
187
187
|
ctx.db,
|
|
188
188
|
);
|
|
189
189
|
if (!res.isSuccess) return res;
|
|
190
|
-
await ctx.
|
|
190
|
+
await ctx.unsafeAppendEvent({
|
|
191
191
|
aggregateId: String(res.data.id),
|
|
192
192
|
aggregateType: "msp-reb-payment",
|
|
193
193
|
type: paymentReceived.name,
|
|
@@ -124,7 +124,7 @@ const mspFeature = defineFeature("msptest", (r) => {
|
|
|
124
124
|
ctx.db,
|
|
125
125
|
);
|
|
126
126
|
if (!res.isSuccess) return res;
|
|
127
|
-
await ctx.
|
|
127
|
+
await ctx.unsafeAppendEvent({
|
|
128
128
|
aggregateId: String(res.data.id),
|
|
129
129
|
aggregateType: "msp-shipment",
|
|
130
130
|
type: shipmentBilled.name,
|
|
@@ -145,7 +145,7 @@ const mspFeature = defineFeature("msptest", (r) => {
|
|
|
145
145
|
ctx.db,
|
|
146
146
|
);
|
|
147
147
|
if (!res.isSuccess) return res;
|
|
148
|
-
await ctx.
|
|
148
|
+
await ctx.unsafeAppendEvent({
|
|
149
149
|
aggregateId: String(res.data.id),
|
|
150
150
|
aggregateType: "msp-refund",
|
|
151
151
|
type: refundIssued.name,
|
|
@@ -96,10 +96,10 @@ const qpFeature = defineFeature("qp", (r) => {
|
|
|
96
96
|
|
|
97
97
|
r.queryHandler(
|
|
98
98
|
"widget:list-system",
|
|
99
|
-
z.object({
|
|
99
|
+
z.object({ unsafeAllTenants: z.boolean().optional() }),
|
|
100
100
|
async (query, ctx) =>
|
|
101
101
|
ctx.queryProjection("qp:projection:widget-audit", {
|
|
102
|
-
|
|
102
|
+
unsafeAllTenants: query.payload.unsafeAllTenants ?? false,
|
|
103
103
|
}),
|
|
104
104
|
{ access: { openToAll: true } },
|
|
105
105
|
);
|
|
@@ -172,8 +172,8 @@ describe("ctx.queryProjection", () => {
|
|
|
172
172
|
expect(rows.map((r) => r.label).sort()).toEqual(["X", "Y"]);
|
|
173
173
|
});
|
|
174
174
|
|
|
175
|
-
test("
|
|
176
|
-
// Repurpose list-system by passing
|
|
175
|
+
test("unsafeAllTenants=true bypasses tenant filter on tenant-scoped projection", async () => {
|
|
176
|
+
// Repurpose list-system by passing unsafeAllTenants=true — but list-system is
|
|
177
177
|
// already no-tenant-column. The semantic matters when a projection HAS
|
|
178
178
|
// tenant_id but the handler wants a cross-tenant sweep (audit). We
|
|
179
179
|
// exercise that contract via a direct queryProjection call here.
|
|
@@ -186,7 +186,7 @@ describe("ctx.queryProjection", () => {
|
|
|
186
186
|
// surface small — assert against the two query handlers we have.)
|
|
187
187
|
const sys = await stack.http.queryOk<Array<{ label: string }>>(
|
|
188
188
|
"qp:query:widget:list-system",
|
|
189
|
-
{
|
|
189
|
+
{ unsafeAllTenants: true },
|
|
190
190
|
admin,
|
|
191
191
|
);
|
|
192
192
|
expect(sys).toHaveLength(2);
|
|
@@ -41,12 +41,21 @@ function eventOwnerFeature(qualifiedName: string): string | undefined {
|
|
|
41
41
|
return idx > 0 ? qualifiedName.slice(0, idx) : undefined;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
// System-event prefix: events under this namespace bypass the registry +
|
|
45
|
+
// ownership checks. Reserved for framework-internal coordination (step-
|
|
46
|
+
// engine deferred dispatch, lifecycle signals). The matching MSP filters
|
|
47
|
+
// on the literal type-string. Apps cannot write into "kumiko:system:*"
|
|
48
|
+
// directly — only framework step implementations call unsafeAppendEvent
|
|
49
|
+
// with these types.
|
|
50
|
+
const SYSTEM_EVENT_PREFIX = "kumiko:system:";
|
|
51
|
+
|
|
44
52
|
export async function appendDomainEventCore(
|
|
45
53
|
deps: AppendDomainEventCoreDeps,
|
|
46
54
|
args: AppendEventArgs,
|
|
47
55
|
): Promise<StoredEvent> {
|
|
56
|
+
const isSystemEvent = args.type.startsWith(SYSTEM_EVENT_PREFIX);
|
|
48
57
|
const eventDef = deps.registry.getEvent(args.type);
|
|
49
|
-
if (!eventDef) {
|
|
58
|
+
if (!eventDef && !isSystemEvent) {
|
|
50
59
|
throw new InternalError({
|
|
51
60
|
message: `${deps.callSiteLabel}("${args.type}") — event not registered. Call r.defineEvent(shortName, schema) in a feature; appendEvent expects the qualified name returned by defineEvent (e.g. "<feature>:event:<short>").`,
|
|
52
61
|
});
|
|
@@ -61,7 +70,7 @@ export async function appendDomainEventCore(
|
|
|
61
70
|
// Feature names are registered case-preserving (pubsubOrders) but qualified
|
|
62
71
|
// into kebab-case for the event/handler names (pubsub-orders:event:…) — so
|
|
63
72
|
// we compare the kebab form on both sides.
|
|
64
|
-
if (deps.callerFeature) {
|
|
73
|
+
if (deps.callerFeature && !isSystemEvent) {
|
|
65
74
|
const owner = eventOwnerFeature(args.type);
|
|
66
75
|
const callerKebab = toKebab(deps.callerFeature);
|
|
67
76
|
if (owner && owner !== callerKebab) {
|
|
@@ -70,9 +79,16 @@ export async function appendDomainEventCore(
|
|
|
70
79
|
});
|
|
71
80
|
}
|
|
72
81
|
}
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
82
|
+
// System events skip schema validation — payload shape is owned by the
|
|
83
|
+
// framework step that emits + the bundled MSP that consumes.
|
|
84
|
+
let validatedPayload: Record<string, unknown>;
|
|
85
|
+
if (eventDef) {
|
|
86
|
+
const parsed = eventDef.schema.safeParse(args.payload ?? {});
|
|
87
|
+
if (!parsed.success) throw validationErrorFromZod(parsed.error);
|
|
88
|
+
validatedPayload = parsed.data as Record<string, unknown>;
|
|
89
|
+
} else {
|
|
90
|
+
validatedPayload = (args.payload ?? {}) as Record<string, unknown>;
|
|
91
|
+
}
|
|
76
92
|
|
|
77
93
|
// Archive guard: block writes on archived streams. Without this an append
|
|
78
94
|
// would produce an "invisible" row that loadAggregate filters out by default
|
|
@@ -97,7 +113,7 @@ export async function appendDomainEventCore(
|
|
|
97
113
|
tenantId: deps.tenantId,
|
|
98
114
|
expectedVersion,
|
|
99
115
|
type: args.type,
|
|
100
|
-
eventVersion: eventDef
|
|
116
|
+
eventVersion: eventDef?.version ?? 1,
|
|
101
117
|
payload: validatedPayload,
|
|
102
118
|
metadata: {
|
|
103
119
|
userId: deps.userId,
|