@cosmicdrift/kumiko-framework 0.1.0 → 0.2.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 (66) hide show
  1. package/LICENSE +57 -0
  2. package/package.json +1 -1
  3. package/src/__tests__/anonymous-access.integration.ts +7 -7
  4. package/src/__tests__/error-contract.integration.ts +2 -2
  5. package/src/__tests__/field-access.integration.ts +2 -2
  6. package/src/__tests__/full-stack.integration.ts +2 -2
  7. package/src/__tests__/ownership.integration.ts +2 -2
  8. package/src/__tests__/raw-table.integration.ts +128 -0
  9. package/src/__tests__/reference-data.integration.ts +2 -2
  10. package/src/__tests__/transition-guard.integration.ts +4 -4
  11. package/src/api/__tests__/batch.integration.ts +3 -3
  12. package/src/api/__tests__/dispatcher-live.integration.ts +2 -2
  13. package/src/api/__tests__/nested-write.integration.ts +3 -3
  14. package/src/db/__tests__/drizzle-helpers.integration.ts +2 -2
  15. package/src/db/__tests__/event-store-executor-list.integration.ts +2 -2
  16. package/src/db/__tests__/event-store-executor.integration.ts +9 -3
  17. package/src/db/__tests__/implicit-projection-equivalence.integration.ts +3 -3
  18. package/src/db/__tests__/multi-row-insert.integration.ts +3 -3
  19. package/src/db/__tests__/schema-migration.integration.ts +9 -9
  20. package/src/db/__tests__/tenant-db.integration.ts +4 -4
  21. package/src/db/__tests__/unique-violation-mapping.integration.ts +2 -2
  22. package/src/db/schema-inspection.ts +1 -1
  23. package/src/engine/__tests__/raw-table.test.ts +149 -0
  24. package/src/engine/define-feature.ts +38 -0
  25. package/src/engine/registry.ts +46 -0
  26. package/src/engine/types/feature.ts +55 -0
  27. package/src/engine/types/index.ts +3 -0
  28. package/src/event-store/__tests__/upcaster.integration.ts +11 -5
  29. package/src/event-store/archive.ts +2 -2
  30. package/src/event-store/events-schema.ts +2 -2
  31. package/src/event-store/snapshot.ts +2 -2
  32. package/src/event-store/upcaster-dead-letter.ts +2 -2
  33. package/src/files/__tests__/file-field-column.integration.ts +4 -4
  34. package/src/files/__tests__/file-field-pipeline.integration.ts +2 -2
  35. package/src/files/__tests__/files.integration.ts +8 -8
  36. package/src/observability/__tests__/observability.integration.ts +2 -2
  37. package/src/pipeline/__tests__/archive-stream.integration.ts +2 -2
  38. package/src/pipeline/__tests__/cascade-handler.integration.ts +9 -9
  39. package/src/pipeline/__tests__/causation-chain.integration.ts +2 -2
  40. package/src/pipeline/__tests__/ctx-bridge.integration.ts +3 -3
  41. package/src/pipeline/__tests__/domain-events-projections.integration.ts +2 -2
  42. package/src/pipeline/__tests__/event-dedup.integration.ts +2 -2
  43. package/src/pipeline/__tests__/event-define-event-strict.integration.ts +2 -2
  44. package/src/pipeline/__tests__/event-dispatcher-lifecycle.integration.ts +3 -3
  45. package/src/pipeline/__tests__/event-dispatcher-multi-instance.integration.ts +2 -2
  46. package/src/pipeline/__tests__/event-dispatcher-pg-listen.integration.ts +2 -2
  47. package/src/pipeline/__tests__/event-dispatcher-recovery.integration.ts +2 -2
  48. package/src/pipeline/__tests__/event-dispatcher-second-audit.integration.ts +4 -4
  49. package/src/pipeline/__tests__/event-dispatcher.integration.ts +4 -4
  50. package/src/pipeline/__tests__/event-retention.integration.ts +2 -2
  51. package/src/pipeline/__tests__/fetch-for-writing.integration.ts +2 -2
  52. package/src/pipeline/__tests__/load-aggregate-query.integration.ts +2 -2
  53. package/src/pipeline/__tests__/msp-error-mode.integration.ts +2 -2
  54. package/src/pipeline/__tests__/msp-multi-hop.integration.ts +2 -2
  55. package/src/pipeline/__tests__/msp-rebuild.integration.ts +3 -3
  56. package/src/pipeline/__tests__/multi-stream-projection.integration.ts +3 -3
  57. package/src/pipeline/__tests__/perf-rebuild.integration.ts +2 -2
  58. package/src/pipeline/__tests__/projection-rebuild.integration.ts +9 -3
  59. package/src/pipeline/__tests__/query-projection.integration.ts +2 -2
  60. package/src/pipeline/event-consumer-state.ts +2 -2
  61. package/src/pipeline/projection-state.ts +3 -3
  62. package/src/stack/index.ts +4 -3
  63. package/src/stack/push-entity-projection-tables.ts +51 -0
  64. package/src/stack/table-helpers.ts +20 -13
  65. package/src/stack/test-stack.ts +13 -4
  66. package/src/testing/__tests__/ensure-entity-table.integration.ts +16 -11
@@ -2,12 +2,12 @@ import { eq } from "drizzle-orm";
2
2
  import { afterAll, beforeAll, describe, expect, test } from "vitest";
3
3
  import { createBooleanField, createEntity, createTextField } from "../../engine";
4
4
  import {
5
- createEntityTable,
6
5
  createTestDb,
7
- pushTables,
8
6
  type TestDb,
9
7
  TestUsers,
10
8
  testTenantId,
9
+ unsafeCreateEntityTable,
10
+ unsafePushTables,
11
11
  } from "../../stack";
12
12
  import { table as pgTable, serial, text, timestamp } from "../dialect";
13
13
  import { buildDrizzleTable } from "../table-builder";
@@ -41,8 +41,8 @@ const tenant2 = TestUsers.otherTenant; // tenantId: 2
41
41
 
42
42
  beforeAll(async () => {
43
43
  testDb = await createTestDb();
44
- await createEntityTable(testDb.db, entity, "tenantDbItem");
45
- await pushTables(testDb.db, { tdb_system_entries: systemTable });
44
+ await unsafeCreateEntityTable(testDb.db, entity, "tenantDbItem");
45
+ await unsafePushTables(testDb.db, { tdb_system_entries: systemTable });
46
46
  });
47
47
 
48
48
  afterAll(async () => {
@@ -17,7 +17,7 @@ import { sql } from "drizzle-orm";
17
17
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
18
18
  import { createEntity, createTextField } from "../../engine";
19
19
  import { createEventsTable } from "../../event-store";
20
- import { createEntityTable, createTestDb, type TestDb, TestUsers } from "../../stack";
20
+ import { createTestDb, type TestDb, TestUsers, unsafeCreateEntityTable } from "../../stack";
21
21
  import { createEventStoreExecutor } from "../event-store-executor";
22
22
  import { buildDrizzleTable } from "../table-builder";
23
23
  import { createTenantDb, type TenantDb } from "../tenant-db";
@@ -48,7 +48,7 @@ const admin = TestUsers.admin;
48
48
 
49
49
  beforeAll(async () => {
50
50
  testDb = await createTestDb();
51
- await createEntityTable(testDb.db, userEntity, "unique-user");
51
+ await unsafeCreateEntityTable(testDb.db, userEntity, "unique-user");
52
52
  await createEventsTable(testDb.db);
53
53
  tdb = createTenantDb(testDb.db, admin.tenantId);
54
54
  });
@@ -13,7 +13,7 @@ import type { DbConnection, DbTx } from "./connection";
13
13
  // "already exists" error code.
14
14
  //
15
15
  // if (await tableExists(db, "public.events")) return;
16
- // await pushTables(db, { events: eventsTable });
16
+ // await unsafePushTables(db, { events: eventsTable });
17
17
  export async function tableExists(
18
18
  db: DbConnection | DbTx,
19
19
  fullyQualifiedName: string,
@@ -0,0 +1,149 @@
1
+ // Unit tests for r.rawTable() — declaration-time validation + registry
2
+ // aggregation. Full DB roundtrip (setupTestStack pushes the table → INSERT
3
+ // / SELECT) lives in src/__tests__/raw-table.integration.ts.
4
+
5
+ import { pgTable, text } from "drizzle-orm/pg-core";
6
+ import { describe, expect, test } from "vitest";
7
+ import { defineFeature } from "../define-feature";
8
+ import { createRegistry } from "../registry";
9
+ import type { ProjectionDefinition } from "../types";
10
+
11
+ const probeTable = pgTable("rt_probe_table", {
12
+ id: text("id").primaryKey(),
13
+ });
14
+ const probeTableTwo = pgTable("rt_probe_table_two", {
15
+ id: text("id").primaryKey(),
16
+ });
17
+
18
+ describe("r.rawTable — declaration", () => {
19
+ test("rejects non-kebab-case names", () => {
20
+ expect(() =>
21
+ defineFeature("probe", (r) => {
22
+ r.rawTable("BadName", probeTable, { reason: "test" });
23
+ }),
24
+ ).toThrow(/must be kebab-case/);
25
+ });
26
+
27
+ test("rejects duplicate names within one feature", () => {
28
+ expect(() =>
29
+ defineFeature("probe", (r) => {
30
+ r.rawTable("cache", probeTable, { reason: "test" });
31
+ r.rawTable("cache", probeTableTwo, { reason: "test" });
32
+ }),
33
+ ).toThrow(/already registered/);
34
+ });
35
+
36
+ test("rejects empty reason", () => {
37
+ expect(() =>
38
+ defineFeature("probe", (r) => {
39
+ r.rawTable("cache", probeTable, { reason: "" });
40
+ }),
41
+ ).toThrow(/options\.reason must be a non-empty string/);
42
+ });
43
+
44
+ test("rejects whitespace-only reason", () => {
45
+ expect(() =>
46
+ defineFeature("probe", (r) => {
47
+ r.rawTable("cache", probeTable, { reason: " " });
48
+ }),
49
+ ).toThrow(/options\.reason must be a non-empty string/);
50
+ });
51
+
52
+ test("accepts valid registration and stores reason verbatim", () => {
53
+ const feature = defineFeature("probe", (r) => {
54
+ r.rawTable("cache", probeTable, {
55
+ reason: "external Stripe webhook cache, write-only by webhook handler",
56
+ });
57
+ });
58
+ expect(feature.rawTables).toHaveProperty("cache");
59
+ expect(feature.rawTables.cache?.reason).toBe(
60
+ "external Stripe webhook cache, write-only by webhook handler",
61
+ );
62
+ expect(feature.rawTables.cache?.table).toBe(probeTable);
63
+ });
64
+ });
65
+
66
+ describe("createRegistry — rawTable aggregation", () => {
67
+ test("aggregates raw tables across features and tags featureName", () => {
68
+ const featA = defineFeature("billing", (r) => {
69
+ r.rawTable("stripe-cache", probeTable, { reason: "external API cache" });
70
+ });
71
+ const featB = defineFeature("inventory", (r) => {
72
+ r.rawTable("legacy-import", probeTableTwo, { reason: "imported pre-ES" });
73
+ });
74
+
75
+ const registry = createRegistry([featA, featB]);
76
+ const all = registry.getAllRawTables();
77
+
78
+ expect(all.size).toBe(2);
79
+ expect(all.get("stripe-cache")?.featureName).toBe("billing");
80
+ expect(all.get("stripe-cache")?.reason).toBe("external API cache");
81
+ expect(all.get("legacy-import")?.featureName).toBe("inventory");
82
+ });
83
+
84
+ test("rejects cross-feature name collisions at boot", () => {
85
+ const featA = defineFeature("a", (r) => {
86
+ r.rawTable("shared", probeTable, { reason: "first" });
87
+ });
88
+ const featB = defineFeature("b", (r) => {
89
+ r.rawTable("shared", probeTableTwo, { reason: "second" });
90
+ });
91
+
92
+ expect(() => createRegistry([featA, featB])).toThrow(
93
+ /Raw-table "shared" registered by both feature "a" and "b"/,
94
+ );
95
+ });
96
+
97
+ test("absent rawTables block on a feature is ok (legacy / hand-built features)", () => {
98
+ const featNoRaw = defineFeature("no-raw", () => {
99
+ // no r.rawTable calls
100
+ });
101
+ const registry = createRegistry([featNoRaw]);
102
+ expect(registry.getAllRawTables().size).toBe(0);
103
+ });
104
+
105
+ test("two raw tables on the same PgTable register under both names", () => {
106
+ // Different logical names, identical physical target — legitimate
107
+ // when one role writes (primary cache) and a second role reads
108
+ // (alias view). Push-time dedupe keeps the boot to a single CREATE;
109
+ // the registry keeps both entries so callers can resolve either name.
110
+ const sharedT = pgTable("rt_shared_dedupe", {
111
+ id: text("id").primaryKey(),
112
+ });
113
+ const feat = defineFeature("dedupe", (r) => {
114
+ r.rawTable("primary", sharedT, { reason: "main writer" });
115
+ r.rawTable("alias", sharedT, { reason: "alias for read consumers" });
116
+ });
117
+ const registry = createRegistry([feat]);
118
+
119
+ expect(registry.getAllRawTables().size).toBe(2);
120
+ expect(registry.getAllRawTables().get("primary")?.table).toBe(sharedT);
121
+ expect(registry.getAllRawTables().get("alias")?.table).toBe(sharedT);
122
+ });
123
+
124
+ test("rejects rawTable that shares a PgTable with an explicit projection", () => {
125
+ // A r.rawTable() declaring the same physical table as a registered
126
+ // r.projection() is almost always an authoring bug — two owners on
127
+ // the same physical table, one event-sourced, one bypass. Boot
128
+ // throws so the failure points at the misconfiguration site.
129
+ const sharedT = pgTable("rt_collision_phys", {
130
+ id: text("id").primaryKey(),
131
+ });
132
+ const projection: ProjectionDefinition = {
133
+ name: "shared-summary",
134
+ source: "shared",
135
+ table: sharedT,
136
+ apply: {},
137
+ };
138
+ const featProj = defineFeature("proj-owner", (r) => {
139
+ r.projection(projection);
140
+ });
141
+ const featRaw = defineFeature("raw-owner", (r) => {
142
+ r.rawTable("collision", sharedT, { reason: "intentionally bad" });
143
+ });
144
+
145
+ expect(() => createRegistry([featProj, featRaw])).toThrow(
146
+ /shares a Drizzle PgTable with a registered projection/,
147
+ );
148
+ });
149
+ });
@@ -1,3 +1,4 @@
1
+ import type { PgTable } from "drizzle-orm/pg-core";
1
2
  import type { ZodType, z } from "zod";
2
3
  import { toTableName } from "../db/table-builder";
3
4
  import { LifecycleHookTypes } from "./constants";
@@ -44,6 +45,8 @@ import type {
44
45
  QueryHandlerDef,
45
46
  QueryHandlerFn,
46
47
  RateLimitOption,
48
+ RawTableEntry,
49
+ RawTableOptions,
47
50
  ReferenceDataDef,
48
51
  RegistrarExtensionDef,
49
52
  RegistrarExtensionRegistration,
@@ -115,6 +118,7 @@ export function defineFeature<const TName extends string, TExports = undefined>(
115
118
  const secretKeys: Record<string, SecretKeyDefinition> = {};
116
119
  const projections: Record<string, ProjectionDefinition> = {};
117
120
  const multiStreamProjections: Record<string, MultiStreamProjectionDefinition> = {};
121
+ const rawTables: Record<string, RawTableEntry> = {};
118
122
  const authClaimsHooks: AuthClaimsFn[] = [];
119
123
  const claimKeys: Record<string, ClaimKeyDefinition> = {};
120
124
  const screens: Record<string, ScreenDefinition> = {};
@@ -625,6 +629,39 @@ export function defineFeature<const TName extends string, TExports = undefined>(
625
629
  httpRoutes[key] = definition;
626
630
  },
627
631
 
632
+ rawTable(rawTableName: string, table: PgTable, options: RawTableOptions): void {
633
+ // Same kebab guard as r.projection / r.screen / r.nav so authoring-time
634
+ // mistakes surface at the feature file, not deep in registry boot.
635
+ if (!isKebabSegment(rawTableName)) {
636
+ throw new Error(
637
+ `[Feature ${name}] Raw-table name "${rawTableName}" must be kebab-case ` +
638
+ `(lowercase letters, digits, dashes; start with a letter). ` +
639
+ `Got "${rawTableName}" — try "${toKebab(rawTableName).replace(/_/g, "-")}".`,
640
+ );
641
+ }
642
+ if (rawTables[rawTableName]) {
643
+ throw new Error(
644
+ `[Feature ${name}] r.rawTable("${rawTableName}") already registered. ` +
645
+ `Raw-table names must be unique per feature.`,
646
+ );
647
+ }
648
+ // The `reason` is the marker that justifies the bypass — empty
649
+ // strings would defeat the audit trail. Reject early so the
650
+ // failure points at the feature file.
651
+ if (typeof options.reason !== "string" || options.reason.trim().length === 0) {
652
+ throw new Error(
653
+ `[Feature ${name}] r.rawTable("${rawTableName}"): options.reason must be a ` +
654
+ `non-empty string. The reason is the marker that justifies the bypass — ` +
655
+ `if you can't write one, declare data via r.entity() instead.`,
656
+ );
657
+ }
658
+ rawTables[rawTableName] = {
659
+ name: rawTableName,
660
+ table,
661
+ reason: options.reason,
662
+ };
663
+ },
664
+
628
665
  claimKey<T extends ClaimKeyType>(
629
666
  shortName: string,
630
667
  options: { readonly type: T },
@@ -698,5 +735,6 @@ export function defineFeature<const TName extends string, TExports = undefined>(
698
735
  navs,
699
736
  workspaces,
700
737
  httpRoutes,
738
+ rawTables,
701
739
  };
702
740
  }
@@ -26,6 +26,7 @@ import type {
26
26
  PreSaveHookFn,
27
27
  ProjectionDefinition,
28
28
  QueryHandlerDef,
29
+ RawTableDef,
29
30
  ReferenceDataDef,
30
31
  RegistrarExtensionDef,
31
32
  RegistrarExtensionRegistration,
@@ -153,6 +154,12 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
153
154
  // qualified-MSP-name → owning-feature name. Used by the event-dispatcher
154
155
  // to pause consumers whose feature is globally disabled.
155
156
  const multiStreamProjectionFeatureMap = new Map<string, string>();
157
+ // Raw tables — declared via r.rawTable(). Bypass the projection registry,
158
+ // so they have no qualified-name namespace and no source-entity index.
159
+ // Keyed by the feature-local short name; cross-feature uniqueness is
160
+ // enforced at ingest below (collisions would race two CREATE TABLE
161
+ // statements at the same physical name and break boot).
162
+ const rawTableMap = new Map<string, RawTableDef>();
156
163
  // Auth-claims hooks — tagged with featureName so the login resolver can
157
164
  // auto-prefix each hook's returned keys with "<feature>:".
158
165
  const authClaimsHooks: AuthClaimsHookDef[] = [];
@@ -484,6 +491,22 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
484
491
  multiStreamProjectionFeatureMap.set(qualified, feature.name);
485
492
  }
486
493
 
494
+ // Raw tables: aggregated by feature-local short name (unprefixed —
495
+ // these bypass the qualified-name namespace because they have no
496
+ // event-stream binding to disambiguate). Reject cross-feature
497
+ // duplicates at boot so the dev-server doesn't race two CREATE TABLE
498
+ // statements that target the same physical table name.
499
+ for (const [rawName, rawDef] of Object.entries(feature.rawTables)) {
500
+ const existing = rawTableMap.get(rawName);
501
+ if (existing) {
502
+ throw new Error(
503
+ `Raw-table "${rawName}" registered by both feature "${existing.featureName}" and ` +
504
+ `"${feature.name}". Pick a feature-prefixed name to disambiguate.`,
505
+ );
506
+ }
507
+ rawTableMap.set(rawName, { ...rawDef, featureName: feature.name });
508
+ }
509
+
487
510
  // Claim keys: aggregated by qualified name. Two features cannot collide
488
511
  // here (qualified by feature name), but we still guard for explicit
489
512
  // correctness — the only way to hit this is a hand-built FeatureDefinition
@@ -739,6 +762,25 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
739
762
  }
740
763
  }
741
764
 
765
+ // Cross-cut: a r.rawTable() PgTable must not coincide with any
766
+ // registered projection's table. Silent dedupe via Set would mask a
767
+ // real authoring bug (two owners writing to the same physical table).
768
+ // Run after both passes so implicit projections are visible too.
769
+ const projectionTables = new Set<unknown>();
770
+ for (const proj of projectionMap.values()) projectionTables.add(proj.table);
771
+ for (const msp of multiStreamProjectionMap.values()) {
772
+ if (msp.table) projectionTables.add(msp.table);
773
+ }
774
+ for (const raw of rawTableMap.values()) {
775
+ if (projectionTables.has(raw.table)) {
776
+ throw new Error(
777
+ `r.rawTable "${raw.name}" (feature "${raw.featureName}") shares a Drizzle ` +
778
+ `PgTable with a registered projection. Pick one owner: r.entity() / ` +
779
+ `r.projection() for event-sourced reads, r.rawTable() for the bypass.`,
780
+ );
781
+ }
782
+ }
783
+
742
784
  for (const [entityName, rels] of relationMap) {
743
785
  const includes = new Map<string, readonly string[]>();
744
786
  for (const [relName, rel] of Object.entries(rels)) {
@@ -1234,6 +1276,10 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
1234
1276
  return projectionMap;
1235
1277
  },
1236
1278
 
1279
+ getAllRawTables(): ReadonlyMap<string, RawTableDef> {
1280
+ return rawTableMap;
1281
+ },
1282
+
1237
1283
  getAllMultiStreamProjections(): ReadonlyMap<string, MultiStreamProjectionDefinition> {
1238
1284
  return multiStreamProjectionMap;
1239
1285
  },
@@ -1,3 +1,4 @@
1
+ import type { PgTable } from "drizzle-orm/pg-core";
1
2
  import type { ZodType, z } from "zod";
2
3
  import type { QueryHandlerDefinition, WriteHandlerDefinition } from "../define-handler";
3
4
  import type {
@@ -107,6 +108,36 @@ export type SecretKeyHandle = {
107
108
  readonly name: string;
108
109
  };
109
110
 
111
+ // --- Raw tables (declared by features via r.rawTable()) ---
112
+
113
+ /** Options accepted by `r.rawTable()`. The `reason` is required so the
114
+ * bypass leaves an audit trail at the registration site — reviewers can
115
+ * judge legitimacy without spelunking into history, and a future cleanup
116
+ * pass can find candidates for migration to `r.entity()`. */
117
+ export type RawTableOptions = {
118
+ /** Why this table needs to bypass the event-sourcing system. Examples:
119
+ * "imported from pre-ES system, read-only", "external Stripe webhook
120
+ * payload cache, write-only by webhook handler", "denormalised
121
+ * projection of a non-Kumiko data source". */
122
+ readonly reason: string;
123
+ };
124
+
125
+ /** Per-feature raw-table registration. Carries the bypass-justification
126
+ * reason but knows nothing about the owning feature — that's added when
127
+ * the registry aggregates entries cross-feature into `RawTableDef`. */
128
+ export type RawTableEntry = {
129
+ readonly name: string;
130
+ readonly table: PgTable;
131
+ readonly reason: string;
132
+ };
133
+
134
+ /** Registry-aggregated raw-table — the per-feature `RawTableEntry` plus
135
+ * the owning feature name. This is what `Registry.getAllRawTables()`
136
+ * exposes to readers (dev-server, ops UIs). */
137
+ export type RawTableDef = RawTableEntry & {
138
+ readonly featureName: string;
139
+ };
140
+
110
141
  // --- Feature Definition (output of defineFeature) ---
111
142
 
112
143
  export type FeatureDefinition = {
@@ -184,6 +215,10 @@ export type FeatureDefinition = {
184
215
  // den Hono-app (außerhalb /api/*). Pattern symmetrisch zu queryHandlers/
185
216
  // writeHandlers — Routes leben mit dem Feature, nicht im Bootstrap.
186
217
  readonly httpRoutes: Readonly<Record<string, HttpRouteDefinition>>;
218
+ // Raw tables declared via r.rawTable() — bypass the event-sourcing
219
+ // system. Keyed by feature-local short name. The registry attaches
220
+ // featureName on aggregation, lifting RawTableEntry → RawTableDef.
221
+ readonly rawTables: Readonly<Record<string, RawTableEntry>>;
187
222
  };
188
223
 
189
224
  // --- Feature Registrar (the "r" object in defineFeature) ---
@@ -407,6 +442,19 @@ export type FeatureRegistrar<TFeature extends string = string> = {
407
442
  // nicht im Bootstrap. Escape-hatch für nicht-feature-bound Routes
408
443
  // bleibt runProdApp.extraRoutes.
409
444
  httpRoute(definition: HttpRouteDefinition): void;
445
+
446
+ // Declare a raw Drizzle table that bypasses the event-sourcing system.
447
+ // Reserved for legacy-import, read-only caches, write-only webhook
448
+ // payload buffers, or any other case where the event-sourced flow
449
+ // doesn't fit. The dev-server iterates these alongside r.entity()
450
+ // projections at boot so the table exists before the first query.
451
+ // Apps still declare the table in `drizzle/schema.ts` so drizzle-kit
452
+ // tracks migrations and schema-drift detection works automatically.
453
+ //
454
+ // The required `reason` string is the marker that justifies the bypass —
455
+ // a non-empty string is the contract. If you can't write a reason,
456
+ // declare data via `r.entity()` instead.
457
+ rawTable(name: string, table: PgTable, options: RawTableOptions): void;
410
458
  };
411
459
 
412
460
  // --- Registry (created from features) ---
@@ -506,6 +554,13 @@ export type Registry = {
506
554
  getProjectionsForSource(entityName: string): readonly ProjectionDefinition[];
507
555
  getAllProjections(): ReadonlyMap<string, ProjectionDefinition>;
508
556
 
557
+ // All r.rawTable() registrations across all features, keyed by
558
+ // feature-local short name. The dev-server iterates this alongside
559
+ // implicit projections at boot. Cross-feature uniqueness is enforced
560
+ // at registry-build — duplicate names from different features fail
561
+ // the boot, so callers can rely on a flat keyspace.
562
+ getAllRawTables(): ReadonlyMap<string, RawTableDef>;
563
+
509
564
  // Multi-stream projections registered via r.multiStreamProjection().
510
565
  // Keyed by qualified name. The server wires each into the event-dispatcher
511
566
  // as its own EventConsumer with a dedicated cursor.
@@ -56,6 +56,9 @@ export type {
56
56
  FeatureMetricType,
57
57
  FeatureRegistrar,
58
58
  MetricOptions,
59
+ RawTableDef,
60
+ RawTableEntry,
61
+ RawTableOptions,
59
62
  Registry,
60
63
  SecretKeyDefinition,
61
64
  SecretKeyHandle,
@@ -18,7 +18,13 @@ import { createTenantDb, type TenantDb } from "../../db/tenant-db";
18
18
  import { createEntity, createRegistry, createTextField, defineFeature } from "../../engine";
19
19
  import type { StoredEvent } from "../../event-store";
20
20
  import { rebuildProjection } from "../../pipeline";
21
- import { createEntityTable, createTestDb, pushTables, type TestDb, TestUsers } from "../../stack";
21
+ import {
22
+ createTestDb,
23
+ type TestDb,
24
+ TestUsers,
25
+ unsafeCreateEntityTable,
26
+ unsafePushTables,
27
+ } from "../../stack";
22
28
  import { append, createEventsTable } from "../index";
23
29
  import { upcastStoredEvent } from "../upcaster";
24
30
 
@@ -102,11 +108,11 @@ const orderExecutor = createEventStoreExecutor(orderTable, orderEntity, {
102
108
 
103
109
  beforeAll(async () => {
104
110
  testDb = await createTestDb();
105
- await createEntityTable(testDb.db, orderEntity, "upcast-order");
111
+ await unsafeCreateEntityTable(testDb.db, orderEntity, "upcast-order");
106
112
  await createEventsTable(testDb.db);
107
113
  const { createProjectionStateTable } = await import("../../pipeline");
108
114
  await createProjectionStateTable(testDb.db);
109
- await pushTables(testDb.db, { upcastOrderSummary: orderSummaryTable });
115
+ await unsafePushTables(testDb.db, { upcastOrderSummary: orderSummaryTable });
110
116
  tdb = createTenantDb(testDb.db, admin.tenantId);
111
117
  });
112
118
 
@@ -298,7 +304,7 @@ describe("upcaster: async (Marten AsyncOnlyEventUpcaster — DB-Lookups)", () =>
298
304
  customerId: pgText("customer_id").primaryKey(),
299
305
  segment: pgText("segment").notNull(),
300
306
  });
301
- await pushTables(testDb.db, { upcastAsyncCustomerSegments: customerSegments });
307
+ await unsafePushTables(testDb.db, { upcastAsyncCustomerSegments: customerSegments });
302
308
  await testDb.db
303
309
  .insert(customerSegments)
304
310
  .values({ customerId: "c-async-1", segment: "PREMIUM" });
@@ -308,7 +314,7 @@ describe("upcaster: async (Marten AsyncOnlyEventUpcaster — DB-Lookups)", () =>
308
314
  customerId: pgText("customer_id").notNull(),
309
315
  segment: pgText("segment").notNull(),
310
316
  });
311
- await pushTables(testDb.db, { upcastAsyncSummary: asyncSummary });
317
+ await unsafePushTables(testDb.db, { upcastAsyncSummary: asyncSummary });
312
318
 
313
319
  // Feature with async upcaster v1 → v2: enrich payload with segment from DB.
314
320
  const asyncFeature = defineFeature("upcastasync", (r) => {
@@ -3,7 +3,7 @@ import type { DbConnection, DbRunner } from "../db/connection";
3
3
  import { instant, table as pgTable, text, uniqueIndex, uuid } from "../db/dialect";
4
4
  import { tableExists } from "../db/schema-inspection";
5
5
  import type { TenantId } from "../engine/types";
6
- import { pushTables } from "../stack";
6
+ import { unsafePushTables } from "../stack";
7
7
 
8
8
  // Marten-aligned stream archival. Archived streams become read-only: fresh
9
9
  // appendEvent on an archived aggregate throws, and loadAggregate returns
@@ -31,7 +31,7 @@ export const archivedStreamsTable = pgTable(
31
31
  export async function createArchivedStreamsTable(db: DbConnection): Promise<void> {
32
32
  // skip: table already exists — idempotent boot + test-setup call
33
33
  if (await tableExists(db, "public.kumiko_archived_streams")) return;
34
- await pushTables(db, { kumikoArchivedStreams: archivedStreamsTable });
34
+ await unsafePushTables(db, { kumikoArchivedStreams: archivedStreamsTable });
35
35
  }
36
36
 
37
37
  export type ArchiveStreamArgs = {
@@ -11,7 +11,7 @@ import {
11
11
  uniqueIndex,
12
12
  uuid,
13
13
  } from "../db/dialect";
14
- import { pushTables } from "../stack";
14
+ import { unsafePushTables } from "../stack";
15
15
  import { createArchivedStreamsTable } from "./archive";
16
16
  import { createSnapshotsTable } from "./snapshot";
17
17
 
@@ -83,7 +83,7 @@ export async function createEventsTable(db: DbConnection): Promise<void> {
83
83
  // skip: events table already exists — createEventsTable is called from both
84
84
  // setupTestStack and explicit test-setups, the guard keeps it idempotent.
85
85
  if (!(await tableExists(db, "public.kumiko_events"))) {
86
- await pushTables(db, { kumikoEvents: eventsTable });
86
+ await unsafePushTables(db, { kumikoEvents: eventsTable });
87
87
  }
88
88
  await createArchivedStreamsTable(db);
89
89
  await createSnapshotsTable(db);
@@ -12,7 +12,7 @@ import {
12
12
  } from "../db/dialect";
13
13
  import { tableExists } from "../db/schema-inspection";
14
14
  import type { TenantId } from "../engine/types";
15
- import { pushTables } from "../stack";
15
+ import { unsafePushTables } from "../stack";
16
16
  import { isStreamArchived } from "./archive";
17
17
  import { loadEventsAfterVersion, type StoredEvent } from "./event-store";
18
18
 
@@ -73,7 +73,7 @@ export const snapshotsTable = pgTable(
73
73
  export async function createSnapshotsTable(db: DbConnection): Promise<void> {
74
74
  // skip: table already exists — idempotent boot + test-setup call
75
75
  if (await tableExists(db, "public.kumiko_snapshots")) return;
76
- await pushTables(db, { kumikoSnapshots: snapshotsTable });
76
+ await unsafePushTables(db, { kumikoSnapshots: snapshotsTable });
77
77
  }
78
78
 
79
79
  export type Snapshot<TState extends Record<string, unknown> = Record<string, unknown>> = {
@@ -15,7 +15,7 @@
15
15
  import { bigint, index, integer, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core";
16
16
  import type { DbConnection, DbRunner } from "../db/connection";
17
17
  import { tableExists } from "../db/schema-inspection";
18
- import { pushTables } from "../stack";
18
+ import { unsafePushTables } from "../stack";
19
19
  import type { StoredEvent } from "./event-store";
20
20
 
21
21
  export const upcasterDeadLetterTable = pgTable(
@@ -50,7 +50,7 @@ export const upcasterDeadLetterTable = pgTable(
50
50
  export async function createUpcasterDeadLetterTable(db: DbConnection): Promise<void> {
51
51
  // skip: table already exists — bootstrap called from multiple paths
52
52
  if (await tableExists(db, "public.kumiko_upcaster_dead_letters")) return;
53
- await pushTables(db, { kumikoUpcasterDeadLetters: upcasterDeadLetterTable });
53
+ await unsafePushTables(db, { kumikoUpcasterDeadLetters: upcasterDeadLetterTable });
54
54
  }
55
55
 
56
56
  // Writes a dead-letter row. Called by upcastStoredEvent when errorPolicy
@@ -10,7 +10,7 @@
10
10
  import { sql } from "drizzle-orm";
11
11
  import { afterAll, beforeAll, describe, expect, test } from "vitest";
12
12
  import { createEntity, createFileField, createImageField } from "../../engine";
13
- import { createEntityTable, createTestDb, pushTables, type TestDb } from "../../stack";
13
+ import { createTestDb, type TestDb, unsafeCreateEntityTable, unsafePushTables } from "../../stack";
14
14
  import { generateId } from "../../utils";
15
15
  import { fileRefsTable } from "../file-ref-table";
16
16
 
@@ -28,8 +28,8 @@ let testDb: TestDb;
28
28
 
29
29
  beforeAll(async () => {
30
30
  testDb = await createTestDb();
31
- await pushTables(testDb.db, { fileRefsTable });
32
- await createEntityTable(testDb.db, documentEntity);
31
+ await unsafePushTables(testDb.db, { fileRefsTable });
32
+ await unsafeCreateEntityTable(testDb.db, documentEntity);
33
33
  });
34
34
 
35
35
  afterAll(async () => {
@@ -40,7 +40,7 @@ describe("file-field entity-column type", () => {
40
40
  test("`file` and `image` fields generate UUID columns (not integer)", async () => {
41
41
  // Pull the actual column type from information_schema. This is the
42
42
  // load-bearing assertion: the type emitted by drizzle-kit during
43
- // `createEntityTable` must be `uuid`. A regression to `integer` would
43
+ // `unsafeCreateEntityTable` must be `uuid`. A regression to `integer` would
44
44
  // fail here even if higher-level code happened to still work through
45
45
  // implicit casts.
46
46
  const rows = await testDb.db.execute<{ column_name: string; data_type: string }>(sql`
@@ -30,11 +30,11 @@ import {
30
30
  defineFeature,
31
31
  } from "../../engine";
32
32
  import {
33
- createEntityTable,
34
33
  createTestUser,
35
34
  setupTestStack,
36
35
  type TestStack,
37
36
  testTenantId,
37
+ unsafeCreateEntityTable,
38
38
  } from "../../stack";
39
39
  import { createLocalProvider } from "../local-provider";
40
40
 
@@ -74,7 +74,7 @@ beforeAll(async () => {
74
74
  features: [documentFeature],
75
75
  files: { storageProvider: createLocalProvider(storagePath) },
76
76
  });
77
- await createEntityTable(stack.db, documentEntity);
77
+ await unsafeCreateEntityTable(stack.db, documentEntity);
78
78
  });
79
79
 
80
80
  afterAll(async () => {