@cosmicdrift/kumiko-dev-server 0.63.0 → 0.65.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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-dev-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.65.0",
|
|
4
4
|
"description": "Development server bootstrap for Kumiko apps. Bundles the client, mints dev-JWTs, injects the resolved AppSchema, and seeds an admin. Not for production.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -30,6 +30,10 @@
|
|
|
30
30
|
"types": "./src/compose-features.ts",
|
|
31
31
|
"default": "./src/compose-features.ts"
|
|
32
32
|
},
|
|
33
|
+
"./schema-apply": {
|
|
34
|
+
"types": "./src/schema-apply.ts",
|
|
35
|
+
"default": "./src/schema-apply.ts"
|
|
36
|
+
},
|
|
33
37
|
"./schema-tables-auth-mode": {
|
|
34
38
|
"types": "./src/schema-tables-auth-mode.ts",
|
|
35
39
|
"default": "./src/schema-tables-auth-mode.ts"
|
|
@@ -46,8 +50,8 @@
|
|
|
46
50
|
"kumiko-schema-check": "./bin/kumiko-schema-check.ts"
|
|
47
51
|
},
|
|
48
52
|
"dependencies": {
|
|
49
|
-
"@cosmicdrift/kumiko-bundled-features": "0.
|
|
50
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
53
|
+
"@cosmicdrift/kumiko-bundled-features": "0.65.0",
|
|
54
|
+
"@cosmicdrift/kumiko-framework": "0.65.0",
|
|
51
55
|
"ts-morph": "^28.0.0"
|
|
52
56
|
},
|
|
53
57
|
"publishConfig": {
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Production-Behavior von `kumiko schema apply` (runSchemaApply): der
|
|
2
|
+
// migrate-initContainer ruft das gegen eine frische CNPG-DB. Der riskante,
|
|
3
|
+
// neue Teil gegenüber dem alten per-App-Boilerplate ist der Greenfield-
|
|
4
|
+
// Bootstrap — Infra-Tabellen (event-store + pipeline-state) MÜSSEN vor den
|
|
5
|
+
// App-Migrations idempotent angelegt werden, sonst bricht eine leere DB an
|
|
6
|
+
// `relation "kumiko_events" does not exist`. Dieser Test fährt den echten
|
|
7
|
+
// Pfad gegen eine leere DB + den idempotenten Re-Run.
|
|
8
|
+
|
|
9
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
10
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import {
|
|
14
|
+
asRawClient,
|
|
15
|
+
createDbConnection,
|
|
16
|
+
type DbConnection,
|
|
17
|
+
tableExists,
|
|
18
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
19
|
+
import { createTestDb, type TestDb } from "@cosmicdrift/kumiko-framework/stack";
|
|
20
|
+
import { runSchemaApply } from "../schema-apply";
|
|
21
|
+
|
|
22
|
+
let testDb: TestDb;
|
|
23
|
+
let conn: { readonly db: DbConnection; readonly close: () => Promise<void> };
|
|
24
|
+
let appCwd: string;
|
|
25
|
+
let migDir: string;
|
|
26
|
+
const savedDbUrl = process.env["DATABASE_URL"];
|
|
27
|
+
|
|
28
|
+
const APPLY = { features: [], includeBundled: false } as const;
|
|
29
|
+
|
|
30
|
+
beforeAll(async () => {
|
|
31
|
+
const base = process.env["TEST_DATABASE_URL"];
|
|
32
|
+
if (!base) throw new Error("TEST_DATABASE_URL required for schema-apply integration test");
|
|
33
|
+
|
|
34
|
+
testDb = await createTestDb();
|
|
35
|
+
const testUrl = base.replace(/\/[^/]+$/, `/${testDb.dbName}`);
|
|
36
|
+
process.env["DATABASE_URL"] = testUrl;
|
|
37
|
+
conn = createDbConnection(testUrl);
|
|
38
|
+
|
|
39
|
+
// Greenfield erzwingen: createTestDb legt kumiko_events bereits an —
|
|
40
|
+
// wegdroppen, damit runSchemaApply den Infra-Bootstrap echt durchläuft.
|
|
41
|
+
const raw = asRawClient(conn.db);
|
|
42
|
+
await raw.unsafe(`DROP TABLE IF EXISTS "kumiko_events" CASCADE`);
|
|
43
|
+
await raw.unsafe(`DROP TABLE IF EXISTS "kumiko_event_consumers" CASCADE`);
|
|
44
|
+
await raw.unsafe(`DROP TABLE IF EXISTS "kumiko_projections" CASCADE`);
|
|
45
|
+
await raw.unsafe(`DROP TABLE IF EXISTS "_kumiko_migrations" CASCADE`);
|
|
46
|
+
|
|
47
|
+
appCwd = mkdtempSync(join(tmpdir(), "kumiko-schema-apply-"));
|
|
48
|
+
migDir = join(appCwd, "kumiko", "migrations");
|
|
49
|
+
mkdirSync(migDir, { recursive: true });
|
|
50
|
+
writeFileSync(
|
|
51
|
+
join(migDir, "0001_init.sql"),
|
|
52
|
+
`CREATE TABLE "read_thing" ("id" text PRIMARY KEY);`,
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
afterAll(async () => {
|
|
57
|
+
await conn?.close();
|
|
58
|
+
await testDb?.cleanup();
|
|
59
|
+
if (appCwd) rmSync(appCwd, { recursive: true, force: true });
|
|
60
|
+
if (savedDbUrl === undefined) delete process.env["DATABASE_URL"];
|
|
61
|
+
else process.env["DATABASE_URL"] = savedDbUrl;
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("runSchemaApply", () => {
|
|
65
|
+
test("Greenfield: leere DB → Infra-Tabellen + App-Migration appliziert → 0", async () => {
|
|
66
|
+
expect(await runSchemaApply({ ...APPLY, appCwd })).toBe(0);
|
|
67
|
+
|
|
68
|
+
expect(await tableExists(conn.db, "public.kumiko_events")).toBe(true);
|
|
69
|
+
expect(await tableExists(conn.db, "public.kumiko_event_consumers")).toBe(true);
|
|
70
|
+
expect(await tableExists(conn.db, "public.kumiko_projections")).toBe(true);
|
|
71
|
+
expect(await tableExists(conn.db, "public.read_thing")).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("Re-Run auf Bestands-DB ist idempotent → 0 (Infra no-op, Migrations skipped)", async () => {
|
|
75
|
+
expect(await runSchemaApply({ ...APPLY, appCwd })).toBe(0);
|
|
76
|
+
expect(await tableExists(conn.db, "public.read_thing")).toBe(true);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("rebuild-Marker für nicht-registrierte Tabelle → kein Crash, 0", async () => {
|
|
80
|
+
writeFileSync(
|
|
81
|
+
join(migDir, "0002_more.sql"),
|
|
82
|
+
`CREATE TABLE "read_more" ("id" text PRIMARY KEY);`,
|
|
83
|
+
);
|
|
84
|
+
writeFileSync(
|
|
85
|
+
join(migDir, "0002_more.rebuild.json"),
|
|
86
|
+
JSON.stringify({ version: 1, tables: ["read_more"] }),
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
expect(await runSchemaApply({ ...APPLY, appCwd })).toBe(0);
|
|
90
|
+
expect(await tableExists(conn.db, "public.read_more")).toBe(true);
|
|
91
|
+
});
|
|
92
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Standalone `kumiko schema apply` für Production-Bundles. Apps bündeln ein
|
|
2
|
+
// dünnes bin/kumiko.ts das nur runStandaloneSchemaCli mit den App-Features
|
|
3
|
+
// aufruft — die ganze Orchestrierung (Infra-Bootstrap, Migrations,
|
|
4
|
+
// Projection-Rebuild) lebt hier statt als ~100-Zeilen-Boilerplate pro App.
|
|
5
|
+
//
|
|
6
|
+
// Der Pulumi-migrate-initContainer ruft `bun /app/kumiko.js schema apply`;
|
|
7
|
+
// kumiko-build entdeckt das App-bin via findRepoRoot() und bündelt es.
|
|
8
|
+
|
|
9
|
+
import { existsSync } from "node:fs";
|
|
10
|
+
import { join } from "node:path";
|
|
11
|
+
import {
|
|
12
|
+
createDbConnection,
|
|
13
|
+
type DbConnection,
|
|
14
|
+
readRebuildMarker,
|
|
15
|
+
runMigrationsFromDir,
|
|
16
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
17
|
+
import { createRegistry, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
18
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
19
|
+
import { buildProjectionTableIndex } from "@cosmicdrift/kumiko-framework/migrations";
|
|
20
|
+
import {
|
|
21
|
+
createEventConsumerStateTable,
|
|
22
|
+
createProjectionStateTable,
|
|
23
|
+
rebuildProjection,
|
|
24
|
+
} from "@cosmicdrift/kumiko-framework/pipeline";
|
|
25
|
+
import { type ComposeFeaturesOptions, composeFeatures } from "./compose-features";
|
|
26
|
+
|
|
27
|
+
export type SchemaApplyOptions = ComposeFeaturesOptions & {
|
|
28
|
+
/** App-Features (z.B. APP_FEATURES aus run-config) — composed mit den
|
|
29
|
+
* bundled-Features für den Projection-Rebuild-Registry. */
|
|
30
|
+
readonly features: readonly FeatureDefinition[];
|
|
31
|
+
/** Default INIT_CWD ?? process.cwd(); Migrations unter <appCwd>/kumiko/migrations. */
|
|
32
|
+
readonly appCwd?: string;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export async function runSchemaApply(opts: SchemaApplyOptions): Promise<number> {
|
|
36
|
+
const dbUrl = process.env["DATABASE_URL"];
|
|
37
|
+
if (!dbUrl) {
|
|
38
|
+
console.error("\n DATABASE_URL not set.\n");
|
|
39
|
+
return 1;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const appCwd = opts.appCwd ?? process.env["INIT_CWD"] ?? process.cwd();
|
|
43
|
+
const migrationsDir = join(appCwd, "kumiko/migrations");
|
|
44
|
+
if (!existsSync(migrationsDir)) {
|
|
45
|
+
console.error(`\n ${migrationsDir} fehlt — kumiko/migrations/ muss im Image liegen.\n`);
|
|
46
|
+
return 1;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const { db, close } = createDbConnection(dbUrl);
|
|
50
|
+
try {
|
|
51
|
+
// Infra-Tabellen (event-store + pipeline-state) zuerst, alle
|
|
52
|
+
// tableExists-gated idempotent: auf einer Bestands-DB no-op, auf einer
|
|
53
|
+
// leeren Greenfield-DB legen sie kumiko_events/_consumers/_projections an
|
|
54
|
+
// bevor die App-Migrations dagegen laufen. Ohne das bricht eine leere DB
|
|
55
|
+
// (z.B. cashcolt auf frischem CNPG) an `relation "kumiko_events" does not exist`.
|
|
56
|
+
console.log("\n Lege Framework-Infra-Tabellen an (idempotent)…");
|
|
57
|
+
await createEventsTable(db);
|
|
58
|
+
await createEventConsumerStateTable(db);
|
|
59
|
+
await createProjectionStateTable(db);
|
|
60
|
+
|
|
61
|
+
console.log(` Wende kumiko-Migrations an (${migrationsDir})…`);
|
|
62
|
+
const result = await runMigrationsFromDir(db, migrationsDir);
|
|
63
|
+
if (result.applied.length === 0) {
|
|
64
|
+
console.log(`\n ✓ All ${result.skipped.length} migrations already applied.\n`);
|
|
65
|
+
} else {
|
|
66
|
+
console.log(`\n ✓ Applied ${result.applied.length}:`);
|
|
67
|
+
for (const id of result.applied) console.log(` + ${id}`);
|
|
68
|
+
if (result.skipped.length > 0) console.log(` (${result.skipped.length} already applied)`);
|
|
69
|
+
console.log("");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Projection-Rebuild für Tabellen die in frisch applizierten Migrations
|
|
73
|
+
// geändert wurden (Marker NNNN_<name>.rebuild.json von `schema generate`).
|
|
74
|
+
// Ohne das blieben read_*-Projektionen nach einem Schema-Change stale.
|
|
75
|
+
const changedTables = new Set<string>();
|
|
76
|
+
for (const id of result.applied) {
|
|
77
|
+
for (const table of readRebuildMarker(migrationsDir, id)) changedTables.add(table);
|
|
78
|
+
}
|
|
79
|
+
if (changedTables.size > 0) {
|
|
80
|
+
await rebuildAffectedProjections(db, [...changedTables], opts);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return 0;
|
|
84
|
+
} catch (e) {
|
|
85
|
+
console.error(`\n ✗ ${e instanceof Error ? e.message : String(e)}\n`);
|
|
86
|
+
return 1;
|
|
87
|
+
} finally {
|
|
88
|
+
await close();
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function rebuildAffectedProjections(
|
|
93
|
+
db: DbConnection,
|
|
94
|
+
changedTables: readonly string[],
|
|
95
|
+
opts: SchemaApplyOptions,
|
|
96
|
+
): Promise<void> {
|
|
97
|
+
const registry = createRegistry(composeFeatures([...opts.features], opts));
|
|
98
|
+
const tableToProjection = buildProjectionTableIndex(registry);
|
|
99
|
+
|
|
100
|
+
const projections = new Set<string>();
|
|
101
|
+
for (const table of changedTables) {
|
|
102
|
+
const name = tableToProjection.get(table);
|
|
103
|
+
if (name) projections.add(name);
|
|
104
|
+
}
|
|
105
|
+
if (projections.size === 0) return;
|
|
106
|
+
|
|
107
|
+
console.log(` Rebuild ${projections.size} Projection(s)…`);
|
|
108
|
+
for (const name of projections) {
|
|
109
|
+
const r = await rebuildProjection(name, { db, registry });
|
|
110
|
+
console.log(` ↻ ${name} (${r.eventsProcessed} events, ${r.durationMs}ms)`);
|
|
111
|
+
}
|
|
112
|
+
console.log("");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export async function runStandaloneSchemaCli(opts: SchemaApplyOptions): Promise<never> {
|
|
116
|
+
const cmd = Bun.argv[2];
|
|
117
|
+
const sub = Bun.argv[3];
|
|
118
|
+
|
|
119
|
+
if (cmd === "schema" && sub === "apply") {
|
|
120
|
+
process.exit(await runSchemaApply(opts));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
console.error(
|
|
124
|
+
`\n Unbekannt: kumiko ${cmd ?? ""} ${sub ?? ""}\n Nur 'kumiko schema apply' im Standalone-Bundle.\n`,
|
|
125
|
+
);
|
|
126
|
+
process.exit(1);
|
|
127
|
+
}
|