@cosmicdrift/kumiko-dev-server 0.64.0 → 0.66.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.66.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.66.0",
|
|
54
|
+
"@cosmicdrift/kumiko-framework": "0.66.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
|
+
});
|
|
@@ -173,6 +173,11 @@ export type CreateKumikoServerOptions = {
|
|
|
173
173
|
* Handler `roles: ["anonymous"]` deklariert. Tenant-Resolution per
|
|
174
174
|
* Header/Cookie/Default; siehe AnonymousAccessConfig. */
|
|
175
175
|
readonly anonymousAccess?: TestStackOptions["anonymousAccess"];
|
|
176
|
+
/** File-Storage-Provider — durchgereicht an setupTestStack. Wenn gesetzt,
|
|
177
|
+
* mountet der Stack die Upload-Routes (POST/GET/DELETE /api/files) +
|
|
178
|
+
* `ctx.files` und legt die file_refs-Tabelle an. Für Demos:
|
|
179
|
+
* `{ storageProvider: createInMemoryFileProvider() }`. */
|
|
180
|
+
readonly files?: TestStackOptions["files"];
|
|
176
181
|
/** Feature-toggle resolver — durchgereicht an setupTestStack. Wenn
|
|
177
182
|
* gesetzt, konsultiert der dispatcher-feature-gate, hook-filter, MSP-
|
|
178
183
|
* filter den callback; absent = alle features always-on. Erforderlich
|
|
@@ -660,6 +665,7 @@ export async function createKumikoServer(
|
|
|
660
665
|
...(options.auth !== undefined && { authConfig: options.auth }),
|
|
661
666
|
...(options.extraContext !== undefined && { extraContext: options.extraContext }),
|
|
662
667
|
...(options.anonymousAccess !== undefined && { anonymousAccess: options.anonymousAccess }),
|
|
668
|
+
...(options.files !== undefined && { files: options.files }),
|
|
663
669
|
...(options.effectiveFeatures !== undefined && {
|
|
664
670
|
effectiveFeatures: options.effectiveFeatures,
|
|
665
671
|
}),
|
package/src/run-dev-app.ts
CHANGED
|
@@ -163,6 +163,9 @@ export type RunDevAppOptions = {
|
|
|
163
163
|
* als Pseudo-User mit Rolle `anonymous` durch, wenn der Handler die
|
|
164
164
|
* Rolle in `access.roles` führt. */
|
|
165
165
|
readonly anonymousAccess?: CreateKumikoServerOptions["anonymousAccess"];
|
|
166
|
+
/** File-Storage-Provider — aktiviert die Upload-Routes (/api/files) +
|
|
167
|
+
* `ctx.files`. Demos: `{ storageProvider: createInMemoryFileProvider() }`. */
|
|
168
|
+
readonly files?: CreateKumikoServerOptions["files"];
|
|
166
169
|
/** App-eigene HTTP-Routes (z.B. /feed.xml, /sitemap.xml) — wird ans
|
|
167
170
|
* Hono-app gehängt, läuft VOR dem static-asset-Pfad. Symmetrisch zur
|
|
168
171
|
* gleichnamigen Option in runProdApp. */
|
|
@@ -187,6 +190,13 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
|
|
|
187
190
|
...(composeAuthOptions && { authOptions: composeAuthOptions }),
|
|
188
191
|
});
|
|
189
192
|
|
|
193
|
+
// Ein explizit gewireter File-Provider (options.files) erfüllt das
|
|
194
|
+
// FILE_STORAGE_PROVIDER-Boot-Gate — der Provider IST konfiguriert, nur
|
|
195
|
+
// nicht über die env-Bridge. Setzen bevor validateBoot greift.
|
|
196
|
+
if (options.files !== undefined && process.env["FILE_STORAGE_PROVIDER"] === undefined) {
|
|
197
|
+
process.env["FILE_STORAGE_PROVIDER"] = "configured";
|
|
198
|
+
}
|
|
199
|
+
|
|
190
200
|
// Boot-Validation als allererstes — vor fs-Watcher und Server. Dieselbe
|
|
191
201
|
// Fehlerklasse (unqualifizierte nav-/handler-QNs, screen-access etc.),
|
|
192
202
|
// die früher nur runProdApp fing und sonst erst den Prod-Pod im
|
|
@@ -298,6 +308,7 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
|
|
|
298
308
|
}),
|
|
299
309
|
...(extraContext !== undefined && { extraContext }),
|
|
300
310
|
...(options.anonymousAccess !== undefined && { anonymousAccess: options.anonymousAccess }),
|
|
311
|
+
...(options.files !== undefined && { files: options.files }),
|
|
301
312
|
...(options.extraRoutes !== undefined && { extraRoutes: options.extraRoutes }),
|
|
302
313
|
...(finalEffectiveFeatures !== undefined && {
|
|
303
314
|
effectiveFeatures: finalEffectiveFeatures,
|
|
@@ -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
|
+
}
|