@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.
- package/LICENSE +57 -0
- package/package.json +1 -1
- package/src/__tests__/anonymous-access.integration.ts +7 -7
- package/src/__tests__/error-contract.integration.ts +2 -2
- package/src/__tests__/field-access.integration.ts +2 -2
- package/src/__tests__/full-stack.integration.ts +2 -2
- package/src/__tests__/ownership.integration.ts +2 -2
- package/src/__tests__/raw-table.integration.ts +128 -0
- package/src/__tests__/reference-data.integration.ts +2 -2
- package/src/__tests__/transition-guard.integration.ts +4 -4
- package/src/api/__tests__/batch.integration.ts +3 -3
- package/src/api/__tests__/dispatcher-live.integration.ts +2 -2
- package/src/api/__tests__/nested-write.integration.ts +3 -3
- package/src/db/__tests__/drizzle-helpers.integration.ts +2 -2
- package/src/db/__tests__/event-store-executor-list.integration.ts +2 -2
- package/src/db/__tests__/event-store-executor.integration.ts +9 -3
- package/src/db/__tests__/implicit-projection-equivalence.integration.ts +3 -3
- package/src/db/__tests__/multi-row-insert.integration.ts +3 -3
- package/src/db/__tests__/schema-migration.integration.ts +9 -9
- package/src/db/__tests__/tenant-db.integration.ts +4 -4
- package/src/db/__tests__/unique-violation-mapping.integration.ts +2 -2
- package/src/db/schema-inspection.ts +1 -1
- package/src/engine/__tests__/raw-table.test.ts +149 -0
- package/src/engine/define-feature.ts +38 -0
- package/src/engine/registry.ts +46 -0
- package/src/engine/types/feature.ts +55 -0
- package/src/engine/types/index.ts +3 -0
- package/src/event-store/__tests__/upcaster.integration.ts +11 -5
- package/src/event-store/archive.ts +2 -2
- package/src/event-store/events-schema.ts +2 -2
- package/src/event-store/snapshot.ts +2 -2
- package/src/event-store/upcaster-dead-letter.ts +2 -2
- package/src/files/__tests__/file-field-column.integration.ts +4 -4
- package/src/files/__tests__/file-field-pipeline.integration.ts +2 -2
- package/src/files/__tests__/files.integration.ts +8 -8
- package/src/observability/__tests__/observability.integration.ts +2 -2
- package/src/pipeline/__tests__/archive-stream.integration.ts +2 -2
- package/src/pipeline/__tests__/cascade-handler.integration.ts +9 -9
- package/src/pipeline/__tests__/causation-chain.integration.ts +2 -2
- package/src/pipeline/__tests__/ctx-bridge.integration.ts +3 -3
- package/src/pipeline/__tests__/domain-events-projections.integration.ts +2 -2
- package/src/pipeline/__tests__/event-dedup.integration.ts +2 -2
- package/src/pipeline/__tests__/event-define-event-strict.integration.ts +2 -2
- package/src/pipeline/__tests__/event-dispatcher-lifecycle.integration.ts +3 -3
- package/src/pipeline/__tests__/event-dispatcher-multi-instance.integration.ts +2 -2
- package/src/pipeline/__tests__/event-dispatcher-pg-listen.integration.ts +2 -2
- package/src/pipeline/__tests__/event-dispatcher-recovery.integration.ts +2 -2
- package/src/pipeline/__tests__/event-dispatcher-second-audit.integration.ts +4 -4
- package/src/pipeline/__tests__/event-dispatcher.integration.ts +4 -4
- package/src/pipeline/__tests__/event-retention.integration.ts +2 -2
- package/src/pipeline/__tests__/fetch-for-writing.integration.ts +2 -2
- package/src/pipeline/__tests__/load-aggregate-query.integration.ts +2 -2
- package/src/pipeline/__tests__/msp-error-mode.integration.ts +2 -2
- package/src/pipeline/__tests__/msp-multi-hop.integration.ts +2 -2
- package/src/pipeline/__tests__/msp-rebuild.integration.ts +3 -3
- package/src/pipeline/__tests__/multi-stream-projection.integration.ts +3 -3
- package/src/pipeline/__tests__/perf-rebuild.integration.ts +2 -2
- package/src/pipeline/__tests__/projection-rebuild.integration.ts +9 -3
- package/src/pipeline/__tests__/query-projection.integration.ts +2 -2
- package/src/pipeline/event-consumer-state.ts +2 -2
- package/src/pipeline/projection-state.ts +3 -3
- package/src/stack/index.ts +4 -3
- package/src/stack/push-entity-projection-tables.ts +51 -0
- package/src/stack/table-helpers.ts +20 -13
- package/src/stack/test-stack.ts +13 -4
- 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
|
|
45
|
-
await
|
|
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 {
|
|
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
|
|
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
|
|
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
|
}
|
package/src/engine/registry.ts
CHANGED
|
@@ -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.
|
|
@@ -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 {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 {
|
|
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
|
|
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 {
|
|
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
|
|
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 {
|
|
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
|
|
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 {
|
|
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
|
|
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 {
|
|
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
|
|
32
|
-
await
|
|
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
|
-
// `
|
|
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
|
|
77
|
+
await unsafeCreateEntityTable(stack.db, documentEntity);
|
|
78
78
|
});
|
|
79
79
|
|
|
80
80
|
afterAll(async () => {
|