@cosmicdrift/kumiko-framework 0.19.0 → 0.20.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/README.md +5 -5
- package/package.json +5 -1
- package/src/db/__tests__/rebuild-marker.test.ts +86 -0
- package/src/db/index.ts +5 -0
- package/src/db/rebuild-marker.ts +75 -0
- package/src/schema-cli.ts +13 -0
- package/src/seeding/__tests__/entity-seed.test.ts +59 -0
- package/src/seeding/entity-seed.ts +27 -0
- package/src/seeding/index.ts +6 -0
- package/src/seeding/types.ts +8 -0
package/README.md
CHANGED
|
@@ -7,7 +7,7 @@ Framework core for Kumiko — engine, pipeline, API, DB, event-store, and
|
|
|
7
7
|
every other bit that makes Kumiko go.
|
|
8
8
|
|
|
9
9
|
> Multi-tenant, command-based, event-sourced app framework for Bun + Hono +
|
|
10
|
-
>
|
|
10
|
+
> Postgres. Define features, register entities, write commands — the framework
|
|
11
11
|
> wires dispatch, persistence, projections, async subscribers, and realtime
|
|
12
12
|
> delivery.
|
|
13
13
|
|
|
@@ -18,12 +18,12 @@ for runnable examples of every feature.
|
|
|
18
18
|
## Install
|
|
19
19
|
|
|
20
20
|
```bash
|
|
21
|
-
|
|
21
|
+
bun add @cosmicdrift/kumiko-framework
|
|
22
22
|
# peers you probably already have:
|
|
23
|
-
|
|
23
|
+
bun add hono ioredis zod
|
|
24
24
|
```
|
|
25
25
|
|
|
26
|
-
Bun is the intended runtime
|
|
26
|
+
Bun is the intended runtime and test runner.
|
|
27
27
|
|
|
28
28
|
## At-a-glance
|
|
29
29
|
|
|
@@ -108,7 +108,7 @@ export const taskFeature = defineFeature("tasks", (r) => {
|
|
|
108
108
|
| Entry | What's in it |
|
|
109
109
|
|---|---|
|
|
110
110
|
| `@cosmicdrift/kumiko-framework/engine` | `defineFeature`, `createEntity`, field helpers, access rules, registry |
|
|
111
|
-
| `@cosmicdrift/kumiko-framework/db` |
|
|
111
|
+
| `@cosmicdrift/kumiko-framework/db` | `buildEntityTableMeta`, `createEventStoreExecutor`, migrations, tenant-db |
|
|
112
112
|
| `@cosmicdrift/kumiko-framework/event-store` | `events` table, `append`, `loadAggregate`, `loadAggregateAsOf` |
|
|
113
113
|
| `@cosmicdrift/kumiko-framework/pipeline` | Dispatcher, event-dispatcher (AsyncDaemon), projection-rebuild, SSE + search consumers |
|
|
114
114
|
| `@cosmicdrift/kumiko-framework/api` | `buildServer`, auth middleware, SSE route, error contract |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -123,6 +123,10 @@
|
|
|
123
123
|
"types": "./src/search/meilisearch-adapter.ts",
|
|
124
124
|
"default": "./src/search/meilisearch-adapter.ts"
|
|
125
125
|
},
|
|
126
|
+
"./seeding": {
|
|
127
|
+
"types": "./src/seeding/index.ts",
|
|
128
|
+
"default": "./src/seeding/index.ts"
|
|
129
|
+
},
|
|
126
130
|
"./secrets": {
|
|
127
131
|
"types": "./src/secrets/index.ts",
|
|
128
132
|
"default": "./src/secrets/index.ts"
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import type { EntityTableMeta } from "../entity-table-meta";
|
|
6
|
+
import { diffSnapshots, snapshotFromMetas } from "../migrate-generator";
|
|
7
|
+
import { readRebuildMarker, rebuildTablesFromDiff, writeRebuildMarker } from "../rebuild-marker";
|
|
8
|
+
|
|
9
|
+
function meta(
|
|
10
|
+
tableName: string,
|
|
11
|
+
extraColumn?: EntityTableMeta["columns"][number],
|
|
12
|
+
): EntityTableMeta {
|
|
13
|
+
return {
|
|
14
|
+
tableName,
|
|
15
|
+
source: "unmanaged",
|
|
16
|
+
indexes: [],
|
|
17
|
+
columns: [
|
|
18
|
+
{ name: "id", pgType: "uuid", notNull: true, primaryKey: true },
|
|
19
|
+
...(extraColumn ? [extraColumn] : []),
|
|
20
|
+
],
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function tmpDir(): string {
|
|
25
|
+
return mkdtempSync(join(tmpdir(), "rebuild-marker-"));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe("rebuildTablesFromDiff", () => {
|
|
29
|
+
test("includes new + changed tables, excludes dropped, sorted + unique", () => {
|
|
30
|
+
const prev = snapshotFromMetas([meta("read_a"), meta("read_c")]);
|
|
31
|
+
const next = snapshotFromMetas([
|
|
32
|
+
meta("read_a", { name: "title", pgType: "text", notNull: true }),
|
|
33
|
+
meta("read_b"),
|
|
34
|
+
]);
|
|
35
|
+
const diff = diffSnapshots(prev, next);
|
|
36
|
+
expect(rebuildTablesFromDiff(diff)).toEqual(["read_a", "read_b"]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("no schema change → empty", () => {
|
|
40
|
+
const snap = snapshotFromMetas([meta("read_a")]);
|
|
41
|
+
expect(rebuildTablesFromDiff(diffSnapshots(snap, snap))).toEqual([]);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
describe("write/read marker", () => {
|
|
46
|
+
test("roundtrip: write tables → read them back via migration-id", () => {
|
|
47
|
+
const dir = tmpDir();
|
|
48
|
+
try {
|
|
49
|
+
writeRebuildMarker(dir, "0002_add_locale.sql", ["read_users", "read_text_blocks"]);
|
|
50
|
+
expect(existsSync(join(dir, "0002_add_locale.rebuild.json"))).toBe(true);
|
|
51
|
+
expect(readRebuildMarker(dir, "0002_add_locale")).toEqual(["read_users", "read_text_blocks"]);
|
|
52
|
+
} finally {
|
|
53
|
+
rmSync(dir, { recursive: true, force: true });
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("empty table list → no marker file written, read returns []", () => {
|
|
58
|
+
const dir = tmpDir();
|
|
59
|
+
try {
|
|
60
|
+
writeRebuildMarker(dir, "0003_noop.sql", []);
|
|
61
|
+
expect(existsSync(join(dir, "0003_noop.rebuild.json"))).toBe(false);
|
|
62
|
+
expect(readRebuildMarker(dir, "0003_noop")).toEqual([]);
|
|
63
|
+
} finally {
|
|
64
|
+
rmSync(dir, { recursive: true, force: true });
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("missing marker → []", () => {
|
|
69
|
+
const dir = tmpDir();
|
|
70
|
+
try {
|
|
71
|
+
expect(readRebuildMarker(dir, "9999_absent")).toEqual([]);
|
|
72
|
+
} finally {
|
|
73
|
+
rmSync(dir, { recursive: true, force: true });
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("corrupt marker file → [] (does not throw)", () => {
|
|
78
|
+
const dir = tmpDir();
|
|
79
|
+
try {
|
|
80
|
+
writeFileSync(join(dir, "0004_broken.rebuild.json"), "{ not json");
|
|
81
|
+
expect(readRebuildMarker(dir, "0004_broken")).toEqual([]);
|
|
82
|
+
} finally {
|
|
83
|
+
rmSync(dir, { recursive: true, force: true });
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
});
|
package/src/db/index.ts
CHANGED
|
@@ -95,6 +95,11 @@ export {
|
|
|
95
95
|
transaction,
|
|
96
96
|
updateMany,
|
|
97
97
|
} from "./query-api";
|
|
98
|
+
export {
|
|
99
|
+
readRebuildMarker,
|
|
100
|
+
rebuildTablesFromDiff,
|
|
101
|
+
writeRebuildMarker,
|
|
102
|
+
} from "./rebuild-marker";
|
|
98
103
|
export { seedReferenceData } from "./reference-data";
|
|
99
104
|
export { renderTableDdl, renderTablesDdl } from "./render-ddl";
|
|
100
105
|
export { tableExists } from "./schema-inspection";
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Rebuild-Marker für den drizzle-freien Migrations-Pfad.
|
|
2
|
+
//
|
|
3
|
+
// `kumiko schema generate` schreibt neben jedes `NNNN_<name>.sql` einen
|
|
4
|
+
// Sibling-Marker `NNNN_<name>.rebuild.json`, der die in dieser Migration
|
|
5
|
+
// geänderten/neu angelegten Tabellen listet. `kumiko schema apply` liest den
|
|
6
|
+
// Marker für jede frisch applizierte Migration und rebuildet die betroffenen
|
|
7
|
+
// Projektionen.
|
|
8
|
+
//
|
|
9
|
+
// Bewusst nur **Tabellennamen** (kein Projection-Name): der Generator ist
|
|
10
|
+
// registry-frei (kennt die App-Projektionen nicht). Die Auflösung
|
|
11
|
+
// Tabelle→Projection passiert app-seitig beim Apply via
|
|
12
|
+
// `buildProjectionTableIndex(registry)`. Tabellen ohne zugehörige Projektion
|
|
13
|
+
// werden dort einfach übersprungen.
|
|
14
|
+
//
|
|
15
|
+
// Marker werden zum generate-Zeitpunkt aus dem strukturierten `SchemaDiff`
|
|
16
|
+
// geschrieben — nicht beim Apply aus dem (ggf. hand-editierten) SQL
|
|
17
|
+
// re-derived.
|
|
18
|
+
|
|
19
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { join } from "node:path";
|
|
21
|
+
|
|
22
|
+
import type { SchemaDiff } from "./migrate-generator";
|
|
23
|
+
|
|
24
|
+
const MARKER_VERSION = 1 as const;
|
|
25
|
+
|
|
26
|
+
type RebuildMarker = {
|
|
27
|
+
readonly version: typeof MARKER_VERSION;
|
|
28
|
+
readonly tables: readonly string[];
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function markerPathFor(migrationsDir: string, migrationId: string): string {
|
|
32
|
+
return join(migrationsDir, `${migrationId}.rebuild.json`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// Tabellen die nach dieser Migration einen Projection-Rebuild brauchen können:
|
|
36
|
+
// neu angelegte + spalten-/index-geänderte. Gelöschte Tabellen NICHT (die
|
|
37
|
+
// Projektion ist mit der Tabelle weg). Sortiert + dedupliziert für stabilen
|
|
38
|
+
// PR-Diff.
|
|
39
|
+
export function rebuildTablesFromDiff(diff: SchemaDiff): readonly string[] {
|
|
40
|
+
const names = new Set<string>();
|
|
41
|
+
for (const t of diff.changedTables) names.add(t.tableName);
|
|
42
|
+
for (const t of diff.newTables) names.add(t.tableName);
|
|
43
|
+
return [...names].sort();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Schreibt `<sqlFilename ohne .sql>.rebuild.json`. Leere Tabellen-Liste →
|
|
47
|
+
// kein Marker (z.B. reine Drop-Migration).
|
|
48
|
+
export function writeRebuildMarker(
|
|
49
|
+
migrationsDir: string,
|
|
50
|
+
sqlFilename: string,
|
|
51
|
+
tables: readonly string[],
|
|
52
|
+
): void {
|
|
53
|
+
// skip: leere Tabellen-Liste → kein Marker (z.B. reine Drop-Migration).
|
|
54
|
+
if (tables.length === 0) return;
|
|
55
|
+
const migrationId = sqlFilename.replace(/\.sql$/, "");
|
|
56
|
+
const marker: RebuildMarker = { version: MARKER_VERSION, tables };
|
|
57
|
+
writeFileSync(markerPathFor(migrationsDir, migrationId), `${JSON.stringify(marker, null, 2)}\n`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Liest die Tabellen-Liste für eine applizierte Migration. Kein Marker /
|
|
61
|
+
// kaputtes File → leere Liste (Migration ohne Projection-Impact).
|
|
62
|
+
export function readRebuildMarker(migrationsDir: string, migrationId: string): readonly string[] {
|
|
63
|
+
const path = markerPathFor(migrationsDir, migrationId);
|
|
64
|
+
if (!existsSync(path)) return [];
|
|
65
|
+
let parsed: unknown;
|
|
66
|
+
try {
|
|
67
|
+
parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
68
|
+
} catch {
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
if (typeof parsed !== "object" || parsed === null || !("tables" in parsed)) return [];
|
|
72
|
+
const { tables } = parsed;
|
|
73
|
+
if (!Array.isArray(tables)) return [];
|
|
74
|
+
return tables.filter((t): t is string => typeof t === "string");
|
|
75
|
+
}
|
package/src/schema-cli.ts
CHANGED
|
@@ -17,8 +17,10 @@ import {
|
|
|
17
17
|
generateMigration,
|
|
18
18
|
loadMigrationsFromDir,
|
|
19
19
|
loadSnapshotJson,
|
|
20
|
+
rebuildTablesFromDiff,
|
|
20
21
|
type renderTablesDdl,
|
|
21
22
|
runMigrationsFromDir,
|
|
23
|
+
writeRebuildMarker,
|
|
22
24
|
writeSnapshotJson,
|
|
23
25
|
} from "./db";
|
|
24
26
|
|
|
@@ -105,11 +107,22 @@ export async function runSchemaCli(
|
|
|
105
107
|
writeFileSync(join(migrationsDir, result.filename), result.sqlContent);
|
|
106
108
|
writeSnapshotJson(snapshotPath, result.snapshot);
|
|
107
109
|
|
|
110
|
+
// Rebuild-Marker nur für inkrementelle Migrationen — die Init-Migration
|
|
111
|
+
// (prevSnapshot===null) legt nur Tabellen an, es gibt keine historischen
|
|
112
|
+
// Events zum Replayen.
|
|
113
|
+
const rebuildTables = prevSnapshot === null ? [] : rebuildTablesFromDiff(result.diff);
|
|
114
|
+
writeRebuildMarker(migrationsDir, result.filename, rebuildTables);
|
|
115
|
+
|
|
108
116
|
out.log("");
|
|
109
117
|
out.log(` ✓ ${result.filename}`);
|
|
110
118
|
out.log(
|
|
111
119
|
` new tables: ${result.diff.newTables.length}, changed: ${result.diff.changedTables.length}, dropped: ${result.diff.droppedTables.length}`,
|
|
112
120
|
);
|
|
121
|
+
if (rebuildTables.length > 0) {
|
|
122
|
+
out.log(
|
|
123
|
+
` rebuild-marker: ${result.filename.replace(/\.sql$/, ".rebuild.json")} (${rebuildTables.length} table(s))`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
113
126
|
out.log("");
|
|
114
127
|
out.log(" Review + ggf. hand-edit + git add + commit. Apply via: kumiko-schema apply");
|
|
115
128
|
out.log("");
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { runEventStoreSeed } from "../entity-seed";
|
|
3
|
+
|
|
4
|
+
describe("runEventStoreSeed", () => {
|
|
5
|
+
test('default ifExists="skip" returns existing id without update', async () => {
|
|
6
|
+
let updateCalls = 0;
|
|
7
|
+
let createCalls = 0;
|
|
8
|
+
|
|
9
|
+
const result = await runEventStoreSeed({
|
|
10
|
+
existing: { id: "agg-1", version: 3 },
|
|
11
|
+
create: async () => {
|
|
12
|
+
createCalls++;
|
|
13
|
+
return { id: "new" };
|
|
14
|
+
},
|
|
15
|
+
update: async () => {
|
|
16
|
+
updateCalls++;
|
|
17
|
+
return { id: "agg-1" };
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
expect(result.id).toBe("agg-1");
|
|
22
|
+
expect(updateCalls).toBe(0);
|
|
23
|
+
expect(createCalls).toBe(0);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('ifExists="update" calls update when row exists', async () => {
|
|
27
|
+
let updateCalls = 0;
|
|
28
|
+
|
|
29
|
+
const result = await runEventStoreSeed({
|
|
30
|
+
existing: { id: "agg-2", version: 1 },
|
|
31
|
+
ifExists: "update",
|
|
32
|
+
create: async () => ({ id: "new" }),
|
|
33
|
+
update: async (existing) => {
|
|
34
|
+
updateCalls++;
|
|
35
|
+
expect(existing.version).toBe(1);
|
|
36
|
+
return { id: existing.id };
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
expect(result.id).toBe("agg-2");
|
|
41
|
+
expect(updateCalls).toBe(1);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("missing row calls create", async () => {
|
|
45
|
+
let createCalls = 0;
|
|
46
|
+
|
|
47
|
+
const result = await runEventStoreSeed({
|
|
48
|
+
existing: null,
|
|
49
|
+
create: async () => {
|
|
50
|
+
createCalls++;
|
|
51
|
+
return { id: "created" };
|
|
52
|
+
},
|
|
53
|
+
update: async () => ({ id: "never" }),
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
expect(result.id).toBe("created");
|
|
57
|
+
expect(createCalls).toBe(1);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { DEFAULT_SEED_IF_EXISTS, type SeedIfExists } from "./types";
|
|
2
|
+
|
|
3
|
+
export type EventStoreSeedExisting = {
|
|
4
|
+
readonly id: string | number;
|
|
5
|
+
readonly version: number;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export type RunEventStoreSeedOptions<TExisting extends EventStoreSeedExisting> = {
|
|
9
|
+
readonly existing: TExisting | null | undefined;
|
|
10
|
+
readonly ifExists?: SeedIfExists;
|
|
11
|
+
readonly create: () => Promise<{ id: string | number }>;
|
|
12
|
+
readonly update: (existing: TExisting) => Promise<{ id: string | number }>;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** Shared create-or-skip/update path for event-store boot-seed helpers. */
|
|
16
|
+
export async function runEventStoreSeed<TExisting extends EventStoreSeedExisting>(
|
|
17
|
+
opts: RunEventStoreSeedOptions<TExisting>,
|
|
18
|
+
): Promise<{ id: string | number }> {
|
|
19
|
+
const ifExists = opts.ifExists ?? DEFAULT_SEED_IF_EXISTS;
|
|
20
|
+
if (opts.existing != null) {
|
|
21
|
+
if (ifExists === "skip") {
|
|
22
|
+
return { id: opts.existing.id };
|
|
23
|
+
}
|
|
24
|
+
return opts.update(opts.existing);
|
|
25
|
+
}
|
|
26
|
+
return opts.create();
|
|
27
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Boot-seed semantics for event-sourced entity helpers.
|
|
2
|
+
*
|
|
3
|
+
* - `skip` (default): row exists → return without write (no event).
|
|
4
|
+
* - `update`: row exists → overwrite via executor.update (opt-in, e.g.
|
|
5
|
+
* demo-fixtures where code is source-of-truth). */
|
|
6
|
+
export type SeedIfExists = "skip" | "update";
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_SEED_IF_EXISTS: SeedIfExists = "skip";
|