@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.
Files changed (55) hide show
  1. package/package.json +2 -2
  2. package/src/api/routes.ts +2 -7
  3. package/src/api/server.ts +1 -1
  4. package/src/bun-db/connection.ts +1 -0
  5. package/src/bun-db/index.ts +0 -1
  6. package/src/db/__tests__/schema-migration.integration.test.ts +1 -1
  7. package/src/db/__tests__/table-builder-meta-lockstep.test.ts +42 -1
  8. package/src/db/__tests__/tenant-db-where-merge.test.ts +34 -0
  9. package/src/db/collect-table-metas.ts +2 -2
  10. package/src/db/connection.ts +1 -0
  11. package/src/db/dialect.ts +16 -3
  12. package/src/db/event-store-executor.ts +29 -0
  13. package/src/db/index.ts +1 -0
  14. package/src/db/query.ts +1 -0
  15. package/src/db/tenant-db.ts +14 -4
  16. package/src/engine/__tests__/build-app-schema.test.ts +31 -3
  17. package/src/engine/__tests__/engine.test.ts +3 -3
  18. package/src/engine/__tests__/hook-phases.test.ts +5 -5
  19. package/src/engine/__tests__/lifecycle-hooks.test.ts +8 -8
  20. package/src/engine/__tests__/post-query-hook.test.ts +3 -3
  21. package/src/engine/__tests__/validation-hooks.test.ts +2 -2
  22. package/src/engine/boot-validator/entity-handler.ts +14 -11
  23. package/src/engine/boot-validator/ownership.ts +1 -1
  24. package/src/engine/boot-validator/pii-retention.ts +1 -1
  25. package/src/engine/boot-validator/screens-nav.ts +9 -6
  26. package/src/engine/build-app-schema.ts +42 -12
  27. package/src/engine/create-app.ts +1 -1
  28. package/src/engine/define-feature.ts +5 -1
  29. package/src/engine/feature-ast/extractors/round4.ts +1 -0
  30. package/src/engine/index.ts +2 -0
  31. package/src/engine/registry.ts +4 -3
  32. package/src/engine/steps/unsafe-projection-upsert.ts +4 -15
  33. package/src/engine/types/feature.ts +7 -3
  34. package/src/engine/types/hooks.ts +15 -11
  35. package/src/engine/types/screen.ts +2 -7
  36. package/src/engine/validate-projection-allowlist.ts +1 -1
  37. package/src/engine/validation.ts +1 -1
  38. package/src/errors/index.ts +1 -1
  39. package/src/errors/to-kumiko-error.ts +8 -0
  40. package/src/event-store/archive.ts +1 -0
  41. package/src/event-store/event-store.ts +1 -16
  42. package/src/event-store/index.ts +1 -0
  43. package/src/event-store/row-to-stored-event.ts +34 -0
  44. package/src/logging/__tests__/fallback-logger.test.ts +5 -5
  45. package/src/logging/utils.ts +1 -1
  46. package/src/migrations/projection-table-index.ts +3 -15
  47. package/src/pipeline/__tests__/archive-stream.integration.test.ts +75 -0
  48. package/src/pipeline/dispatcher-utils.ts +2 -12
  49. package/src/pipeline/event-consumer-state.ts +1 -0
  50. package/src/pipeline/event-dispatcher.ts +21 -42
  51. package/src/pipeline/msp-rebuild.ts +8 -19
  52. package/src/pipeline/projection-rebuild.ts +2 -13
  53. package/src/pipeline/system-hooks.ts +17 -4
  54. package/src/random/words.ts +4 -3
  55. 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.react === undefined && section.component.native === undefined) {
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.react === undefined && section.component.native === undefined) {
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 = Object.keys(feature.entities).sort().join(", ") || "(none)";
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.react === undefined && section.component.native === undefined) {
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
- try {
69
- const roundTripped = JSON.parse(JSON.stringify(schema));
70
- if (JSON.stringify(roundTripped) !== JSON.stringify(schema)) {
71
- // biome-ignore lint/suspicious/noConsole: dev-only assertion
72
- console.error(
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
- "[kumiko] buildAppSchema: JSON.stringify fehlgeschlagen Schema enthält nicht-serialisierbare Werte.",
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>> {
@@ -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
- } as HookMap, // @cast-boundary engine-payload
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,
@@ -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,
@@ -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 lässt das slot undefined
310
- // wenn das feature keine hooks dieses typs hat. Behandeln wie leeres
311
- // record statt Object.entries(undefined)-crash.
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 table name + column snake_case
26
- // names from drizzle Symbol-based metadata without importing drizzle-orm.
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 = resolveTableName(args.table);
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
- readonly entities: Readonly<Record<string, EntityDefinition>>;
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: HookMap;
206
- readonly entityHooks: EntityHookMap;
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: Readonly<Record<string, ValidationHookFn>>;
148
- readonly preSave: Readonly<Record<string, readonly OwnedFn<PreSaveHookFn>[]>>;
149
- readonly postSave: Readonly<Record<string, readonly PhasedHook<PostSaveHookFn>[]>>;
150
- readonly preDelete: Readonly<Record<string, readonly PhasedHook<PreDeleteHookFn>[]>>;
151
- readonly postDelete: Readonly<Record<string, readonly PhasedHook<PostDeleteHookFn>[]>>;
152
- readonly preQuery: Readonly<Record<string, readonly OwnedFn<PreQueryHookFn>[]>>;
153
- readonly postQuery: Readonly<Record<string, readonly OwnedFn<PostQueryHookFn>[]>>;
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: Readonly<Record<string, readonly PhasedHook<PostSaveHookFn>[]>>;
158
- readonly preDelete: Readonly<Record<string, readonly PhasedHook<PreDeleteHookFn>[]>>;
159
- readonly postDelete: Readonly<Record<string, readonly PhasedHook<PostDeleteHookFn>[]>>;
160
- readonly postQuery: Readonly<Record<string, readonly OwnedFn<PostQueryHookFn>[]>>;
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) {
@@ -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.validation;
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.
@@ -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
- }
@@ -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 inkl. des non-obvious
4
- // Format-Unterschieds: der wrapped-Pfad schreibt "[ns] msg", der
5
- // console-Fallback "[ns] msg:" (trailing colon).
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 UND trailing colon", () => {
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:", { phase: "init" });
41
+ expect(spy).toHaveBeenCalledWith("[boot] no logger wired", { phase: "init" });
42
42
  } finally {
43
43
  spy.mockRestore();
44
44
  }
@@ -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}:`, data);
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(getTableName(def.table), name);
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(getTableName(def.table), name);
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
  });