@cosmicdrift/kumiko-framework 0.36.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
@@ -6,13 +6,7 @@ import type {
6
6
  SessionUser,
7
7
  WriteResult,
8
8
  } from "../engine/types";
9
- import {
10
- type FieldIssue,
11
- InternalError,
12
- isKumikoError,
13
- type KumikoError,
14
- type WriteErrorInfo,
15
- } from "../errors";
9
+ import { type FieldIssue, toKumikoError, type WriteErrorInfo } from "../errors";
16
10
 
17
11
  export type FailedWriteResult = Extract<WriteResult, { isSuccess: false }>;
18
12
 
@@ -182,8 +176,4 @@ export function resolveType(type: HandlerType): string {
182
176
  return typeof type === "string" ? type : type.name;
183
177
  }
184
178
 
185
- export function wrapToKumiko(e: unknown): KumikoError {
186
- if (isKumikoError(e)) return e;
187
- if (e instanceof Error) return new InternalError({ cause: e });
188
- return new InternalError({ message: String(e) });
189
- }
179
+ export const wrapToKumiko = toKumikoError;
@@ -97,6 +97,7 @@ export type ConsumerStatus = (typeof ConsumerStatuses)[keyof typeof ConsumerStat
97
97
  // table is already present (second stack in the same test DB, prod boot
98
98
  // after migration), skip cleanly.
99
99
  //
100
+ // guard:dup-ok — intentionale Parallele zu createProjectionStateTable; symmetrische State-Tabellen by design
100
101
  export async function createEventConsumerStateTable(db: DbConnection): Promise<void> {
101
102
  // skip: table already exists — bootstrap is called from multiple paths
102
103
  if (await tableExists(db, "public.kumiko_event_consumers")) return;
@@ -16,6 +16,7 @@ import {
16
16
  EVENTS_PUBSUB_CHANNEL,
17
17
  eventsTable,
18
18
  getEventsHighWaterMark,
19
+ toStoredEvent as rowToStoredEvent,
19
20
  type StoredEvent,
20
21
  } from "../event-store";
21
22
  import {
@@ -298,22 +299,6 @@ type DeliveryOutcome = {
298
299
  readonly failed: number;
299
300
  };
300
301
 
301
- function rowToStoredEvent(row: StoredEventRow): StoredEvent {
302
- return {
303
- id: String(row.id),
304
- aggregateId: row.aggregateId,
305
- aggregateType: row.aggregateType,
306
- tenantId: row.tenantId,
307
- version: row.version,
308
- type: row.type,
309
- eventVersion: row.eventVersion,
310
- payload: row.payload,
311
- metadata: row.metadata,
312
- createdAt: row.createdAt,
313
- createdBy: row.createdBy,
314
- };
315
- }
316
-
317
302
  // Deliver events to the consumer's handler in events.id order. Halt-on-
318
303
  // poison: a throw breaks the loop, the cursor stays at the last successful
319
304
  // event, and attempts climb. At maxAttempts the caller persists status=
@@ -743,18 +728,13 @@ async function requireConsumerRow(
743
728
  return row;
744
729
  }
745
730
 
746
- export async function restartConsumer(
731
+ async function applyConsumerStatusTransition(
747
732
  db: DbConnection,
748
733
  name: string,
749
- instanceId: string = SHARED_INSTANCE_SENTINEL,
734
+ instanceId: string,
735
+ targetStatus: "idle" | "disabled",
750
736
  ): Promise<ConsumerRecoveryState> {
751
- const before = await requireConsumerRow(db, name, instanceId);
752
- if (before.status !== "dead") {
753
- throw new Error(
754
- `Consumer "${name}" (instance_id="${instanceId}") is not dead (status="${before.status}"). Restart only applies to dead consumers; use "enable" for a disabled one.`,
755
- );
756
- }
757
- const raw = await updateConsumerStatusReturning(db, name, instanceId, "idle");
737
+ const raw = await updateConsumerStatusReturning(db, name, instanceId, targetStatus);
758
738
  const updated =
759
739
  raw && (coerceRow(raw, extractTableInfo(eventConsumerStateTable)) as ConsumerStateRow);
760
740
  if (!updated) {
@@ -765,21 +745,27 @@ export async function restartConsumer(
765
745
  return normalizeConsumerState(updated);
766
746
  }
767
747
 
768
- export async function disableConsumer(
748
+ export async function restartConsumer(
769
749
  db: DbConnection,
770
750
  name: string,
771
751
  instanceId: string = SHARED_INSTANCE_SENTINEL,
772
752
  ): Promise<ConsumerRecoveryState> {
773
- await requireConsumerRow(db, name, instanceId);
774
- const raw = await updateConsumerStatusReturning(db, name, instanceId, "disabled");
775
- const updated =
776
- raw && (coerceRow(raw, extractTableInfo(eventConsumerStateTable)) as ConsumerStateRow);
777
- if (!updated) {
753
+ const before = await requireConsumerRow(db, name, instanceId);
754
+ if (before.status !== "dead") {
778
755
  throw new Error(
779
- `Consumer "${name}" (instance_id="${instanceId}") vanished between read and write retry.`,
756
+ `Consumer "${name}" (instance_id="${instanceId}") is not dead (status="${before.status}"). Restart only applies to dead consumers; use "enable" for a disabled one.`,
780
757
  );
781
758
  }
782
- return normalizeConsumerState(updated);
759
+ return applyConsumerStatusTransition(db, name, instanceId, "idle");
760
+ }
761
+
762
+ export async function disableConsumer(
763
+ db: DbConnection,
764
+ name: string,
765
+ instanceId: string = SHARED_INSTANCE_SENTINEL,
766
+ ): Promise<ConsumerRecoveryState> {
767
+ await requireConsumerRow(db, name, instanceId);
768
+ return applyConsumerStatusTransition(db, name, instanceId, "disabled");
783
769
  }
784
770
 
785
771
  export async function enableConsumer(
@@ -793,15 +779,7 @@ export async function enableConsumer(
793
779
  `Consumer "${name}" (instance_id="${instanceId}") is not disabled (status="${before.status}"). Enable only flips disabled → idle; use "restart" for a dead consumer.`,
794
780
  );
795
781
  }
796
- const raw = await updateConsumerStatusReturning(db, name, instanceId, "idle");
797
- const updated =
798
- raw && (coerceRow(raw, extractTableInfo(eventConsumerStateTable)) as ConsumerStateRow);
799
- if (!updated) {
800
- throw new Error(
801
- `Consumer "${name}" (instance_id="${instanceId}") vanished between read and write — retry.`,
802
- );
803
- }
804
- return normalizeConsumerState(updated);
782
+ return applyConsumerStatusTransition(db, name, instanceId, "idle");
805
783
  }
806
784
 
807
785
  // skipPoisonEvent advances the cursor past the first event after the
@@ -948,6 +926,7 @@ export type ConsumerProgress = {
948
926
  // post-commit — lag is the primary signal for backpressure, dead consumers,
949
927
  // or dispatcher stalls. Programmatic callers can map the result to a
950
928
  // `kumiko_consumer_lag{name}` Prometheus gauge.
929
+ // guard:dup-ok — intentionale Parallele zu getAllProjectionProgress; Consumer ≠ Projection (verschiedene Subsysteme)
951
930
  export async function getAllConsumerProgress(
952
931
  db: DbConnection,
953
932
  registeredNames: readonly string[],
@@ -1,3 +1,4 @@
1
+ import { extractTableName } from "../db";
1
2
  import type { DbConnection, DbRunner, DbTx } from "../db/connection";
2
3
  import {
3
4
  markConsumerRebuildFailed,
@@ -91,16 +92,16 @@ export async function rebuildMultiStreamProjection(
91
92
  const { db, registry } = deps;
92
93
  const msp = registry.getAllMultiStreamProjections().get(mspName);
93
94
  if (!msp) {
94
- throw new Error(
95
- `MultiStreamProjection "${mspName}" is not registered. Known: ${
95
+ throw new InternalError({
96
+ message: `MultiStreamProjection "${mspName}" is not registered. Known: ${
96
97
  [...registry.getAllMultiStreamProjections().keys()].join(", ") || "(none)"
97
98
  }`,
98
- );
99
+ });
99
100
  }
100
101
  if (!msp.table) {
101
- throw new Error(
102
- `MultiStreamProjection "${mspName}" has no backing table — it is a pure side-effect consumer (webhooks, notifications, external sync). Rebuild would re-invoke those side-effects by replaying the log. For poison events use bun kumiko consumer skip / restart; there is no analogous "rebuild" concept for side-effect sinks.`,
103
- );
102
+ throw new InternalError({
103
+ message: `MultiStreamProjection "${mspName}" has no backing table — it is a pure side-effect consumer (webhooks, notifications, external sync). Rebuild would re-invoke those side-effects by replaying the log. For poison events use bun kumiko consumer skip / restart; there is no analogous "rebuild" concept for side-effect sinks.`,
104
+ });
104
105
  }
105
106
 
106
107
  const startedAt = Date.now();
@@ -113,7 +114,7 @@ export async function rebuildMultiStreamProjection(
113
114
  await selectConsumerForUpdate(tx, mspName, SHARED_INSTANCE_SENTINEL);
114
115
 
115
116
  const mspTable = msp.table as NonNullable<typeof msp.table>;
116
- const tableName = getTableName(mspTable);
117
+ const tableName = extractTableName(mspTable, "msp-rebuild");
117
118
  await truncateTable(tx, tableName);
118
119
 
119
120
  const subscribedTypes = Object.keys(msp.apply);
@@ -204,15 +205,3 @@ export async function rebuildMultiStreamProjection(
204
205
  deps.onMetrics?.(result);
205
206
  return result;
206
207
  }
207
-
208
- const KUMIKO_NAME_SYMBOL = Symbol.for("kumiko:schema:Name");
209
- function getTableName(table: unknown): string {
210
- if (typeof table !== "object" || table === null) {
211
- throw new InternalError({ message: "msp-rebuild: msp.table is not a pgTable object" });
212
- }
213
- const name = (table as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL];
214
- if (typeof name !== "string") {
215
- throw new InternalError({ message: "msp-rebuild: msp.table missing drizzle name symbol" });
216
- }
217
- return name;
218
- }
@@ -1,3 +1,4 @@
1
+ import { extractTableName } from "../db";
1
2
  import type { DbConnection, DbTx } from "../db/connection";
2
3
  import {
3
4
  finalizeProjectionRebuild,
@@ -96,7 +97,7 @@ export async function rebuildProjection(
96
97
  await db.begin(async (tx: DbTx) => {
97
98
  await markProjectionRebuilding(tx, projectionName);
98
99
 
99
- const tableName = getTableName(projection.table);
100
+ const tableName = extractTableName(projection.table, "projection-rebuild");
100
101
  await truncateTable(tx, tableName);
101
102
 
102
103
  // Stream events in chronological order for every source. The event
@@ -199,18 +200,6 @@ export async function rebuildProjection(
199
200
  return result;
200
201
  }
201
202
 
202
- const KUMIKO_NAME_SYMBOL = Symbol.for("kumiko:schema:Name");
203
- function getTableName(table: unknown): string {
204
- if (typeof table !== "object" || table === null) {
205
- throw new Error("projection-rebuild: projection.table is not a pgTable object");
206
- }
207
- const name = (table as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL];
208
- if (typeof name !== "string") {
209
- throw new Error("projection-rebuild: projection.table missing drizzle name symbol");
210
- }
211
- return name;
212
- }
213
-
214
203
  // Read-only status for one projection. Returns null if the projection was
215
204
  // registered but never rebuilt (no row yet).
216
205
  export async function getProjectionState(
@@ -95,6 +95,21 @@ function reconstructStateForSearch(
95
95
  return (payload["previous"] as Record<string, unknown> | undefined) ?? {}; // @cast-boundary engine-payload
96
96
  }
97
97
 
98
+ // buildSearchDocument runs per save — without dedup a colliding contributor
99
+ // key would spam one warn line per write on the hotpath.
100
+ const warnedKeyCollisions = new Set<string>();
101
+ function warnOncePerKeyCollision(entityName: string, key: string, isBaseField: boolean): void {
102
+ const dedupKey = `${entityName}:${key}`;
103
+ if (!warnedKeyCollisions.has(dedupKey)) {
104
+ warnedKeyCollisions.add(dedupKey);
105
+ const collidesWith = isBaseField ? `Stammfield "${key}"` : `earlier contributor key "${key}"`;
106
+ console.warn(
107
+ `[kumiko:search] searchPayloadExtension on "${entityName}" tried to overwrite ` +
108
+ `${collidesWith} — keeping the first value. Rename the contributor key.`,
109
+ );
110
+ }
111
+ }
112
+
98
113
  // Build a SearchDocument from raw field-state. Parallel to the old
99
114
  // buildSearchDocument that took a SaveContext — same selector logic, just
100
115
  // a different input shape.
@@ -149,14 +164,12 @@ export async function buildSearchDocument(
149
164
  // searchable Stammfield is dropped (not silently merged over the real value)
150
165
  // and warned. A jsonb custom-field that happens to share a Stammfield name
151
166
  // must not shadow the indexed Stammfield.
167
+ const baseFieldKeys = new Set(Object.keys(fields));
152
168
  for (const contribute of extensions) {
153
169
  const contributed = await contribute({ entityName, entityId, state });
154
170
  for (const [key, value] of Object.entries(contributed)) {
155
171
  if (Object.hasOwn(fields, key)) {
156
- console.warn(
157
- `[kumiko:search] searchPayloadExtension on "${entityName}" tried to overwrite ` +
158
- `Stammfield "${key}" — keeping the base field. Rename the contributor key.`,
159
- );
172
+ warnOncePerKeyCollision(entityName, key, baseFieldKeys.has(key));
160
173
  continue;
161
174
  }
162
175
  fields[key] = value;
@@ -13,9 +13,10 @@
13
13
  // - Aussprechbar in Deutsch UND Englisch (User-Telefon-Support)
14
14
  // - Keine Wörter mit ambiguer Bedeutung in Englisch+Deutsch
15
15
  //
16
- // 191 × 173 = 33.043 saubere Kombinationen bei einer Standard-
17
- // Hashing-Kollision (Birthday-Bound) reicht das für ~180 Tenants ohne
18
- // Suffix. Drüber kommt der Suffix-Pfad in generateUniqueName.
16
+ // 191 × 173 = 33.043 saubere Kombinationen. Birthday-Bound: ab ~180
17
+ // Tenants wird kumulativ die erste Kollision wahrscheinlich (~50%);
18
+ // pro Einzel-Draw bleibt p(Kollision) < 1% (vgl. generate.ts). Bei
19
+ // Kollision greift der Suffix-Pfad in generateUniqueName.
19
20
  //
20
21
  // Erweiterung: weitere Wörter unten anhängen reicht (sortiert ist
21
22
  // hilfreich für Reviews aber nicht erforderlich). Caller können auch
@@ -212,7 +212,7 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
212
212
  if (enabledHooks.includes("search")) {
213
213
  const searchableFields: string[] = [];
214
214
  for (const feature of options.features) {
215
- for (const [, entity] of Object.entries(feature.entities)) {
215
+ for (const [, entity] of Object.entries(feature.entities ?? {})) {
216
216
  for (const [fieldName, field] of Object.entries(entity.fields)) {
217
217
  if (field.type === "text" && field.searchable) {
218
218
  searchableFields.push(fieldName);