@cosmicdrift/kumiko-framework 0.158.2 → 0.160.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 (168) hide show
  1. package/package.json +7 -2
  2. package/src/__tests__/consumer-cli.integration.test.ts +110 -0
  3. package/src/api/__tests__/api.test.ts +65 -0
  4. package/src/api/__tests__/auth-routes-cookie.test.ts +17 -1
  5. package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +237 -0
  6. package/src/api/__tests__/auth-routes-mfa-verify.test.ts +1 -0
  7. package/src/api/__tests__/csrf-constants-sync.test.ts +20 -0
  8. package/src/api/__tests__/dispatcher-live.integration.test.ts +74 -0
  9. package/src/api/__tests__/jwt.test.ts +150 -1
  10. package/src/api/__tests__/login-rate-limiter-sweep.test.ts +41 -0
  11. package/src/api/__tests__/server-boot-guards.test.ts +71 -0
  12. package/src/api/__tests__/server-jwt-ttl.test.ts +58 -0
  13. package/src/api/api-constants.ts +5 -0
  14. package/src/api/auth-middleware.ts +48 -59
  15. package/src/api/auth-routes.ts +51 -17
  16. package/src/api/index.ts +3 -3
  17. package/src/api/jwt.ts +148 -7
  18. package/src/api/pii-leak-guard.ts +5 -2
  19. package/src/api/routes.ts +57 -0
  20. package/src/api/server.ts +19 -5
  21. package/src/bun-db/__tests__/select-many-retry.test.ts +79 -0
  22. package/src/bun-db/query.ts +46 -27
  23. package/src/consumer-cli.ts +87 -0
  24. package/src/crypto/__tests__/pii-field-encryption.test.ts +69 -13
  25. package/src/crypto/blind-index.ts +8 -4
  26. package/src/crypto/event-pii.ts +1 -0
  27. package/src/crypto/kms-adapter.ts +2 -118
  28. package/src/crypto/pii-field-encryption.ts +49 -15
  29. package/src/db/__tests__/build-filter-where.test.ts +34 -0
  30. package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +67 -0
  31. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +396 -0
  32. package/src/db/__tests__/event-store-executor.integration.test.ts +5 -5
  33. package/src/db/blind-index-cleanup.ts +3 -1
  34. package/src/db/connection.ts +3 -11
  35. package/src/db/cursor.ts +1 -18
  36. package/src/db/dialect.ts +8 -19
  37. package/src/db/encryption.ts +2 -3
  38. package/src/db/entity-table-meta-types.ts +2 -0
  39. package/src/db/entity-table-meta.ts +16 -90
  40. package/src/db/event-store-executor.ts +4 -96
  41. package/src/db/queries/backfill-pii.ts +1 -0
  42. package/src/db/queries/event-consumer.ts +35 -2
  43. package/src/db/table-builder.ts +2 -19
  44. package/src/db/tenant-db.ts +6 -55
  45. package/src/engine/__tests__/boot-validator-boot-check.test.ts +99 -0
  46. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +7 -233
  47. package/src/engine/__tests__/boot-validator.test.ts +46 -0
  48. package/src/engine/__tests__/codemod-pipeline.test.ts +139 -10
  49. package/src/engine/__tests__/define-roles.test.ts +21 -0
  50. package/src/engine/__tests__/engine.test.ts +28 -0
  51. package/src/engine/__tests__/event-type-map-augmentation.test.ts +24 -0
  52. package/src/engine/__tests__/registry-facade-sweep.test.ts +80 -0
  53. package/src/engine/__tests__/registry.test.ts +40 -0
  54. package/src/engine/__tests__/store-table.test.ts +12 -0
  55. package/src/engine/__tests__/tier-resolver-extension.test.ts +19 -1
  56. package/src/engine/boot-validator/action-wiring.ts +1 -1
  57. package/src/engine/boot-validator/boot-check.ts +21 -0
  58. package/src/engine/boot-validator/entity-handler.ts +10 -1
  59. package/src/engine/boot-validator/entity-list-screens.ts +1 -1
  60. package/src/engine/boot-validator/gdpr-storage.ts +0 -112
  61. package/src/engine/boot-validator/index.ts +3 -9
  62. package/src/engine/boot-validator/screens.ts +1 -1
  63. package/src/engine/define-feature.ts +2 -0
  64. package/src/engine/define-handler.ts +11 -91
  65. package/src/engine/entity-handlers.ts +15 -27
  66. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +11 -1
  67. package/src/engine/feature-ast/__tests__/parse.test.ts +983 -3
  68. package/src/engine/feature-ast/__tests__/patch.test.ts +168 -0
  69. package/src/engine/feature-ast/__tests__/patcher.test.ts +7 -0
  70. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +9 -0
  71. package/src/engine/feature-ast/extractors/handlers.ts +19 -2
  72. package/src/engine/feature-ast/extractors/index.ts +1 -0
  73. package/src/engine/feature-ast/index.ts +2 -0
  74. package/src/engine/feature-ast/parse.ts +3 -0
  75. package/src/engine/feature-ast/patch.ts +2 -0
  76. package/src/engine/feature-ast/patcher.ts +21 -0
  77. package/src/engine/feature-ast/patterns.ts +16 -0
  78. package/src/engine/feature-ast/render.ts +15 -0
  79. package/src/engine/feature-builder-state.ts +6 -0
  80. package/src/engine/feature-config-events-jobs.ts +1 -1
  81. package/src/engine/feature-entity-handlers.ts +36 -2
  82. package/src/engine/feature-ui-extensions.ts +5 -1
  83. package/src/engine/field-helpers.ts +31 -0
  84. package/src/engine/handler-helpers.ts +26 -0
  85. package/src/engine/hook-helpers.ts +14 -0
  86. package/src/engine/index.ts +5 -2
  87. package/src/engine/ownership.ts +22 -76
  88. package/src/engine/pattern-library/__tests__/library.test.ts +9 -0
  89. package/src/engine/pattern-library/library.ts +2 -0
  90. package/src/engine/pattern-library/mixed-schemas.ts +37 -0
  91. package/src/engine/registry-facade.ts +9 -0
  92. package/src/engine/registry-ingest.ts +10 -0
  93. package/src/engine/registry-state.ts +3 -0
  94. package/src/engine/registry-validate.ts +1 -1
  95. package/src/engine/screen-helpers.ts +54 -0
  96. package/src/engine/tier-resolver-extension.ts +3 -2
  97. package/src/engine/types/config.ts +2 -497
  98. package/src/engine/types/define-handler.ts +2 -0
  99. package/src/engine/types/entity-handlers.ts +2 -0
  100. package/src/engine/types/event-type-map.ts +1 -37
  101. package/src/engine/types/feature.ts +2 -976
  102. package/src/engine/types/fields.ts +2 -697
  103. package/src/engine/types/handlers.ts +2 -839
  104. package/src/engine/types/hooks.ts +2 -184
  105. package/src/engine/types/http-route.ts +1 -72
  106. package/src/engine/types/identifiers.ts +1 -47
  107. package/src/engine/types/index.ts +66 -33
  108. package/src/engine/types/nav.ts +2 -67
  109. package/src/engine/types/ownership.ts +2 -0
  110. package/src/engine/types/projection.ts +2 -165
  111. package/src/engine/types/relations.ts +1 -51
  112. package/src/engine/types/screen.ts +2 -793
  113. package/src/engine/types/step.ts +2 -334
  114. package/src/engine/types/target-ref.ts +1 -21
  115. package/src/engine/types/tree-node.ts +1 -129
  116. package/src/engine/types/workspace.ts +2 -42
  117. package/src/entrypoint/index.ts +2 -2
  118. package/src/errors/write-error-info.ts +6 -22
  119. package/src/event-store/__tests__/event-store.integration.test.ts +31 -0
  120. package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +43 -0
  121. package/src/event-store/errors.ts +2 -35
  122. package/src/event-store/event-store.ts +28 -51
  123. package/src/event-store/events-schema.ts +1 -10
  124. package/src/event-store/index.ts +3 -2
  125. package/src/event-store/snapshot.ts +11 -35
  126. package/src/event-store/types.ts +2 -0
  127. package/src/files/__tests__/in-memory-provider.contract.test.ts +4 -0
  128. package/src/files/file-handle.ts +2 -19
  129. package/src/files/provider-resolver.ts +3 -5
  130. package/src/files/types.ts +5 -54
  131. package/src/i18n/required-surface-keys.ts +1 -1
  132. package/src/jobs/__tests__/jobs.integration.test.ts +102 -1
  133. package/src/logging/types.ts +1 -7
  134. package/src/observability/types/index.ts +1 -29
  135. package/src/observability/types/metric.ts +1 -56
  136. package/src/observability/types/provider.ts +1 -32
  137. package/src/observability/types/span.ts +1 -58
  138. package/src/pipeline/__tests__/dispatcher.test.ts +134 -1
  139. package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +126 -0
  140. package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +180 -0
  141. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +208 -0
  142. package/src/pipeline/dispatch-shared.ts +51 -3
  143. package/src/pipeline/dispatch-stream.ts +74 -0
  144. package/src/pipeline/dispatcher-utils.ts +1 -1
  145. package/src/pipeline/dispatcher.ts +7 -0
  146. package/src/pipeline/entity-cache.ts +2 -33
  147. package/src/pipeline/event-consumer-state.ts +28 -3
  148. package/src/pipeline/event-dispatcher-admin.ts +4 -0
  149. package/src/pipeline/event-dispatcher-delivery.ts +29 -3
  150. package/src/pipeline/event-dispatcher.ts +27 -1
  151. package/src/pipeline/multi-stream-apply-context.ts +4 -42
  152. package/src/pipeline/system-hooks.ts +7 -0
  153. package/src/rate-limit/resolver.ts +10 -30
  154. package/src/search/types.ts +1 -39
  155. package/src/secrets/__tests__/envelope-cipher.test.ts +2 -30
  156. package/src/secrets/__tests__/envelope.test.ts +1 -1
  157. package/src/secrets/envelope-cipher.ts +17 -45
  158. package/src/secrets/types.ts +2 -177
  159. package/src/stack/__tests__/event-collector.test.ts +42 -0
  160. package/src/testing/__tests__/late-bound.test.ts +25 -0
  161. package/src/testing/__tests__/wait-for.test.ts +53 -0
  162. package/src/testing/boot-validator-fixture.ts +1 -1
  163. package/src/testing/file-provider-contract.ts +84 -0
  164. package/src/testing/handler-context.ts +1 -1
  165. package/src/testing/index.ts +1 -0
  166. package/src/time/geo-tz.ts +1 -32
  167. package/src/time/tz-context.ts +9 -56
  168. package/src/ui-types/index.ts +7 -7
@@ -19,86 +19,25 @@
19
19
  // append-only-logs mit serial PK, aggregate-ID ohne DEFAULT, …).
20
20
 
21
21
  import { collectPiiSubjectFields } from "../crypto";
22
+ import type { EntityDefinition, EntityIndexDef, FieldDefinition } from "../engine/types";
22
23
  import type {
23
- EntityDefinition,
24
- EntityIndexDef,
25
- EntityRelations,
26
- FieldDefinition,
27
- } from "../engine/types";
24
+ BuildEntityTableMetaOptions,
25
+ ColumnMeta,
26
+ EntityTableMeta,
27
+ IndexMeta,
28
+ UnmanagedTableInput,
29
+ } from "./entity-table-meta-types";
28
30
  import { READ_MODEL_PREFIX, toSnakeCase, toTableName } from "./table-builder";
29
31
 
30
- // PG-Type-Repertoire das die Read-Model-Tabellen brauchen. Bewusst
31
- // schmal — keine Vendor-spezifischen Typen (TSVECTOR, HSTORE, etc.).
32
- // App-Author die solche braucht greift in der reviewten SQL-Migration
33
- // hand-edit ein, nicht im Generator.
34
- export type PgType =
35
- | "uuid"
36
- | "text"
37
- | "boolean"
38
- | "integer"
39
- | "double precision"
40
- | "bigint"
41
- | "serial"
42
- | "bigserial"
43
- | "jsonb"
44
- | "timestamptz"
45
- | "timestamptz(3)"
46
- // Exact decimal — precision/scale are encoded in the type string so the
47
- // DDL renderer and read-coercion need no side-channel metadata.
48
- | `numeric(${number},${number})`;
49
-
50
- export type ColumnMeta = {
51
- readonly name: string; // snake_case PG column name
52
- readonly pgType: PgType;
53
- readonly notNull: boolean;
54
- // Raw SQL-default-expression (e.g. `now()`, `gen_random_uuid()`,
55
- // `'[]'::jsonb`). undefined = no DEFAULT clause.
56
- readonly defaultSql?: string;
57
- readonly primaryKey?: boolean;
58
- readonly identity?: boolean;
59
- // bigint/bigserial only: JS round-trip mode. `number` = createBigIntField /
60
- // drizzle mode:"number" (safe ≤2^53). `bigint` = money cents, raw unmanaged.
61
- readonly bigintJsMode?: "number" | "bigint";
62
- };
63
-
64
- export type IndexMeta = {
65
- readonly name: string;
66
- readonly columns: readonly string[]; // snake_case PG column names
67
- readonly unique?: boolean;
68
- // Raw SQL-where-expression for partial indexes. Caller is responsible
69
- // for safety — emitted verbatim.
70
- readonly whereSql?: string;
71
- // Set wenn die EntityDefinition ein partial-Index hat (def.where als
72
- // drizzle SQL-AST), der vom Generator nicht zuverlässig renderbar ist.
73
- // Renderer emittiert das Statement dann AUSKOMMENTIERT mit Warn-Hint —
74
- // App-Author muss das WHERE manuell im generierten SQL hinzufügen.
75
- readonly needsManualWhere?: boolean;
76
- };
77
-
78
- export type CompositePrimaryKeyMeta = {
79
- readonly name: string;
80
- readonly columns: readonly string[];
81
- };
82
-
83
- export type EntityTableMeta = {
84
- readonly tableName: string;
85
- readonly columns: readonly ColumnMeta[];
86
- readonly indexes: readonly IndexMeta[];
87
- // For tables with composite PK (no single id column, e.g. snapshots
88
- // keyed by aggregate_id+version). When set, no column should have
89
- // primaryKey:true; the constraint is emitted at table-level.
90
- readonly compositePrimaryKey?: CompositePrimaryKeyMeta;
91
- // Source-hint für Diagnose/Tests — nicht funktional verwendet.
92
- // "managed" = aus EntityDefinition (mit base-columns + audit-trail).
93
- // "unmanaged" = via defineUnmanagedTable — kein Standard-Audit, App
94
- // trägt Verantwortung. Migration-Generator + Tooling können den
95
- // discriminator nutzen um Warnungen zu rendern ("X tables are unmanaged").
96
- readonly source: "managed" | "unmanaged";
97
- // PII-Subject-annotated field names (pii/userOwned/tenantOwned). Set by
98
- // buildEntityTableMeta so the registry can reject r.storeTable stores
99
- // whose direct writes would skip the executor's encryption (#820).
100
- readonly piiSubjectFields?: readonly string[];
101
- };
32
+ export type {
33
+ BuildEntityTableMetaOptions,
34
+ ColumnMeta,
35
+ CompositePrimaryKeyMeta,
36
+ EntityTableMeta,
37
+ IndexMeta,
38
+ PgType,
39
+ UnmanagedTableInput,
40
+ } from "./entity-table-meta-types";
102
41
 
103
42
  // Standard base-columns für event-sourced Read-Model-Tabellen. Spiegelt
104
43
  // `buildBaseColumns()` aus table-builder.ts (drizzle-Variante).
@@ -293,12 +232,6 @@ export function resolveTableName(
293
232
  return `${featureName}_${baseName}`;
294
233
  }
295
234
 
296
- export type BuildEntityTableMetaOptions = {
297
- readonly featureName?: string;
298
- readonly relations?: EntityRelations;
299
- readonly source?: "managed" | "unmanaged";
300
- };
301
-
302
235
  export function buildEntityTableMeta(
303
236
  entityName: string,
304
237
  entity: EntityDefinition,
@@ -434,13 +367,6 @@ export function buildEntityTableMeta(
434
367
  // Heutige use-cases im framework:
435
368
  // - `store_delivery_attempts` — id kommt aus dem Aggregate-Stream
436
369
  // - `store_job_run_logs` — child-table, serial PK, kein tenant-scope
437
- export type UnmanagedTableInput = {
438
- readonly tableName: string;
439
- readonly columns: readonly ColumnMeta[];
440
- readonly indexes?: readonly IndexMeta[];
441
- readonly compositePrimaryKey?: CompositePrimaryKeyMeta;
442
- };
443
-
444
370
  function sqlExpressionText(where: unknown): string | undefined {
445
371
  if (
446
372
  typeof where === "object" &&
@@ -1,19 +1,14 @@
1
+ import type { EventStoreExecutor } from "@cosmicdrift/kumiko-types/event-store-executor-types";
1
2
  import type { LocalKeyKmsAdapter } from "../crypto";
2
- import type {
3
- DeleteContext,
4
- EntityDefinition,
5
- EntityId,
6
- SaveContext,
7
- SessionUser,
8
- WriteResult,
9
- } from "../engine/types";
3
+ import type { EntityDefinition } from "../engine/types";
10
4
  import type { EntityCache } from "../pipeline/entity-cache";
11
5
  import type { SearchAdapter } from "../search/types";
12
6
  import type { EnvelopeCipher } from "../secrets/envelope-cipher";
13
7
  import { buildExecutorContext, type Table } from "./event-store-executor-context";
14
8
  import { createReadVerbs } from "./event-store-executor-read";
15
9
  import { createWriteVerbs } from "./event-store-executor-write";
16
- import type { CursorResult } from "./index";
10
+
11
+ export type { EventStoreExecutor } from "@cosmicdrift/kumiko-types/event-store-executor-types";
17
12
 
18
13
  // The executor writes events + auto-projection (entity table) in one TX.
19
14
  // It no longer knows about user projections — those are driven by the
@@ -42,93 +37,6 @@ export type EventStoreExecutorOptions = {
42
37
  kms?: LocalKeyKmsAdapter;
43
38
  };
44
39
 
45
- export type EventStoreExecutor = {
46
- create: (
47
- payload: Record<string, unknown>,
48
- user: SessionUser,
49
- db: import("./tenant-db").TenantDb,
50
- ) => Promise<WriteResult<SaveContext>>;
51
-
52
- update: (
53
- payload: { id: EntityId; version?: number | undefined; changes: Record<string, unknown> },
54
- user: SessionUser,
55
- db: import("./tenant-db").TenantDb,
56
- options?: { skipOptimisticLock?: boolean; skipUnchanged?: boolean },
57
- ) => Promise<WriteResult<SaveContext>>;
58
-
59
- delete: (
60
- payload: { id: EntityId },
61
- user: SessionUser,
62
- db: import("./tenant-db").TenantDb,
63
- ) => Promise<WriteResult<DeleteContext>>;
64
-
65
- // Hard-purge (Art. 17 erasure). Like delete, but emits `<entity>.forgotten`
66
- // which hard-deletes the row even for softDelete entities — and, being an
67
- // auto-verb replayed by the implicit projection, the erasure survives a
68
- // rebuild (created → forgotten → row gone). Reaches soft-deleted rows too.
69
- forget: (
70
- payload: { id: EntityId },
71
- user: SessionUser,
72
- db: import("./tenant-db").TenantDb,
73
- ) => Promise<WriteResult<DeleteContext>>;
74
-
75
- restore: (
76
- payload: { id: EntityId },
77
- user: SessionUser,
78
- db: import("./tenant-db").TenantDb,
79
- ) => Promise<WriteResult<SaveContext>>;
80
-
81
- list: (
82
- payload: {
83
- cursor?: string | undefined;
84
- limit?: number | undefined;
85
- search?: string | undefined;
86
- sort?: string | undefined;
87
- sortDirection?: "asc" | "desc" | undefined;
88
- offset?: number | undefined;
89
- totalCount?: boolean | undefined;
90
- filter?:
91
- | {
92
- readonly field: string;
93
- readonly op: "eq" | "ne" | "lt" | "gt" | "in";
94
- readonly value: unknown;
95
- }
96
- | undefined;
97
- // User-gewählte Faceted-Filter (dynamisch, additiv zum statischen
98
- // `filter`). Alle werden mit AND verknüpft.
99
- filters?:
100
- | ReadonlyArray<{
101
- readonly field: string;
102
- readonly op: "eq" | "ne" | "lt" | "gt" | "in";
103
- readonly value: unknown;
104
- }>
105
- | undefined;
106
- },
107
- user: SessionUser,
108
- db: import("./tenant-db").TenantDb,
109
- /** Tier 2.7e Audit-Fix: per-Call SearchAdapter Override. Wenn der
110
- * Executor beim Build keinen SearchAdapter via Options bekommen
111
- * hat (defaultEntityQueryHandler-Pfad), kann der Caller (Handler)
112
- * hier zur Runtime einen aus ctx.searchAdapter durchreichen.
113
- * options.searchAdapter (build-time) gewinnt — runtime-Override
114
- * ist Fallback für die default-Wrapper. */
115
- runtimeOptions?: {
116
- readonly searchAdapter?: SearchAdapter;
117
- // Trash query: skip the implicit `isDeleted = FALSE` filter so soft-
118
- // deleted rows are returned too. Tenant + ownership clauses still apply
119
- // — includeDeleted only relaxes the soft-delete predicate, never the
120
- // visibility ones, so it can ride untrusted query input safely.
121
- readonly includeDeleted?: boolean;
122
- },
123
- ) => Promise<CursorResult<Record<string, unknown>>>;
124
-
125
- detail: (
126
- payload: { id: EntityId },
127
- user: SessionUser,
128
- db: import("./tenant-db").TenantDb,
129
- ) => Promise<Record<string, unknown> | null>;
130
- };
131
-
132
40
  export function createEventStoreExecutor(
133
41
  table: Table,
134
42
  entity: EntityDefinition,
@@ -236,6 +236,7 @@ export async function backfillEventPiiEncryption(
236
236
  subject,
237
237
  value,
238
238
  kmsCtx,
239
+ field,
239
240
  );
240
241
  return "encrypted";
241
242
  } catch (e) {
@@ -47,6 +47,7 @@ export type ConsumerDeliveryOutcome = {
47
47
  readonly attempts: number;
48
48
  readonly lastError: string | null;
49
49
  readonly deadLettered: boolean;
50
+ readonly processed: number;
50
51
  };
51
52
 
52
53
  export async function updateConsumerDeliveryOutcome(
@@ -55,19 +56,26 @@ export async function updateConsumerDeliveryOutcome(
55
56
  instanceId: string,
56
57
  outcome: ConsumerDeliveryOutcome,
57
58
  ): Promise<void> {
59
+ // Advancing the cursor proves this pass wasn't poison — reset the re-arm
60
+ // budget so an unrelated failure down the line gets its own fresh 3
61
+ // chances instead of inheriting a partially-spent counter from a past,
62
+ // already-resolved outage.
63
+ const resetRearmCount = outcome.processed > 0;
58
64
  await asRawClient(db).unsafe(
59
65
  `UPDATE "kumiko_event_consumers" SET
60
66
  "last_processed_event_id" = $1,
61
67
  "attempts" = $2,
62
68
  "status" = $3,
63
69
  "last_error" = $4,
70
+ "rearm_count" = CASE WHEN $5 THEN 0 ELSE "rearm_count" END,
64
71
  "updated_at" = now()
65
- WHERE "name" = $5 AND "instance_id" = $6`,
72
+ WHERE "name" = $6 AND "instance_id" = $7`,
66
73
  [
67
74
  outcome.cursor,
68
75
  outcome.attempts,
69
76
  outcome.deadLettered ? "dead" : "idle",
70
77
  outcome.lastError,
78
+ resetRearmCount,
71
79
  name,
72
80
  instanceId,
73
81
  ],
@@ -81,7 +89,7 @@ export async function updateConsumerStatusReturning(
81
89
  status: "idle" | "disabled",
82
90
  ): Promise<Record<string, unknown> | undefined> {
83
91
  const rows = (await asRawClient(db).unsafe(
84
- `UPDATE "kumiko_event_consumers" SET "status" = $1, "attempts" = 0, "last_error" = NULL, "updated_at" = now()
92
+ `UPDATE "kumiko_event_consumers" SET "status" = $1, "attempts" = 0, "last_error" = NULL, "rearm_count" = 0, "updated_at" = now()
85
93
  WHERE "name" = $2 AND "instance_id" = $3
86
94
  RETURNING *`,
87
95
  [status, name, instanceId],
@@ -101,6 +109,7 @@ export async function advanceConsumerPastEventReturning(
101
109
  "status" = 'idle',
102
110
  "attempts" = 0,
103
111
  "last_error" = NULL,
112
+ "rearm_count" = 0,
104
113
  "updated_at" = now()
105
114
  WHERE "name" = $2 AND "instance_id" = $3
106
115
  RETURNING *`,
@@ -168,3 +177,27 @@ export async function markConsumerRebuildFailed(
168
177
  [errorMessage, name, instanceId],
169
178
  );
170
179
  }
180
+
181
+ // Auto-revive a dead consumer once its cooldown has elapsed (called from
182
+ // acquireConsumerState — not an ops action, so no "requireConsumerRow"
183
+ // precondition like restartConsumer/enableConsumer). Increments rearm_count
184
+ // so the caller can enforce a lifetime cap on automatic revivals; manual
185
+ // restartConsumer()/enableConsumer() reset it back to 0.
186
+ export async function rearmDeadConsumer(
187
+ db: AnyDb,
188
+ name: string,
189
+ instanceId: string,
190
+ ): Promise<Record<string, unknown> | undefined> {
191
+ const rows = (await asRawClient(db).unsafe(
192
+ `UPDATE "kumiko_event_consumers" SET
193
+ "status" = 'idle',
194
+ "attempts" = 0,
195
+ "last_error" = NULL,
196
+ "rearm_count" = "rearm_count" + 1,
197
+ "updated_at" = now()
198
+ WHERE "name" = $1 AND "instance_id" = $2
199
+ RETURNING *`,
200
+ [name, instanceId],
201
+ )) as ReadonlyArray<Record<string, unknown>>;
202
+ return rows[0];
203
+ }
@@ -1,3 +1,4 @@
1
+ import type { ExecutorOnly } from "@cosmicdrift/kumiko-types/executor-brand";
1
2
  import type {
2
3
  EntityDefinition,
3
4
  EntityRelations,
@@ -382,25 +383,7 @@ type SoftDeleteColumnsType = {
382
383
  };
383
384
 
384
385
  // ── ES-write brand ──────────────────────────────────────────────────────
385
- // A managed projection is only writable through the executor (event →
386
- // rebuild-safe). To make a direct write a *compile* error rather than a
387
- // convention, EntityTable carries a phantom `unique symbol` prop; the public
388
- // write helpers reject anything that has it (see NotExecutorOnly + query.ts).
389
- // The symbol key dodges SchemaTable's `[field: string]: unknown` index
390
- // signature (string index sigs don't cover symbol keys), so the brand
391
- // survives the type-erasure that would swallow a plain marker prop — and the
392
- // executor seam (applyEntityEvent) erases `table` to TableColumns<any>, which
393
- // carries no such prop, so the one legitimate writer stays green.
394
- declare const EXECUTOR_ONLY: unique symbol;
395
- export interface ExecutorOnly {
396
- readonly [EXECUTOR_ONLY]: true;
397
- }
398
- // Negative brand for write-helper params: a branded EntityTable is NOT
399
- // assignable (its `true` clashes with `never`), while unmanaged EntityTableMeta
400
- // and erased SchemaTable pass (they lack the prop, so `?: never` is satisfied).
401
- export type NotExecutorOnly = {
402
- readonly [EXECUTOR_ONLY]?: never;
403
- };
386
+ export type { ExecutorOnly, NotExecutorOnly } from "@cosmicdrift/kumiko-types/executor-brand";
404
387
 
405
388
  export type EntityTable<E extends EntityDefinition = EntityDefinition> =
406
389
  TableColumns<// biome-ignore lint/suspicious/noExplicitAny: drizzle's internal table-config stays generic; we layer typed columns on top via the intersection below.
@@ -1,3 +1,5 @@
1
+ import type { SchemaTable } from "@cosmicdrift/kumiko-types/schema-table-types";
2
+ import type { TenantDb, TenantDbMode } from "@cosmicdrift/kumiko-types/tenant-db-types";
1
3
  import {
2
4
  asEntityTableMeta,
3
5
  asRawClient,
@@ -12,61 +14,10 @@ import {
12
14
  import { SYSTEM_TENANT_ID, type TenantId } from "../engine/types/identifiers";
13
15
  import { emitDbQuery, type Meter, registerStandardMetrics, type Tracer } from "../observability";
14
16
  import type { DbRunner } from "./connection";
15
- import type { TableColumns } from "./dialect";
16
- import type { EntityTableMeta } from "./entity-table-meta";
17
- import type { NotExecutorOnly } from "./table-builder";
18
-
19
- // biome-ignore lint/suspicious/noExplicitAny: Dynamic tables — keys depend on user-defined schema.
20
- type Table = TableColumns<any>;
21
-
22
- // Method-form writes reject the executor-only brand exactly like the free-function
23
- // helpers (#742): a managed EntityTable is a rebuildable projection, so writing it
24
- // directly — free-function OR method-form — drifts the row past its event stream and
25
- // a rebuild wipes it. The permissive base stays (raw pgTables AND unmanaged entity
26
- // metas are not projections → writable); `& NotExecutorOnly` strips only branded
27
- // EntityTables (its `[EXECUTOR_ONLY]: true` violates the optional-never). Reads keep
28
- // the plain `Table` param.
29
- type WritableTable = (Table | EntityTableMeta) & NotExecutorOnly;
30
-
31
- /**
32
- * TenantDb scope modes:
33
- *
34
- * - "tenant" (default): SELECT/UPDATE/DELETE filtered by tenantId + reference data (tenantId=SYSTEM_TENANT_ID).
35
- * INSERT forces tenantId — handler cannot override.
36
- *
37
- * - "system" (r.systemScope()): No tenant filter on reads/updates/deletes.
38
- * INSERT uses tenantId as default but handler can override.
39
- *
40
- * Tables without a tenantId column are always unfiltered regardless of mode.
41
- */
42
- export type TenantDbMode = "tenant" | "system";
43
-
44
- export type TenantDb = {
45
- readonly tenantId: TenantId;
46
- readonly mode: TenantDbMode;
47
- /**
48
- * Underlying DbRunner. Framework-internal use (event-store, migrations) —
49
- * bypasses tenant-filter. Feature code uses the typed helpers above so the
50
- * automatic scoping stays intact.
51
- */
52
- readonly raw: DbRunner;
53
- selectMany<T = Record<string, unknown>>(
54
- table: Table,
55
- where?: WhereObject,
56
- options?: SelectOptions,
57
- ): Promise<readonly T[]>;
58
- fetchOne<T = Record<string, unknown>>(table: Table, where: WhereObject): Promise<T | undefined>;
59
- insertOne<T = Record<string, unknown>>(
60
- table: WritableTable,
61
- values: Record<string, unknown>,
62
- ): Promise<T | undefined>;
63
- updateMany<T = Record<string, unknown>>(
64
- table: WritableTable,
65
- set: Record<string, unknown>,
66
- where: WhereObject,
67
- ): Promise<readonly T[]>;
68
- deleteMany(table: WritableTable, where: WhereObject): Promise<void>;
69
- };
17
+
18
+ type Table = SchemaTable;
19
+
20
+ export type { TenantDb, TenantDbMode } from "@cosmicdrift/kumiko-types/tenant-db-types";
70
21
 
71
22
  // @cast-boundary tenant-db-row
72
23
  export function castTenantRows<T>(rows: readonly Record<string, unknown>[]): readonly T[] {
@@ -0,0 +1,99 @@
1
+ // r.bootCheck(fn) — feature-declared mount invariants, checked at boot.
2
+ // Mirrors the prompt-store trap (kumiko-enterprise#229): a feature with
3
+ // PII-annotated fields was mounted without its required companion feature,
4
+ // and nothing caught it at boot. The conditional-invariant tests below
5
+ // reproduce that shape: the check only fails when the feature actually has
6
+ // PII fields AND the companion is missing — a bare "is X mounted" check
7
+ // would already be covered by r.requires and wouldn't justify this API.
8
+
9
+ import { describe, expect, test } from "bun:test";
10
+ import { validateFeatureBootChecks } from "../boot-validator/boot-check";
11
+ import { defineFeature } from "../define-feature";
12
+ import { createEntity, createTextField } from "../factories";
13
+
14
+ function catchMessage(fn: () => void): string {
15
+ try {
16
+ fn();
17
+ } catch (e) {
18
+ return e instanceof Error ? e.message : String(e);
19
+ }
20
+ throw new Error("expected function to throw, but it did not");
21
+ }
22
+
23
+ const requiresUserDataHook = (features: readonly { readonly name: string }[]) =>
24
+ features.some((f) => f.name === "user-data-hook");
25
+
26
+ const promptStore = () =>
27
+ defineFeature("prompt-store", (r) => {
28
+ const promptFields = { text: createTextField({ pii: true }) };
29
+ r.entity("prompt", createEntity({ fields: promptFields }));
30
+ r.bootCheck(({ features }) => {
31
+ // Conditional on this feature's own shape (has a pii field), closed
32
+ // over from setup — r.requires("user-data-hook") can't express that.
33
+ const hasPiiField = Object.values(promptFields).some((field) => field.pii);
34
+ if (hasPiiField && !requiresUserDataHook(features)) {
35
+ throw new Error("prompt-store has PII fields but no user-data-hook feature is mounted");
36
+ }
37
+ });
38
+ });
39
+
40
+ describe("r.bootCheck / validateFeatureBootChecks", () => {
41
+ test("no bootChecks registered → no-op", () => {
42
+ const noop = defineFeature("noop", () => {});
43
+ expect(() => validateFeatureBootChecks([noop])).not.toThrow();
44
+ });
45
+
46
+ test("conditional invariant satisfied (companion mounted) → boot succeeds", () => {
47
+ const userDataHook = defineFeature("user-data-hook", () => {});
48
+ expect(() => validateFeatureBootChecks([userDataHook, promptStore()])).not.toThrow();
49
+ });
50
+
51
+ test("conditional invariant violated (PII field, no companion) → boot fails with feature-prefixed message", () => {
52
+ const message = catchMessage(() => validateFeatureBootChecks([promptStore()]));
53
+ expect(message).toContain("[Feature prompt-store]");
54
+ expect(message).toContain(
55
+ "prompt-store has PII fields but no user-data-hook feature is mounted",
56
+ );
57
+ });
58
+
59
+ test("no PII field → boot succeeds even without the companion", () => {
60
+ const noPiiFields = { text: createTextField() };
61
+ const noPii = defineFeature("prompt-store-no-pii", (r) => {
62
+ r.entity("note", createEntity({ fields: noPiiFields }));
63
+ r.bootCheck(({ features }) => {
64
+ const hasPiiField = Object.values(noPiiFields).some((field) => field.pii);
65
+ if (hasPiiField && !requiresUserDataHook(features)) {
66
+ throw new Error("unreachable in this test");
67
+ }
68
+ });
69
+ });
70
+ expect(() => validateFeatureBootChecks([noPii])).not.toThrow();
71
+ });
72
+
73
+ test("multiple bootChecks on one feature all run in order until one throws", () => {
74
+ const calls: string[] = [];
75
+ const feature = defineFeature("multi-check", (r) => {
76
+ r.bootCheck(() => {
77
+ calls.push("first");
78
+ });
79
+ r.bootCheck(() => {
80
+ calls.push("second");
81
+ throw new Error("second check failed");
82
+ });
83
+ });
84
+ expect(() => validateFeatureBootChecks([feature])).toThrow("second check failed");
85
+ expect(calls).toEqual(["first", "second"]);
86
+ });
87
+
88
+ test("ctx.features exposes every mounted feature, not just the declaring one", () => {
89
+ const a = defineFeature("feature-a", () => {});
90
+ let seenNames: string[] = [];
91
+ const b = defineFeature("feature-b", (r) => {
92
+ r.bootCheck(({ features }) => {
93
+ seenNames = features.map((f) => f.name);
94
+ });
95
+ });
96
+ validateFeatureBootChecks([a, b]);
97
+ expect(seenNames).toEqual(["feature-a", "feature-b"]);
98
+ });
99
+ });