@cosmicdrift/kumiko-framework 0.18.0 → 0.19.1
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 +9 -1
- package/src/schema-cli.ts +228 -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.19.1",
|
|
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,10 +123,18 @@
|
|
|
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"
|
|
129
133
|
},
|
|
134
|
+
"./schema-cli": {
|
|
135
|
+
"types": "./src/schema-cli.ts",
|
|
136
|
+
"default": "./src/schema-cli.ts"
|
|
137
|
+
},
|
|
130
138
|
"./stack": {
|
|
131
139
|
"types": "./src/stack/index.ts",
|
|
132
140
|
"default": "./src/stack/index.ts"
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
// Shared core for the schema-migration CLI (generate | apply | baseline | status).
|
|
2
|
+
//
|
|
3
|
+
// Used by BOTH the dev `kumiko schema` command (bin/commands/schema.ts) and the
|
|
4
|
+
// shipped `kumiko-schema` bin (dev-server) — so apps run migrations without the
|
|
5
|
+
// full dev-CLI registry (which eager-loads ts-morph-heavy dev commands).
|
|
6
|
+
//
|
|
7
|
+
// NO-MAGIC-ON-DATA: reads only checked-in artifacts (kumiko/schema.ts →
|
|
8
|
+
// ENTITY_METAS, kumiko/migrations/*.sql). Never auto-generates at runtime,
|
|
9
|
+
// never applies on app-boot — apply/baseline are explicit deploy-steps.
|
|
10
|
+
|
|
11
|
+
import { existsSync, mkdirSync, readdirSync, writeFileSync } from "node:fs";
|
|
12
|
+
import { join, resolve as resolvePath } from "node:path";
|
|
13
|
+
import {
|
|
14
|
+
baselineMigrations,
|
|
15
|
+
createDbConnection,
|
|
16
|
+
fetchAppliedMigrations,
|
|
17
|
+
generateMigration,
|
|
18
|
+
loadMigrationsFromDir,
|
|
19
|
+
loadSnapshotJson,
|
|
20
|
+
type renderTablesDdl,
|
|
21
|
+
runMigrationsFromDir,
|
|
22
|
+
writeSnapshotJson,
|
|
23
|
+
} from "./db";
|
|
24
|
+
|
|
25
|
+
export type SchemaCliOut = {
|
|
26
|
+
readonly log: (line: string) => void;
|
|
27
|
+
readonly err: (line: string) => void;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const SNAPSHOT_FILENAME = ".snapshot.json";
|
|
31
|
+
|
|
32
|
+
async function loadEntityMetasFromApp(
|
|
33
|
+
schemaFile: string,
|
|
34
|
+
): Promise<Parameters<typeof renderTablesDdl>[0]> {
|
|
35
|
+
// bun imports TS directly — no spawn needed.
|
|
36
|
+
const mod = (await import(schemaFile)) as { ENTITY_METAS?: unknown };
|
|
37
|
+
if (!Array.isArray(mod.ENTITY_METAS)) {
|
|
38
|
+
throw new Error(
|
|
39
|
+
`Schema file ${schemaFile} muss \`export const ENTITY_METAS: EntityTableMeta[]\` haben.`,
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
return mod.ENTITY_METAS as Parameters<typeof renderTablesDdl>[0];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function nextSequenceNumber(migrationsDir: string): number {
|
|
46
|
+
if (!existsSync(migrationsDir)) return 1;
|
|
47
|
+
const files = readdirSync(migrationsDir).filter((f) => f.endsWith(".sql"));
|
|
48
|
+
let max = 0;
|
|
49
|
+
for (const f of files) {
|
|
50
|
+
const m = f.match(/^(\d+)_/);
|
|
51
|
+
if (m) {
|
|
52
|
+
const n = Number(m[1]);
|
|
53
|
+
if (n > max) max = n;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return max + 1;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Runs a schema-CLI subcommand. `appCwd` is the app workspace root (where
|
|
61
|
+
* `kumiko/schema.ts` + `kumiko/migrations/` live). Returns a process exit code.
|
|
62
|
+
*/
|
|
63
|
+
export async function runSchemaCli(
|
|
64
|
+
argv: readonly string[],
|
|
65
|
+
appCwd: string,
|
|
66
|
+
out: SchemaCliOut,
|
|
67
|
+
): Promise<number> {
|
|
68
|
+
const sub = argv[0];
|
|
69
|
+
const schemaFile = resolvePath(appCwd, "kumiko/schema.ts");
|
|
70
|
+
const migrationsDir = resolvePath(appCwd, "kumiko/migrations");
|
|
71
|
+
|
|
72
|
+
switch (sub) {
|
|
73
|
+
case "generate": {
|
|
74
|
+
const name = argv[1];
|
|
75
|
+
if (!name) {
|
|
76
|
+
out.err(" Usage: kumiko-schema generate <name>");
|
|
77
|
+
return 1;
|
|
78
|
+
}
|
|
79
|
+
if (!existsSync(schemaFile)) {
|
|
80
|
+
out.err(` ${schemaFile} fehlt.`);
|
|
81
|
+
out.err(" App-Convention: kumiko/schema.ts mit");
|
|
82
|
+
out.err(" export const ENTITY_METAS: EntityTableMeta[] = [...]");
|
|
83
|
+
return 1;
|
|
84
|
+
}
|
|
85
|
+
const metas = await loadEntityMetasFromApp(schemaFile);
|
|
86
|
+
const snapshotPath = join(migrationsDir, SNAPSHOT_FILENAME);
|
|
87
|
+
const prevSnapshot = existsSync(snapshotPath) ? loadSnapshotJson(snapshotPath) : null;
|
|
88
|
+
const result = generateMigration({
|
|
89
|
+
metas,
|
|
90
|
+
prevSnapshot,
|
|
91
|
+
name,
|
|
92
|
+
sequenceNumber: nextSequenceNumber(migrationsDir),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const isEmpty =
|
|
96
|
+
result.diff.newTables.length === 0 &&
|
|
97
|
+
result.diff.changedTables.length === 0 &&
|
|
98
|
+
result.diff.droppedTables.length === 0;
|
|
99
|
+
if (isEmpty) {
|
|
100
|
+
out.log(" No schema changes detected — kein neues Migration-File geschrieben.");
|
|
101
|
+
return 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (!existsSync(migrationsDir)) mkdirSync(migrationsDir, { recursive: true });
|
|
105
|
+
writeFileSync(join(migrationsDir, result.filename), result.sqlContent);
|
|
106
|
+
writeSnapshotJson(snapshotPath, result.snapshot);
|
|
107
|
+
|
|
108
|
+
out.log("");
|
|
109
|
+
out.log(` ✓ ${result.filename}`);
|
|
110
|
+
out.log(
|
|
111
|
+
` new tables: ${result.diff.newTables.length}, changed: ${result.diff.changedTables.length}, dropped: ${result.diff.droppedTables.length}`,
|
|
112
|
+
);
|
|
113
|
+
out.log("");
|
|
114
|
+
out.log(" Review + ggf. hand-edit + git add + commit. Apply via: kumiko-schema apply");
|
|
115
|
+
out.log("");
|
|
116
|
+
return 0;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
case "apply": {
|
|
120
|
+
const dbUrl = process.env["DATABASE_URL"];
|
|
121
|
+
if (!dbUrl) {
|
|
122
|
+
out.err(" DATABASE_URL not set.");
|
|
123
|
+
return 1;
|
|
124
|
+
}
|
|
125
|
+
if (!existsSync(migrationsDir)) {
|
|
126
|
+
out.err(` ${migrationsDir} fehlt — erst kumiko-schema generate <name>.`);
|
|
127
|
+
return 1;
|
|
128
|
+
}
|
|
129
|
+
const { db, close } = createDbConnection(dbUrl);
|
|
130
|
+
try {
|
|
131
|
+
const result = await runMigrationsFromDir(db, migrationsDir);
|
|
132
|
+
out.log("");
|
|
133
|
+
if (result.applied.length === 0) {
|
|
134
|
+
out.log(` ✓ All ${result.skipped.length} migrations already applied.`);
|
|
135
|
+
} else {
|
|
136
|
+
out.log(` ✓ Applied ${result.applied.length}:`);
|
|
137
|
+
for (const id of result.applied) out.log(` + ${id}`);
|
|
138
|
+
if (result.skipped.length > 0) out.log(` (${result.skipped.length} already applied)`);
|
|
139
|
+
}
|
|
140
|
+
out.log("");
|
|
141
|
+
return 0;
|
|
142
|
+
} catch (e) {
|
|
143
|
+
out.err("");
|
|
144
|
+
out.err(` ✗ ${e instanceof Error ? e.message : String(e)}`);
|
|
145
|
+
out.err("");
|
|
146
|
+
return 1;
|
|
147
|
+
} finally {
|
|
148
|
+
await close();
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
case "baseline": {
|
|
153
|
+
// Adopt an existing DB: mark all checked-in migrations as applied WITHOUT
|
|
154
|
+
// running their SQL (prod tables already exist — cutover from the legacy
|
|
155
|
+
// drizzle system). Afterwards the boot-gate is drift-free.
|
|
156
|
+
const dbUrl = process.env["DATABASE_URL"];
|
|
157
|
+
if (!dbUrl) {
|
|
158
|
+
out.err(" DATABASE_URL not set.");
|
|
159
|
+
return 1;
|
|
160
|
+
}
|
|
161
|
+
if (!existsSync(migrationsDir)) {
|
|
162
|
+
out.err(` ${migrationsDir} fehlt — erst kumiko-schema generate <name>.`);
|
|
163
|
+
return 1;
|
|
164
|
+
}
|
|
165
|
+
const { db, close } = createDbConnection(dbUrl);
|
|
166
|
+
try {
|
|
167
|
+
const result = await baselineMigrations(db, loadMigrationsFromDir(migrationsDir));
|
|
168
|
+
out.log("");
|
|
169
|
+
out.log(` ✓ Marked ${result.marked.length} migration(s) as applied (no SQL run):`);
|
|
170
|
+
for (const id of result.marked) out.log(` + ${id}`);
|
|
171
|
+
if (result.alreadyTracked.length > 0) {
|
|
172
|
+
out.log(` (${result.alreadyTracked.length} already tracked)`);
|
|
173
|
+
}
|
|
174
|
+
out.log("");
|
|
175
|
+
return 0;
|
|
176
|
+
} catch (e) {
|
|
177
|
+
out.err(` ✗ ${e instanceof Error ? e.message : String(e)}`);
|
|
178
|
+
return 1;
|
|
179
|
+
} finally {
|
|
180
|
+
await close();
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
case "status": {
|
|
185
|
+
const dbUrl = process.env["DATABASE_URL"];
|
|
186
|
+
if (!dbUrl) {
|
|
187
|
+
out.err(" DATABASE_URL not set.");
|
|
188
|
+
return 1;
|
|
189
|
+
}
|
|
190
|
+
if (!existsSync(migrationsDir)) {
|
|
191
|
+
out.log(" Kein kumiko/migrations/ — App ist noch auf dem alten drizzle-Pfad.");
|
|
192
|
+
return 0;
|
|
193
|
+
}
|
|
194
|
+
const local = loadMigrationsFromDir(migrationsDir);
|
|
195
|
+
const { db, close } = createDbConnection(dbUrl);
|
|
196
|
+
try {
|
|
197
|
+
// fetchAppliedMigrations wirft wenn die tracking-table noch nicht da ist.
|
|
198
|
+
let applied: Set<string>;
|
|
199
|
+
try {
|
|
200
|
+
applied = new Set((await fetchAppliedMigrations(db)).map((a) => a.id));
|
|
201
|
+
} catch {
|
|
202
|
+
applied = new Set();
|
|
203
|
+
}
|
|
204
|
+
out.log("");
|
|
205
|
+
out.log(` ${local.length} migrations in ${migrationsDir}:`);
|
|
206
|
+
for (const m of local) out.log(` ${applied.has(m.id) ? "✓" : " "} ${m.id}`);
|
|
207
|
+
const pending = local.filter((m) => !applied.has(m.id)).length;
|
|
208
|
+
out.log("");
|
|
209
|
+
out.log(` ${applied.size} applied, ${pending} pending.`);
|
|
210
|
+
out.log("");
|
|
211
|
+
return 0;
|
|
212
|
+
} finally {
|
|
213
|
+
await close();
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
default: {
|
|
218
|
+
out.log("");
|
|
219
|
+
out.log(" Subcommands:");
|
|
220
|
+
out.log(" generate <name> Schreibe neue Migration aus EntityTableMeta-Diff");
|
|
221
|
+
out.log(" apply Applied pending checked-in SQL-Files");
|
|
222
|
+
out.log(" baseline Markiere checked-in Migrations als applied (kein SQL-Run)");
|
|
223
|
+
out.log(" status Liste applied vs pending");
|
|
224
|
+
out.log("");
|
|
225
|
+
return 0;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
@@ -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";
|