@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,7 +2,7 @@ import { sql } from "drizzle-orm";
|
|
|
2
2
|
import type { DbConnection } from "../db/connection";
|
|
3
3
|
import { bigint, index, instant, table as pgTable, text } from "../db/dialect";
|
|
4
4
|
import { tableExists } from "../db/schema-inspection";
|
|
5
|
-
import {
|
|
5
|
+
import { unsafePushTables } from "../stack";
|
|
6
6
|
|
|
7
7
|
// Framework-level state for every registered projection. One row per qualified
|
|
8
8
|
// projection name. Written by the rebuild machinery; read by the CLI + any
|
|
@@ -23,7 +23,7 @@ import { pushTables } from "../stack";
|
|
|
23
23
|
// last_processed_event_id uses a raw DEFAULT 0 instead of .default(0n) because
|
|
24
24
|
// drizzle-kit's JSON snapshot generator cannot serialise bigint literals —
|
|
25
25
|
// `TypeError: Do not know how to serialize a BigInt` bubbles through
|
|
26
|
-
//
|
|
26
|
+
// unsafePushTables → generateMigration. `sql\`0\`` yields the same server-side
|
|
27
27
|
// default without ever putting a bigint in a generated-JSON path.
|
|
28
28
|
export const projectionStateTable = pgTable(
|
|
29
29
|
"kumiko_projections",
|
|
@@ -68,5 +68,5 @@ export const PROJECTION_STATUSES = [
|
|
|
68
68
|
export async function createProjectionStateTable(db: DbConnection): Promise<void> {
|
|
69
69
|
// skip: table already exists — bootstrap is called from multiple paths
|
|
70
70
|
if (await tableExists(db, "public.kumiko_projections")) return;
|
|
71
|
-
await
|
|
71
|
+
await unsafePushTables(db, { kumikoProjections: projectionStateTable });
|
|
72
72
|
}
|
package/src/stack/index.ts
CHANGED
|
@@ -15,13 +15,14 @@ export {
|
|
|
15
15
|
type TestDb,
|
|
16
16
|
} from "./db";
|
|
17
17
|
export { createEventCollector, type EventCollector } from "./event-collector";
|
|
18
|
+
export { pushEntityProjectionTables } from "./push-entity-projection-tables";
|
|
18
19
|
export { createTestRedis, type TestRedis } from "./redis";
|
|
19
20
|
export { createRequestHelper, type RequestHelper } from "./request-helper";
|
|
20
21
|
export {
|
|
21
|
-
createEntityTable,
|
|
22
|
-
ensureEntityTable,
|
|
23
|
-
pushTables,
|
|
24
22
|
resetEventStore,
|
|
23
|
+
unsafeCreateEntityTable,
|
|
24
|
+
unsafeEnsureEntityTable,
|
|
25
|
+
unsafePushTables,
|
|
25
26
|
} from "./table-helpers";
|
|
26
27
|
export { setupTestStack, type TestStack, type TestStackOptions } from "./test-stack";
|
|
27
28
|
export {
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { getTableName } from "drizzle-orm";
|
|
2
|
+
import { tableExists } from "../db/schema-inspection";
|
|
3
|
+
import type { Registry } from "../engine/types";
|
|
4
|
+
import { unsafePushTables } from "./table-helpers";
|
|
5
|
+
import type { TestStack } from "./test-stack";
|
|
6
|
+
|
|
7
|
+
// biome-ignore lint/suspicious/noConsole: stack-internal status logging
|
|
8
|
+
const logInfo = (msg: string): void => console.log(msg);
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Push all implicit-projection tables — one per `r.entity()` — that the
|
|
12
|
+
* registry knows about. setupTestStack already handles explicit
|
|
13
|
+
* projections, MSPs, and `r.rawTable()` declarations in its own loop;
|
|
14
|
+
* implicit projections are the missing piece for a fresh boot.
|
|
15
|
+
*
|
|
16
|
+
* Idempotent via `tableExists` so a persistent dev DB
|
|
17
|
+
* (`KUMIKO_DEV_DB_NAME`) reuses existing tables on reboot. One batched
|
|
18
|
+
* push at the end so drizzle-kit's `generateMigration` runs once over
|
|
19
|
+
* the whole missing set.
|
|
20
|
+
*
|
|
21
|
+
* Lives next to `setupTestStack` because both are stack-bootstrap
|
|
22
|
+
* helpers that legitimately speak the `unsafe*`-DDL layer; the
|
|
23
|
+
* Table-DDL Guard's stack/** allowlist is the single shared exemption
|
|
24
|
+
* site. Apps still declare data via `r.entity()` / `r.rawTable()` and
|
|
25
|
+
* never call this directly.
|
|
26
|
+
*/
|
|
27
|
+
export async function pushEntityProjectionTables(
|
|
28
|
+
stack: TestStack,
|
|
29
|
+
registry: Registry,
|
|
30
|
+
): Promise<void> {
|
|
31
|
+
const seen = new Set<unknown>();
|
|
32
|
+
const missing: Record<string, unknown> = {};
|
|
33
|
+
|
|
34
|
+
for (const [projName, proj] of registry.getAllProjections()) {
|
|
35
|
+
if (!proj.isImplicit) continue;
|
|
36
|
+
if (seen.has(proj.table)) continue;
|
|
37
|
+
seen.add(proj.table);
|
|
38
|
+
// @cast-boundary drizzle-bridge — ProjectionTable + PgTable both round-trip
|
|
39
|
+
// through getTableName at runtime; the type system can't unify them.
|
|
40
|
+
const physical = getTableName(proj.table as Parameters<typeof getTableName>[0]);
|
|
41
|
+
if (await tableExists(stack.db, `public.${physical}`)) {
|
|
42
|
+
logInfo(`[kumiko-stack] table ${physical} already exists — skipping create`);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
missing[projName] = proj.table;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (Object.keys(missing).length > 0) {
|
|
49
|
+
await unsafePushTables(stack.db, missing);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -7,49 +7,56 @@ import { buildDrizzleTable, toTableName } from "../db/table-builder";
|
|
|
7
7
|
import type { TestStack } from "./test-stack";
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
10
|
+
* Bypass: creates a Drizzle table directly, without registering it as
|
|
11
|
+
* a projection of the event-sourcing engine. Apps should declare data
|
|
12
|
+
* via `r.entity(...)` and get tables, migrations, snapshots and audit
|
|
13
|
+
* for free — this helper is reserved for framework-internal meta-tables
|
|
14
|
+
* (event-store, snapshots, projection-state) and test setup.
|
|
15
|
+
*
|
|
12
16
|
* Strict: raises a postgres `relation already exists` (42P07) error if
|
|
13
|
-
* the table is already there. Use `
|
|
14
|
-
* boot
|
|
17
|
+
* the table is already there. Use `unsafeEnsureEntityTable` for the
|
|
18
|
+
* idempotent boot-path variant.
|
|
15
19
|
*/
|
|
16
|
-
export async function
|
|
20
|
+
export async function unsafeCreateEntityTable(
|
|
17
21
|
db: ReturnType<typeof drizzle>,
|
|
18
22
|
entity: import("../engine/types").EntityDefinition,
|
|
19
23
|
entityName?: string,
|
|
20
24
|
): Promise<void> {
|
|
21
25
|
const table = buildDrizzleTable(entityName ?? "entity", entity);
|
|
22
|
-
await
|
|
26
|
+
await unsafePushTables(db, { [entityName ?? "entity"]: table });
|
|
23
27
|
}
|
|
24
28
|
|
|
25
29
|
/**
|
|
26
|
-
*
|
|
30
|
+
* Bypass (idempotent): same caveat as `unsafeCreateEntityTable` —
|
|
31
|
+
* apps declare data via `r.entity(...)`. Checks whether the entity's
|
|
27
32
|
* table already exists and skips creation if so. Schema-drift is *not*
|
|
28
|
-
* detected
|
|
33
|
+
* detected: if the table is there but has the wrong columns, that's
|
|
29
34
|
* the caller's problem (the dev-server contract is "drop the DB by
|
|
30
35
|
* hand when you change the schema"). Tests should use
|
|
31
|
-
* `
|
|
36
|
+
* `unsafeCreateEntityTable` instead, since they rely on fresh DBs.
|
|
32
37
|
*/
|
|
33
|
-
export async function
|
|
38
|
+
export async function unsafeEnsureEntityTable(
|
|
34
39
|
db: ReturnType<typeof drizzle>,
|
|
35
40
|
entity: import("../engine/types").EntityDefinition,
|
|
36
41
|
entityName?: string,
|
|
37
42
|
): Promise<boolean> {
|
|
38
43
|
const resolvedName = entity.table ?? toTableName(entityName ?? "entity");
|
|
39
44
|
if (await tableExists(db, `public.${resolvedName}`)) return false;
|
|
40
|
-
await
|
|
45
|
+
await unsafeCreateEntityTable(db, entity, entityName);
|
|
41
46
|
return true;
|
|
42
47
|
}
|
|
43
48
|
|
|
44
49
|
/**
|
|
45
|
-
*
|
|
50
|
+
* Bypass: pushes Drizzle table definitions to the database directly.
|
|
46
51
|
* Uses drizzle-kit's generateDrizzleJson + generateMigration to produce SQL,
|
|
47
52
|
* then executes it. Same SQL that `drizzle-kit push` would generate.
|
|
53
|
+
* Reserved for framework-internal meta-tables (event-store, projections,
|
|
54
|
+
* consumer-state) and test setup — apps declare data via `r.entity(...)`.
|
|
48
55
|
*
|
|
49
56
|
* @param prevTables - Previous table definitions (for ALTER TABLE scenarios).
|
|
50
57
|
* If omitted, assumes empty DB (CREATE TABLE).
|
|
51
58
|
*/
|
|
52
|
-
export async function
|
|
59
|
+
export async function unsafePushTables(
|
|
53
60
|
db: ReturnType<typeof drizzle>,
|
|
54
61
|
tables: Record<string, unknown>,
|
|
55
62
|
prevTables?: Record<string, unknown>,
|
package/src/stack/test-stack.ts
CHANGED
|
@@ -17,7 +17,7 @@ import { createTestDb } from "./db";
|
|
|
17
17
|
import { createEventCollector, type EventCollector } from "./event-collector";
|
|
18
18
|
import { createTestRedis, type TestRedis } from "./redis";
|
|
19
19
|
import { createRequestHelper, type RequestHelper } from "./request-helper";
|
|
20
|
-
import {
|
|
20
|
+
import { unsafePushTables } from "./table-helpers";
|
|
21
21
|
|
|
22
22
|
export type TestStack = {
|
|
23
23
|
app: Hono;
|
|
@@ -161,7 +161,7 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
|
|
|
161
161
|
// stays off tenant test DBs that never touch files.
|
|
162
162
|
if (options.files) {
|
|
163
163
|
const { fileRefsTable } = await import("../files");
|
|
164
|
-
await
|
|
164
|
+
await unsafePushTables(testDb.db, { fileRefsTable });
|
|
165
165
|
}
|
|
166
166
|
|
|
167
167
|
// Projection tables: the executor writes into them in the same TX as the
|
|
@@ -189,9 +189,18 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
|
|
|
189
189
|
seenTables.add(msp.table);
|
|
190
190
|
projectionTables[`msp_${mspName}`] = msp.table;
|
|
191
191
|
}
|
|
192
|
+
// Raw tables declared via r.rawTable(). Same auto-push rule — the
|
|
193
|
+
// table needs to exist before the first reader query runs. The
|
|
194
|
+
// bypass is in the registration site (r.rawTable's `unsafe` cousins
|
|
195
|
+
// would target the same DDL), not in setting up the test DB.
|
|
196
|
+
for (const [rawName, raw] of Object.entries(feature.rawTables)) {
|
|
197
|
+
if (seenTables.has(raw.table)) continue;
|
|
198
|
+
seenTables.add(raw.table);
|
|
199
|
+
projectionTables[`raw_${rawName}`] = raw.table;
|
|
200
|
+
}
|
|
192
201
|
}
|
|
193
202
|
if (Object.keys(projectionTables).length > 0) {
|
|
194
|
-
//
|
|
203
|
+
// unsafePushTables emits raw CREATE TABLE — fine for ephemeral test DBs but
|
|
195
204
|
// collides on re-boot against a persistent DB whose projection tables
|
|
196
205
|
// were created during a previous run. Filter out the ones that already
|
|
197
206
|
// exist; drizzle-kit's diff machinery would otherwise emit CREATE for
|
|
@@ -205,7 +214,7 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
|
|
|
205
214
|
missing[key] = tbl;
|
|
206
215
|
}
|
|
207
216
|
if (Object.keys(missing).length > 0) {
|
|
208
|
-
await
|
|
217
|
+
await unsafePushTables(testDb.db, missing);
|
|
209
218
|
}
|
|
210
219
|
}
|
|
211
220
|
|
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
import { sql } from "drizzle-orm";
|
|
2
2
|
import { afterAll, beforeAll, describe, expect, test } from "vitest";
|
|
3
3
|
import type { EntityDefinition } from "../../engine/types";
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
|
|
4
|
+
import {
|
|
5
|
+
createTestDb,
|
|
6
|
+
type TestDb,
|
|
7
|
+
unsafeCreateEntityTable,
|
|
8
|
+
unsafeEnsureEntityTable,
|
|
9
|
+
} from "../../stack";
|
|
10
|
+
|
|
11
|
+
// unsafeEnsureEntityTable ist die idempotente Variante von unsafeCreateEntityTable —
|
|
7
12
|
// existiert wegen des dev-server-Boot-Pfads (persistente DB, Table von
|
|
8
|
-
// letztem Run).
|
|
13
|
+
// letztem Run). unsafeCreateEntityTable bleibt strict, damit Tests ein
|
|
9
14
|
// falsches Schema nicht stillschweigend akzeptieren.
|
|
10
15
|
|
|
11
16
|
const tenantEntity: EntityDefinition = {
|
|
@@ -25,9 +30,9 @@ afterAll(async () => {
|
|
|
25
30
|
await db.cleanup();
|
|
26
31
|
});
|
|
27
32
|
|
|
28
|
-
describe("
|
|
33
|
+
describe("unsafeEnsureEntityTable", () => {
|
|
29
34
|
test("legt die Tabelle beim ersten Aufruf an (returnt true)", async () => {
|
|
30
|
-
const created = await
|
|
35
|
+
const created = await unsafeEnsureEntityTable(db.db, tenantEntity, "probe");
|
|
31
36
|
expect(created).toBe(true);
|
|
32
37
|
const rows = await db.db.execute<{ exists: boolean }>(
|
|
33
38
|
sql`SELECT to_regclass('public.ensure_entity_table_probe') IS NOT NULL AS exists`,
|
|
@@ -36,17 +41,17 @@ describe("ensureEntityTable", () => {
|
|
|
36
41
|
});
|
|
37
42
|
|
|
38
43
|
test("ist beim zweiten Aufruf ein No-Op (returnt false, kein Fehler)", async () => {
|
|
39
|
-
const created = await
|
|
44
|
+
const created = await unsafeEnsureEntityTable(db.db, tenantEntity, "probe");
|
|
40
45
|
expect(created).toBe(false);
|
|
41
46
|
});
|
|
42
47
|
|
|
43
|
-
test("
|
|
44
|
-
// Gleiche Entity zweimal via
|
|
48
|
+
test("unsafeCreateEntityTable bleibt strict — wirft bei existierender Tabelle", async () => {
|
|
49
|
+
// Gleiche Entity zweimal via unsafeCreateEntityTable → postgres 42P07
|
|
45
50
|
// (relation already exists). Drizzle wrappt den PG-Error in
|
|
46
51
|
// DrizzleQueryError; der echte Code steckt in .cause. Sicherstellt,
|
|
47
|
-
// dass
|
|
52
|
+
// dass unsafeEnsureEntityTable nicht versehentlich das strict-Verhalten
|
|
48
53
|
// verändert.
|
|
49
|
-
await expect(
|
|
54
|
+
await expect(unsafeCreateEntityTable(db.db, tenantEntity, "probe")).rejects.toSatisfy((err) => {
|
|
50
55
|
const cause = (err as { cause?: { code?: string } }).cause;
|
|
51
56
|
return cause?.code === "42P07";
|
|
52
57
|
});
|