@cosmicdrift/kumiko-framework 0.37.0 → 0.38.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/package.json +2 -2
- package/src/api/routes.ts +2 -7
- package/src/api/server.ts +1 -1
- package/src/bun-db/connection.ts +1 -0
- package/src/bun-db/index.ts +0 -1
- package/src/db/__tests__/schema-migration.integration.test.ts +1 -1
- package/src/db/__tests__/table-builder-meta-lockstep.test.ts +42 -1
- package/src/db/__tests__/tenant-db-where-merge.test.ts +34 -0
- package/src/db/collect-table-metas.ts +2 -2
- package/src/db/connection.ts +1 -0
- package/src/db/dialect.ts +16 -3
- package/src/db/event-store-executor.ts +29 -0
- package/src/db/index.ts +1 -0
- package/src/db/query.ts +1 -0
- package/src/db/tenant-db.ts +14 -4
- package/src/engine/__tests__/build-app-schema.test.ts +31 -3
- package/src/engine/__tests__/engine.test.ts +3 -3
- package/src/engine/__tests__/hook-phases.test.ts +5 -5
- package/src/engine/__tests__/lifecycle-hooks.test.ts +8 -8
- package/src/engine/__tests__/post-query-hook.test.ts +3 -3
- package/src/engine/__tests__/validation-hooks.test.ts +2 -2
- package/src/engine/boot-validator/entity-handler.ts +14 -11
- package/src/engine/boot-validator/ownership.ts +1 -1
- package/src/engine/boot-validator/pii-retention.ts +1 -1
- package/src/engine/boot-validator/screens-nav.ts +9 -6
- package/src/engine/build-app-schema.ts +42 -12
- package/src/engine/create-app.ts +1 -1
- package/src/engine/define-feature.ts +5 -1
- package/src/engine/feature-ast/extractors/round4.ts +1 -0
- package/src/engine/index.ts +2 -0
- package/src/engine/registry.ts +4 -3
- package/src/engine/steps/unsafe-projection-upsert.ts +4 -15
- package/src/engine/types/feature.ts +7 -3
- package/src/engine/types/hooks.ts +15 -11
- package/src/engine/types/screen.ts +2 -7
- package/src/engine/validate-projection-allowlist.ts +1 -1
- package/src/engine/validation.ts +1 -1
- package/src/errors/index.ts +1 -1
- package/src/errors/to-kumiko-error.ts +8 -0
- package/src/event-store/archive.ts +1 -0
- package/src/event-store/event-store.ts +1 -16
- package/src/event-store/index.ts +1 -0
- package/src/event-store/row-to-stored-event.ts +34 -0
- package/src/logging/__tests__/fallback-logger.test.ts +5 -5
- package/src/logging/utils.ts +1 -1
- package/src/migrations/projection-table-index.ts +3 -15
- package/src/pipeline/__tests__/archive-stream.integration.test.ts +75 -0
- package/src/pipeline/dispatcher-utils.ts +2 -12
- package/src/pipeline/event-consumer-state.ts +1 -0
- package/src/pipeline/event-dispatcher.ts +21 -42
- package/src/pipeline/msp-rebuild.ts +8 -19
- package/src/pipeline/projection-rebuild.ts +2 -13
- package/src/pipeline/system-hooks.ts +17 -4
- package/src/random/words.ts +4 -3
- package/src/stack/test-stack.ts +1 -1
|
@@ -13,7 +13,7 @@ export function validateOwnershipRules(
|
|
|
13
13
|
allClaimKeys: ReadonlyMap<string, ClaimKeyDefinition>,
|
|
14
14
|
knownRoles: ReadonlySet<string>,
|
|
15
15
|
): void {
|
|
16
|
-
for (const [entityName, entity] of Object.entries(feature.entities)) {
|
|
16
|
+
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
17
17
|
const columnNames = new Set<string>(Object.keys(entity.fields));
|
|
18
18
|
// Framework-managed columns that rules are allowed to reference too.
|
|
19
19
|
// These are the base columns buildEntityTable adds unconditionally.
|
|
@@ -42,7 +42,7 @@ const KEEP_FOR_PATTERN = /^\d+[hdwmy]$/;
|
|
|
42
42
|
// Encrypt/Decrypt-Mechanik landet in Sprint 3 (crypto-shredding); diese
|
|
43
43
|
// Validation greift schon ab Sprint 0 damit Schema-Drift früh auffällt.
|
|
44
44
|
export function validatePiiAndRetention(feature: FeatureDefinition): void {
|
|
45
|
-
for (const [entityName, entity] of Object.entries(feature.entities)) {
|
|
45
|
+
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
46
46
|
const fieldsByName = entity.fields;
|
|
47
47
|
|
|
48
48
|
for (const [fieldName, field] of Object.entries(fieldsByName)) {
|
|
@@ -63,7 +63,7 @@ export function validateScreens(
|
|
|
63
63
|
}
|
|
64
64
|
for (const section of screen.layout.sections) {
|
|
65
65
|
if (isExtensionEditSection(section)) {
|
|
66
|
-
if (section.component
|
|
66
|
+
if (section.component?.react === undefined && section.component?.native === undefined) {
|
|
67
67
|
throw new Error(
|
|
68
68
|
`[Feature ${feature.name}] Screen "${screenId}" (configEdit) extension section ` +
|
|
69
69
|
`"${section.title}" has no component — declare a react/native component marker.`,
|
|
@@ -170,7 +170,7 @@ export function validateScreens(
|
|
|
170
170
|
}
|
|
171
171
|
for (const section of screen.layout.sections) {
|
|
172
172
|
if (isExtensionEditSection(section)) {
|
|
173
|
-
if (section.component
|
|
173
|
+
if (section.component?.react === undefined && section.component?.native === undefined) {
|
|
174
174
|
throw new Error(
|
|
175
175
|
`[Feature ${feature.name}] Screen "${screenId}" (actionForm) extension section ` +
|
|
176
176
|
`"${section.title}" has no component — declare a react/native component marker.`,
|
|
@@ -227,9 +227,12 @@ export function validateScreens(
|
|
|
227
227
|
}
|
|
228
228
|
|
|
229
229
|
// entityList / entityEdit: entity-refs are feature-local.
|
|
230
|
-
const entityDef = feature.entities[screen.entity];
|
|
230
|
+
const entityDef = feature.entities?.[screen.entity];
|
|
231
231
|
if (!entityDef) {
|
|
232
|
-
const known =
|
|
232
|
+
const known =
|
|
233
|
+
Object.keys(feature.entities ?? {})
|
|
234
|
+
.sort()
|
|
235
|
+
.join(", ") || "(none)";
|
|
233
236
|
const crossFeature = findEntityFeature(screen.entity, featureMap);
|
|
234
237
|
const hint = crossFeature
|
|
235
238
|
? ` Entity "${screen.entity}" is owned by feature "${crossFeature}" — cross-feature screen ownership is not supported.`
|
|
@@ -405,7 +408,7 @@ export function validateScreens(
|
|
|
405
408
|
}
|
|
406
409
|
for (const section of screen.layout.sections) {
|
|
407
410
|
if (isExtensionEditSection(section)) {
|
|
408
|
-
if (section.component
|
|
411
|
+
if (section.component?.react === undefined && section.component?.native === undefined) {
|
|
409
412
|
throw new Error(
|
|
410
413
|
`[Feature ${feature.name}] Screen "${screenId}" (entityEdit) extension section ` +
|
|
411
414
|
`"${section.title}" has no component — declare a react/native component marker.`,
|
|
@@ -484,7 +487,7 @@ export function findEntityFeature(
|
|
|
484
487
|
featureMap: ReadonlyMap<string, FeatureDefinition>,
|
|
485
488
|
): string | undefined {
|
|
486
489
|
for (const [name, feature] of featureMap) {
|
|
487
|
-
if (feature.entities[entityName]) return name;
|
|
490
|
+
if (feature.entities?.[entityName]) return name;
|
|
488
491
|
}
|
|
489
492
|
return undefined;
|
|
490
493
|
}
|
|
@@ -35,7 +35,7 @@ export function buildAppSchema(registry: Registry): AppSchema {
|
|
|
35
35
|
const navs = Object.values(feature.navs);
|
|
36
36
|
const featureSchema: FeatureSchema = {
|
|
37
37
|
featureName,
|
|
38
|
-
entities: projectEntities(feature.entities),
|
|
38
|
+
entities: projectEntities(feature.entities ?? {}),
|
|
39
39
|
screens: Object.values(feature.screens),
|
|
40
40
|
...(navs.length > 0 && { navs }),
|
|
41
41
|
};
|
|
@@ -65,19 +65,14 @@ export function buildAppSchema(registry: Registry): AppSchema {
|
|
|
65
65
|
};
|
|
66
66
|
|
|
67
67
|
if (typeof process !== "undefined" && process.env.NODE_ENV !== "production") {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
"[kumiko] buildAppSchema: Output ist nicht JSON-safe — ein Funktions-Renderer oder nicht-serialisierbarer Wert ist in das Schema gerutscht. Details im Diff:",
|
|
74
|
-
schema,
|
|
75
|
-
);
|
|
76
|
-
}
|
|
77
|
-
} catch {
|
|
68
|
+
// A stringify-roundtrip comparison can never fire here: JSON.stringify
|
|
69
|
+
// drops functions/undefined identically on both sides. Walk the value
|
|
70
|
+
// instead so a leaked function renderer is actually caught.
|
|
71
|
+
const offender = findNonJsonSafePath(schema, "schema");
|
|
72
|
+
if (offender !== null) {
|
|
78
73
|
// biome-ignore lint/suspicious/noConsole: dev-only assertion
|
|
79
74
|
console.error(
|
|
80
|
-
|
|
75
|
+
`[kumiko] buildAppSchema: Output ist nicht JSON-safe — nicht-serialisierbarer Wert bei "${offender}" (Funktions-Renderer oder Klassen-Instanz im Schema?).`,
|
|
81
76
|
schema,
|
|
82
77
|
);
|
|
83
78
|
}
|
|
@@ -86,6 +81,41 @@ export function buildAppSchema(registry: Registry): AppSchema {
|
|
|
86
81
|
return schema;
|
|
87
82
|
}
|
|
88
83
|
|
|
84
|
+
// PlatformComponent slots ({ react, native }) legitimately hold component
|
|
85
|
+
// functions — JSON.stringify drops them at inject-time and the client
|
|
86
|
+
// re-resolves by name, so the walker treats them as opaque.
|
|
87
|
+
function isPlatformComponentShape(value: object): boolean {
|
|
88
|
+
const keys = Object.keys(value);
|
|
89
|
+
return keys.length > 0 && keys.every((k) => k === "react" || k === "native");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Returns the path of the first value JSON.stringify would drop or distort
|
|
93
|
+
// (function, undefined, symbol, bigint, non-finite number, class instance) —
|
|
94
|
+
// or null when the value is JSON-safe apart from PlatformComponent slots.
|
|
95
|
+
export function findNonJsonSafePath(value: unknown, path: string): string | null {
|
|
96
|
+
if (value === null || typeof value === "string" || typeof value === "boolean") return null;
|
|
97
|
+
if (typeof value === "number") return Number.isFinite(value) ? null : path;
|
|
98
|
+
if (Array.isArray(value)) {
|
|
99
|
+
for (let i = 0; i < value.length; i++) {
|
|
100
|
+
const hit = findNonJsonSafePath(value[i], `${path}[${i}]`);
|
|
101
|
+
if (hit !== null) return hit;
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
if (typeof value === "object") {
|
|
106
|
+
const proto = Object.getPrototypeOf(value);
|
|
107
|
+
if (proto !== Object.prototype && proto !== null) return path;
|
|
108
|
+
if (isPlatformComponentShape(value)) return null;
|
|
109
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
110
|
+
const hit = findNonJsonSafePath(entry, `${path}.${key}`);
|
|
111
|
+
if (hit !== null) return hit;
|
|
112
|
+
}
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
// function, symbol, bigint, undefined
|
|
116
|
+
return path;
|
|
117
|
+
}
|
|
118
|
+
|
|
89
119
|
function projectEntities(
|
|
90
120
|
entities: Readonly<Record<string, EntityDefinition>>,
|
|
91
121
|
): Readonly<Record<string, EntityDefinition>> {
|
package/src/engine/create-app.ts
CHANGED
|
@@ -71,7 +71,7 @@ export function createApp(config: AppConfig): App {
|
|
|
71
71
|
|
|
72
72
|
// Validate defaultCurrency on entities that have money fields
|
|
73
73
|
for (const feature of config.features) {
|
|
74
|
-
for (const [entityName, entity] of Object.entries(feature.entities)) {
|
|
74
|
+
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
75
75
|
const hasMoneyField = Object.values(entity.fields).some((f) => f.type === "money");
|
|
76
76
|
if (entity.defaultCurrency && !currencies.includes(entity.defaultCurrency)) {
|
|
77
77
|
throw new Error(
|
|
@@ -935,7 +935,11 @@ export function defineFeature<const TName extends string, TExports = undefined>(
|
|
|
935
935
|
postDelete: phasedLifecycleHooks.postDelete,
|
|
936
936
|
preQuery: lifecycleHooks["preQuery"] ?? {},
|
|
937
937
|
postQuery: lifecycleHooks["postQuery"] ?? {},
|
|
938
|
-
|
|
938
|
+
// @cast-boundary engine-bridge — die Hook-Registrierung erased die
|
|
939
|
+
// per-Slot-Signaturen zu LifecycleHookFn (Union, s. Cast in
|
|
940
|
+
// addLifecycleHook); die Branches dort sind die einzigen Producer und
|
|
941
|
+
// schreiben pro Slot typrichtig.
|
|
942
|
+
} as HookMap,
|
|
939
943
|
entityHooks: {
|
|
940
944
|
postSave: entityPostSave,
|
|
941
945
|
preDelete: entityPreDelete,
|
|
@@ -378,6 +378,7 @@ export function extractEntityHook(
|
|
|
378
378
|
});
|
|
379
379
|
}
|
|
380
380
|
|
|
381
|
+
// guard:dup-ok — intentionale Parallele zu extractTree (round6); verschiedene Feature-AST-Extraktoren by design
|
|
381
382
|
export function extractAuthClaims(
|
|
382
383
|
call: CallExpression,
|
|
383
384
|
sourceFile: SourceFile,
|
package/src/engine/index.ts
CHANGED
|
@@ -293,6 +293,7 @@ export type {
|
|
|
293
293
|
QueryEvent,
|
|
294
294
|
QueryHandlerDef,
|
|
295
295
|
QueryHandlerFn,
|
|
296
|
+
ReferenceDataDef,
|
|
296
297
|
Registry,
|
|
297
298
|
RelationDefinition,
|
|
298
299
|
RetentionDef,
|
|
@@ -300,6 +301,7 @@ export type {
|
|
|
300
301
|
SaveContext,
|
|
301
302
|
ScreenDefinition,
|
|
302
303
|
ScreenSlots,
|
|
304
|
+
SecretKeyHandle,
|
|
303
305
|
SelectFieldDef,
|
|
304
306
|
SessionUser,
|
|
305
307
|
Subscribe,
|
package/src/engine/registry.ts
CHANGED
|
@@ -306,9 +306,10 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
306
306
|
featureName: string,
|
|
307
307
|
hookQnType: QnType,
|
|
308
308
|
): void {
|
|
309
|
-
// skip: optionaler hook-slot — defineFeature
|
|
310
|
-
//
|
|
311
|
-
//
|
|
309
|
+
// skip: optionaler hook-slot — defineFeature materialisiert zwar alle
|
|
310
|
+
// Slots, aber hand-gebaute Definitionen an System-Grenzen (Fixtures,
|
|
311
|
+
// Partial-Boots, s. registry.test.ts) lassen sie weg. Leeres Record
|
|
312
|
+
// statt Object.entries(undefined)-Crash.
|
|
312
313
|
if (!source) return;
|
|
313
314
|
for (const [name, fns] of Object.entries(source)) {
|
|
314
315
|
const qualified = qualify(featureName, hookQnType, name);
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
// rejected by boot-validation — domain mutation MUST go through
|
|
12
12
|
// r.step.aggregate.*.
|
|
13
13
|
|
|
14
|
+
import { extractTableName } from "../../db";
|
|
14
15
|
import { executeRawQuery } from "../../db/queries/raw-sql";
|
|
15
16
|
import { defineStep } from "../define-step";
|
|
16
17
|
import type { PipelineCtx, StepInstance, StepResolver } from "../types/step";
|
|
@@ -22,22 +23,10 @@ type UnsafeProjectionUpsertArgs = {
|
|
|
22
23
|
readonly row: StepResolver<Record<string, unknown>>;
|
|
23
24
|
};
|
|
24
25
|
|
|
25
|
-
// @cast-boundary drizzle-bridge — reads
|
|
26
|
-
//
|
|
27
|
-
const KUMIKO_NAME_SYMBOL = Symbol.for("kumiko:schema:Name");
|
|
26
|
+
// @cast-boundary drizzle-bridge — reads column snake_case names from
|
|
27
|
+
// drizzle Symbol-based metadata without importing drizzle-orm.
|
|
28
28
|
const KUMIKO_COLUMNS_SYMBOL = Symbol.for("kumiko:schema:Columns");
|
|
29
29
|
|
|
30
|
-
function resolveTableName(table: unknown): string {
|
|
31
|
-
if (typeof table !== "object" || table === null) {
|
|
32
|
-
throw new Error("unsafeProjectionUpsert: table is not an object");
|
|
33
|
-
}
|
|
34
|
-
const name = (table as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL];
|
|
35
|
-
if (typeof name !== "string") {
|
|
36
|
-
throw new Error("unsafeProjectionUpsert: table has no kumiko:schema:Name symbol");
|
|
37
|
-
}
|
|
38
|
-
return name;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
30
|
function resolveColumnName(table: unknown, field: string): string {
|
|
42
31
|
if (typeof table !== "object" || table === null) return field;
|
|
43
32
|
const cols = (table as Record<symbol, unknown>)[KUMIKO_COLUMNS_SYMBOL];
|
|
@@ -67,7 +56,7 @@ defineStep<UnsafeProjectionUpsertArgs, void>({
|
|
|
67
56
|
}
|
|
68
57
|
}
|
|
69
58
|
|
|
70
|
-
const tableName =
|
|
59
|
+
const tableName = extractTableName(args.table, "unsafeProjectionUpsert");
|
|
71
60
|
const entries = Object.entries(resolvedRow);
|
|
72
61
|
const params: unknown[] = [];
|
|
73
62
|
|
|
@@ -197,13 +197,17 @@ export type FeatureDefinition = {
|
|
|
197
197
|
// means the feature is always-on (e.g. auth, tenant, user — core infra
|
|
198
198
|
// that would brick the system if switchable).
|
|
199
199
|
readonly toggleableDefault?: boolean;
|
|
200
|
-
|
|
200
|
+
// entities/hooks/entityHooks are optional: defineFeature always
|
|
201
|
+
// materializes them, but hand-built definitions at system boundaries
|
|
202
|
+
// (test fixtures, partial boots — see registry.test.ts "slot robustness")
|
|
203
|
+
// omit them and the registry guards against that. Type follows runtime.
|
|
204
|
+
readonly entities?: Readonly<Record<string, EntityDefinition>>;
|
|
201
205
|
readonly relations: Readonly<Record<string, EntityRelations>>;
|
|
202
206
|
readonly writeHandlers: Readonly<Record<string, WriteHandlerDef>>;
|
|
203
207
|
readonly queryHandlers: Readonly<Record<string, QueryHandlerDef>>;
|
|
204
208
|
readonly translations: TranslationKeys;
|
|
205
|
-
readonly hooks
|
|
206
|
-
readonly entityHooks
|
|
209
|
+
readonly hooks?: HookMap;
|
|
210
|
+
readonly entityHooks?: EntityHookMap;
|
|
207
211
|
// F3 search-payload-extension — per-entity contributors that add flat fields
|
|
208
212
|
// to the search-index payload during indexing. Keyed by entityName. Wrapped
|
|
209
213
|
// in OwnedFn for feature-toggle filtering (consistent with postQuery-Hooks).
|
|
@@ -143,21 +143,25 @@ export type OwnedFn<TFn> = {
|
|
|
143
143
|
|
|
144
144
|
// --- Hook Maps ---
|
|
145
145
|
|
|
146
|
+
// Slots are optional: defineFeature materializes every slot, but hand-built
|
|
147
|
+
// FeatureDefinitions at system boundaries (test fixtures, partial boots —
|
|
148
|
+
// see registry.test.ts "slot robustness") legitimately omit them, and the
|
|
149
|
+
// registry merge paths tolerate undefined. The type mirrors that contract.
|
|
146
150
|
export type HookMap = {
|
|
147
|
-
readonly validation
|
|
148
|
-
readonly preSave
|
|
149
|
-
readonly postSave
|
|
150
|
-
readonly preDelete
|
|
151
|
-
readonly postDelete
|
|
152
|
-
readonly preQuery
|
|
153
|
-
readonly postQuery
|
|
151
|
+
readonly validation?: Readonly<Record<string, ValidationHookFn>>;
|
|
152
|
+
readonly preSave?: Readonly<Record<string, readonly OwnedFn<PreSaveHookFn>[]>>;
|
|
153
|
+
readonly postSave?: Readonly<Record<string, readonly PhasedHook<PostSaveHookFn>[]>>;
|
|
154
|
+
readonly preDelete?: Readonly<Record<string, readonly PhasedHook<PreDeleteHookFn>[]>>;
|
|
155
|
+
readonly postDelete?: Readonly<Record<string, readonly PhasedHook<PostDeleteHookFn>[]>>;
|
|
156
|
+
readonly preQuery?: Readonly<Record<string, readonly OwnedFn<PreQueryHookFn>[]>>;
|
|
157
|
+
readonly postQuery?: Readonly<Record<string, readonly OwnedFn<PostQueryHookFn>[]>>;
|
|
154
158
|
};
|
|
155
159
|
|
|
156
160
|
export type EntityHookMap = {
|
|
157
|
-
readonly postSave
|
|
158
|
-
readonly preDelete
|
|
159
|
-
readonly postDelete
|
|
160
|
-
readonly postQuery
|
|
161
|
+
readonly postSave?: Readonly<Record<string, readonly PhasedHook<PostSaveHookFn>[]>>;
|
|
162
|
+
readonly preDelete?: Readonly<Record<string, readonly PhasedHook<PreDeleteHookFn>[]>>;
|
|
163
|
+
readonly postDelete?: Readonly<Record<string, readonly PhasedHook<PostDeleteHookFn>[]>>;
|
|
164
|
+
readonly postQuery?: Readonly<Record<string, readonly OwnedFn<PostQueryHookFn>[]>>;
|
|
161
165
|
};
|
|
162
166
|
|
|
163
167
|
// Search-Payload-Extension (F3) — contributor function that adds flat
|
|
@@ -25,7 +25,7 @@ export type PlatformComponent = {
|
|
|
25
25
|
};
|
|
26
26
|
|
|
27
27
|
// Built-in value formatters. Apps extend via module augmentation:
|
|
28
|
-
// declare module "@cosmicdrift/kumiko-framework" {
|
|
28
|
+
// declare module "@cosmicdrift/kumiko-framework/engine/types" {
|
|
29
29
|
// interface FieldFormatRegistry { myFormat: { myOption?: string } }
|
|
30
30
|
// }
|
|
31
31
|
// renderer-web handles all built-in keys; unknown app-specific keys fall back
|
|
@@ -489,12 +489,7 @@ export type ScreenDefinition =
|
|
|
489
489
|
// authors who branch on the three FieldRenderer variants without manual
|
|
490
490
|
// "format" in renderer checks.
|
|
491
491
|
export function isFormatSpec(r: unknown): r is FormatSpec {
|
|
492
|
-
return
|
|
493
|
-
typeof r === "object" &&
|
|
494
|
-
r !== null &&
|
|
495
|
-
"format" in r &&
|
|
496
|
-
typeof (r as Record<string, unknown>)["format"] === "string"
|
|
497
|
-
);
|
|
492
|
+
return typeof r === "object" && r !== null && "format" in r && typeof r.format === "string";
|
|
498
493
|
}
|
|
499
494
|
|
|
500
495
|
// Collapse the string-shorthand into the object form. Both the boot-validator
|
|
@@ -112,7 +112,7 @@ export function validateProjectionAllowlist(features: readonly FeatureDefinition
|
|
|
112
112
|
// Followup #8.
|
|
113
113
|
const aggregateTables = new Map<string, string>();
|
|
114
114
|
for (const f of features) {
|
|
115
|
-
for (const [entityName, entity] of Object.entries(f.entities)) {
|
|
115
|
+
for (const [entityName, entity] of Object.entries(f.entities ?? {})) {
|
|
116
116
|
const tableName = entity.table ?? entityName;
|
|
117
117
|
const existing = aggregateTables.get(tableName);
|
|
118
118
|
if (existing && existing !== f.name) {
|
package/src/engine/validation.ts
CHANGED
|
@@ -17,7 +17,7 @@ export function runValidation(
|
|
|
17
17
|
for (const [featureName, feature] of registry.features) {
|
|
18
18
|
if (toKebab(featureName) !== parsed.scope) continue;
|
|
19
19
|
|
|
20
|
-
const validationHooks = feature.hooks
|
|
20
|
+
const validationHooks = feature.hooks?.validation;
|
|
21
21
|
if (!validationHooks) continue;
|
|
22
22
|
|
|
23
23
|
// Find the hook by matching the QN name segment against the stored short name.
|
package/src/errors/index.ts
CHANGED
|
@@ -29,10 +29,10 @@ export type { FrameworkReason } from "./reasons";
|
|
|
29
29
|
export { FrameworkReasons } from "./reasons";
|
|
30
30
|
export type { ErrorLogEntry, ErrorResponseBody } from "./serialize";
|
|
31
31
|
export { buildErrorLog, serializeError } from "./serialize";
|
|
32
|
+
export { toKumikoError } from "./to-kumiko-error";
|
|
32
33
|
export type { InvalidTransitionDetails } from "./transition-details";
|
|
33
34
|
export { buildInvalidTransitionDetails } from "./transition-details";
|
|
34
35
|
export type { WriteErrorInfo, WriteFailure } from "./write-error-info";
|
|
35
|
-
|
|
36
36
|
export {
|
|
37
37
|
failNotFound,
|
|
38
38
|
failTransition,
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { InternalError } from "./classes";
|
|
2
|
+
import { isKumikoError, type KumikoError } from "./kumiko-error";
|
|
3
|
+
|
|
4
|
+
export function toKumikoError(e: unknown): KumikoError {
|
|
5
|
+
if (isKumikoError(e)) return e;
|
|
6
|
+
if (e instanceof Error) return new InternalError({ cause: e });
|
|
7
|
+
return new InternalError({ message: String(e) });
|
|
8
|
+
}
|
|
@@ -31,6 +31,7 @@ export const archivedStreamsTable = pgTable(
|
|
|
31
31
|
}),
|
|
32
32
|
);
|
|
33
33
|
|
|
34
|
+
// guard:dup-ok — intentionale Parallele zu createSnapshotsTable; verschiedene Tabellen-Schemas by design
|
|
34
35
|
export async function createArchivedStreamsTable(db: DbConnection): Promise<void> {
|
|
35
36
|
// skip: table already exists — idempotent boot + test-setup call
|
|
36
37
|
if (await tableExists(db, "public.kumiko_archived_streams")) return;
|
|
@@ -14,6 +14,7 @@ import { stringifyJson } from "../utils/safe-json";
|
|
|
14
14
|
import { isStreamArchived } from "./archive";
|
|
15
15
|
import { VersionConflictError } from "./errors";
|
|
16
16
|
import { eventsTable } from "./events-schema";
|
|
17
|
+
import { toStoredEvent } from "./row-to-stored-event";
|
|
17
18
|
|
|
18
19
|
export type EventMetadata = {
|
|
19
20
|
readonly userId: string;
|
|
@@ -409,19 +410,3 @@ export async function* streamAllEventsByType(
|
|
|
409
410
|
cursorId = nextCursor;
|
|
410
411
|
}
|
|
411
412
|
}
|
|
412
|
-
|
|
413
|
-
function toStoredEvent(row: SelectedEvent): StoredEvent {
|
|
414
|
-
return {
|
|
415
|
-
id: String(row.id),
|
|
416
|
-
aggregateId: row.aggregateId,
|
|
417
|
-
aggregateType: row.aggregateType,
|
|
418
|
-
tenantId: row.tenantId,
|
|
419
|
-
version: row.version,
|
|
420
|
-
type: row.type,
|
|
421
|
-
eventVersion: row.eventVersion,
|
|
422
|
-
payload: row.payload,
|
|
423
|
-
metadata: row.metadata,
|
|
424
|
-
createdAt: row.createdAt,
|
|
425
|
-
createdBy: row.createdBy,
|
|
426
|
-
};
|
|
427
|
-
}
|
package/src/event-store/index.ts
CHANGED
|
@@ -24,6 +24,7 @@ export {
|
|
|
24
24
|
streamAllEventsByType,
|
|
25
25
|
} from "./event-store";
|
|
26
26
|
export { createEventsTable, eventsTable } from "./events-schema";
|
|
27
|
+
export { toStoredEvent } from "./row-to-stored-event";
|
|
27
28
|
export {
|
|
28
29
|
createSnapshotsTable,
|
|
29
30
|
type LoadAggregateWithSnapshotResult,
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { TenantId } from "../engine/types";
|
|
2
|
+
import type { EventMetadata, StoredEvent } from "./event-store";
|
|
3
|
+
|
|
4
|
+
// Minimal row shape accepted by toStoredEvent. Both SelectedEvent
|
|
5
|
+
// (event-store) and StoredEventRow (event-dispatcher) satisfy it.
|
|
6
|
+
type EventRow = {
|
|
7
|
+
readonly id: bigint;
|
|
8
|
+
readonly aggregateId: string;
|
|
9
|
+
readonly aggregateType: string;
|
|
10
|
+
readonly tenantId: TenantId;
|
|
11
|
+
readonly version: number;
|
|
12
|
+
readonly type: string;
|
|
13
|
+
readonly eventVersion: number;
|
|
14
|
+
readonly payload: Record<string, unknown>;
|
|
15
|
+
readonly metadata: EventMetadata;
|
|
16
|
+
readonly createdAt: Temporal.Instant;
|
|
17
|
+
readonly createdBy: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export function toStoredEvent(row: EventRow): StoredEvent {
|
|
21
|
+
return {
|
|
22
|
+
id: String(row.id),
|
|
23
|
+
aggregateId: row.aggregateId,
|
|
24
|
+
aggregateType: row.aggregateType,
|
|
25
|
+
tenantId: row.tenantId,
|
|
26
|
+
version: row.version,
|
|
27
|
+
type: row.type,
|
|
28
|
+
eventVersion: row.eventVersion,
|
|
29
|
+
payload: row.payload,
|
|
30
|
+
metadata: row.metadata,
|
|
31
|
+
createdAt: row.createdAt,
|
|
32
|
+
createdBy: row.createdBy,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
// createFallbackLogger Unit-Tests (Phase 1, test-luecken-integration).
|
|
2
2
|
//
|
|
3
|
-
// Pinnt beide Pfade des Fallback-Loggers
|
|
4
|
-
//
|
|
5
|
-
//
|
|
3
|
+
// Pinnt beide Pfade des Fallback-Loggers auf das identische
|
|
4
|
+
// "[ns] msg"-Format — wrapped logger und console-Fallback dürfen
|
|
5
|
+
// nicht divergieren (Log-Parser/Grep-Konsistenz).
|
|
6
6
|
|
|
7
7
|
import { describe, expect, mock, spyOn, test } from "bun:test";
|
|
8
8
|
import { createFallbackLogger } from "../utils";
|
|
@@ -30,7 +30,7 @@ describe("createFallbackLogger", () => {
|
|
|
30
30
|
});
|
|
31
31
|
|
|
32
32
|
describe("ohne logger (console-Fallback)", () => {
|
|
33
|
-
test("schreibt auf console.error mit [namespace]-Prefix
|
|
33
|
+
test("schreibt auf console.error mit [namespace]-Prefix im selben Format wie wrapped", () => {
|
|
34
34
|
const spy = spyOn(console, "error").mockImplementation(() => {});
|
|
35
35
|
try {
|
|
36
36
|
const fallback = createFallbackLogger("boot");
|
|
@@ -38,7 +38,7 @@ describe("createFallbackLogger", () => {
|
|
|
38
38
|
fallback.error("no logger wired", { phase: "init" });
|
|
39
39
|
|
|
40
40
|
expect(spy).toHaveBeenCalledTimes(1);
|
|
41
|
-
expect(spy).toHaveBeenCalledWith("[boot] no logger wired
|
|
41
|
+
expect(spy).toHaveBeenCalledWith("[boot] no logger wired", { phase: "init" });
|
|
42
42
|
} finally {
|
|
43
43
|
spy.mockRestore();
|
|
44
44
|
}
|
package/src/logging/utils.ts
CHANGED
|
@@ -18,7 +18,7 @@ export function createFallbackLogger(
|
|
|
18
18
|
return {
|
|
19
19
|
error(msg, data) {
|
|
20
20
|
// biome-ignore lint/suspicious/noConsole: ops-visible fallback when no logger is wired
|
|
21
|
-
console.error(`[${namespace}] ${msg}
|
|
21
|
+
console.error(`[${namespace}] ${msg}`, data);
|
|
22
22
|
},
|
|
23
23
|
};
|
|
24
24
|
}
|
|
@@ -5,31 +5,19 @@
|
|
|
5
5
|
// Drizzle-frei: der Tabellen-Name kommt aus dem kumiko-Symbol das
|
|
6
6
|
// buildEntityTable/buildEntityTableMeta an die Table-Definition hängt.
|
|
7
7
|
|
|
8
|
+
import { extractTableName } from "../db";
|
|
8
9
|
import type { Registry } from "../engine/types/feature";
|
|
9
10
|
|
|
10
|
-
const KUMIKO_NAME_SYMBOL = Symbol.for("kumiko:schema:Name");
|
|
11
|
-
|
|
12
|
-
function getTableName(table: unknown): string {
|
|
13
|
-
if (typeof table !== "object" || table === null) {
|
|
14
|
-
throw new Error("projection-table-index: table is not a table object");
|
|
15
|
-
}
|
|
16
|
-
const name = (table as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL];
|
|
17
|
-
if (typeof name !== "string") {
|
|
18
|
-
throw new Error("projection-table-index: table missing kumiko name symbol");
|
|
19
|
-
}
|
|
20
|
-
return name;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
11
|
/** Index `tableName → projection-name` aus der Registry. Nur Projections mit
|
|
24
12
|
* table-Definition (single-stream + multi-stream-with-table) zählen.
|
|
25
13
|
* Side-effect-only MSPs (table omitted) haben keinen Rebuild-Sinn. */
|
|
26
14
|
export function buildProjectionTableIndex(registry: Registry): ReadonlyMap<string, string> {
|
|
27
15
|
const index = new Map<string, string>();
|
|
28
16
|
for (const [name, def] of registry.getAllProjections()) {
|
|
29
|
-
index.set(
|
|
17
|
+
index.set(extractTableName(def.table, `projection-table-index(${name})`), name);
|
|
30
18
|
}
|
|
31
19
|
for (const [name, def] of registry.getAllMultiStreamProjections()) {
|
|
32
|
-
if (def.table) index.set(
|
|
20
|
+
if (def.table) index.set(extractTableName(def.table, `projection-table-index(${name})`), name);
|
|
33
21
|
}
|
|
34
22
|
return index;
|
|
35
23
|
}
|
|
@@ -83,6 +83,26 @@ const archFeature = defineFeature("archtest", (r) => {
|
|
|
83
83
|
{ access: { roles: ["Admin"] } },
|
|
84
84
|
);
|
|
85
85
|
|
|
86
|
+
r.writeHandler(
|
|
87
|
+
"item:update",
|
|
88
|
+
z.object({ id: z.uuid(), label: z.string() }),
|
|
89
|
+
async (event, ctx) =>
|
|
90
|
+
executor.update(
|
|
91
|
+
{ id: event.payload.id, changes: { label: event.payload.label } },
|
|
92
|
+
event.user,
|
|
93
|
+
ctx.db,
|
|
94
|
+
{ skipOptimisticLock: true },
|
|
95
|
+
),
|
|
96
|
+
{ access: { roles: ["Admin"] } },
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
r.writeHandler(
|
|
100
|
+
"item:delete",
|
|
101
|
+
z.object({ id: z.uuid() }),
|
|
102
|
+
async (event, ctx) => executor.delete({ id: event.payload.id }, event.user, ctx.db),
|
|
103
|
+
{ access: { roles: ["Admin"] } },
|
|
104
|
+
);
|
|
105
|
+
|
|
86
106
|
r.queryHandler(
|
|
87
107
|
"item:events",
|
|
88
108
|
z.object({ id: z.uuid() }),
|
|
@@ -217,4 +237,59 @@ describe("archiveStream — Marten ArchiveStream equivalent", () => {
|
|
|
217
237
|
expect(err.tenantId).toBe(admin.tenantId);
|
|
218
238
|
expect(err.name).toBe("ArchivedStreamError");
|
|
219
239
|
});
|
|
240
|
+
|
|
241
|
+
// The CRUD executor appends via append() + getStreamVersion(), neither of
|
|
242
|
+
// which consults the archive flag. Without the executor-level guard a
|
|
243
|
+
// PATCH/DELETE would silently land an event on an archived stream — the
|
|
244
|
+
// read-only contract honoured by ctx.appendEvent would not extend to
|
|
245
|
+
// entity-CRUD writes. These prove the guard closes that gap on both paths.
|
|
246
|
+
describe("CRUD writes honour the archive guard", () => {
|
|
247
|
+
test("executor.update on an archived stream is rejected, no event lands", async () => {
|
|
248
|
+
const { id } = await stack.http.writeOk<{ id: string }>(
|
|
249
|
+
"archtest:write:item:create",
|
|
250
|
+
{ label: "before-archive" },
|
|
251
|
+
admin,
|
|
252
|
+
);
|
|
253
|
+
await stack.http.writeOk("archtest:write:item:archive", { id }, admin);
|
|
254
|
+
|
|
255
|
+
const res = await stack.http.write(
|
|
256
|
+
"archtest:write:item:update",
|
|
257
|
+
{ id, label: "too-late" },
|
|
258
|
+
admin,
|
|
259
|
+
);
|
|
260
|
+
expect(res.status).toBe(500);
|
|
261
|
+
|
|
262
|
+
const raw = await loadAggregateRaw(stack.db, id, admin.tenantId, { includeArchived: true });
|
|
263
|
+
expect(raw.map((e) => e.type)).not.toContain("arch-item.updated");
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("executor.delete on an archived stream is rejected, no event lands", async () => {
|
|
267
|
+
const { id } = await stack.http.writeOk<{ id: string }>(
|
|
268
|
+
"archtest:write:item:create",
|
|
269
|
+
{ label: "keep-me" },
|
|
270
|
+
admin,
|
|
271
|
+
);
|
|
272
|
+
await stack.http.writeOk("archtest:write:item:archive", { id }, admin);
|
|
273
|
+
|
|
274
|
+
const res = await stack.http.write("archtest:write:item:delete", { id }, admin);
|
|
275
|
+
expect(res.status).toBe(500);
|
|
276
|
+
|
|
277
|
+
const raw = await loadAggregateRaw(stack.db, id, admin.tenantId, { includeArchived: true });
|
|
278
|
+
expect(raw.map((e) => e.type)).not.toContain("arch-item.deleted");
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test("restoreStream re-opens the stream for CRUD updates", async () => {
|
|
282
|
+
const { id } = await stack.http.writeOk<{ id: string }>(
|
|
283
|
+
"archtest:write:item:create",
|
|
284
|
+
{ label: "v1" },
|
|
285
|
+
admin,
|
|
286
|
+
);
|
|
287
|
+
await stack.http.writeOk("archtest:write:item:archive", { id }, admin);
|
|
288
|
+
await stack.http.writeOk("archtest:write:item:restore", { id }, admin);
|
|
289
|
+
|
|
290
|
+
await stack.http.writeOk("archtest:write:item:update", { id, label: "v2" }, admin);
|
|
291
|
+
const raw = await loadAggregateRaw(stack.db, id, admin.tenantId);
|
|
292
|
+
expect(raw.map((e) => e.type)).toEqual(["arch-item.created", "arch-item.updated"]);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
220
295
|
});
|