@cosmicdrift/kumiko-dev-server 0.220.1 → 0.222.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/bin/kumiko-upgrade.ts +2 -1
- package/package.json +4 -4
- package/src/__tests__/create-kumiko-server.integration.test.ts +64 -0
- package/src/__tests__/env-schema.integration.test.ts +5 -0
- package/src/__tests__/runtime-classification.test.ts +0 -15
- package/src/__tests__/scaffold-app.test.ts +3 -1
- package/src/__tests__/schema-apply.integration.test.ts +101 -5
- package/src/create-kumiko-server.ts +9 -2
- package/src/env-schema.ts +4 -0
- package/src/scaffold-app.ts +33 -24
- package/src/scaffold-demo-tasks.ts +0 -1
- package/src/schema-apply.ts +32 -49
package/bin/kumiko-upgrade.ts
CHANGED
|
@@ -15,4 +15,5 @@ import { runUpgradeCli } from "@cosmicdrift/kumiko-framework/upgrade-cli";
|
|
|
15
15
|
const out = { log: (l: string) => console.log(l), err: (l: string) => console.error(l) };
|
|
16
16
|
const appCwd = process.env["INIT_CWD"] ?? process.cwd();
|
|
17
17
|
const code = await runUpgradeCli(process.argv.slice(2), appCwd, out);
|
|
18
|
-
|
|
18
|
+
// Let the event loop drain stdout (piped by guard-upgrade-state) before exit.
|
|
19
|
+
process.exitCode = code;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-dev-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.222.0",
|
|
4
4
|
"description": "Dev-tooling for Kumiko apps: local dev-server bootstrap (runDevApp), scaffolding, codegen. Its compose-stacks/env-schema subpaths are consumed at prod boot too — see @cosmicdrift/kumiko-server-runtime for the prod runner itself.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -59,9 +59,9 @@
|
|
|
59
59
|
"kumiko-upgrade": "./bin/kumiko-upgrade.ts"
|
|
60
60
|
},
|
|
61
61
|
"dependencies": {
|
|
62
|
-
"@cosmicdrift/kumiko-bundled-features": "0.
|
|
63
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
64
|
-
"@cosmicdrift/kumiko-server-runtime": "0.
|
|
62
|
+
"@cosmicdrift/kumiko-bundled-features": "0.222.0",
|
|
63
|
+
"@cosmicdrift/kumiko-framework": "0.222.0",
|
|
64
|
+
"@cosmicdrift/kumiko-server-runtime": "0.222.0",
|
|
65
65
|
"ts-morph": "^28.0.0"
|
|
66
66
|
},
|
|
67
67
|
"publishConfig": {
|
|
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
+
import { NO_ROUTE_MATCH_HEADER_NAME } from "@cosmicdrift/kumiko-framework/api";
|
|
5
6
|
import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
6
7
|
import {
|
|
7
8
|
createBooleanField,
|
|
@@ -507,3 +508,66 @@ describe("createKumikoServer — hot-reload broadcast", () => {
|
|
|
507
508
|
}
|
|
508
509
|
});
|
|
509
510
|
});
|
|
511
|
+
|
|
512
|
+
// kumiko-framework#2435: tryHonoFirst used to decide "no route matched"
|
|
513
|
+
// purely from the status code — a matched httpRoute answering 404 on
|
|
514
|
+
// purpose (e.g. default-deny reads) was indistinguishable from an
|
|
515
|
+
// unregistered path and got silently rewritten to the SPA shell with
|
|
516
|
+
// status 200. Drives the REAL fetch-handler end to end (real Postgres,
|
|
517
|
+
// real Hono app via createKumikoServer) so the fix's actual wiring
|
|
518
|
+
// (buildServer's app.notFound() marker + tryHonoFirst reading it) is
|
|
519
|
+
// under test, not just the pure-function pin in try-hono-first.test.ts.
|
|
520
|
+
const probeHttpRouteFeature = defineFeature("dev-server-probe-http-route", (r) => {
|
|
521
|
+
r.httpRoute({
|
|
522
|
+
method: "GET",
|
|
523
|
+
path: "/probe/:id",
|
|
524
|
+
anonymous: true,
|
|
525
|
+
handler: async (c) => {
|
|
526
|
+
const id = c.req.param("id");
|
|
527
|
+
if (id === "missing") return c.text("not found", 404);
|
|
528
|
+
return c.text(`probe:${id}`, 200);
|
|
529
|
+
},
|
|
530
|
+
});
|
|
531
|
+
});
|
|
532
|
+
|
|
533
|
+
describe("createKumikoServer — tryHonoFirst 404-vs-router-miss (#2435)", () => {
|
|
534
|
+
async function bootWithProbeRoute(): Promise<KumikoServerHandle> {
|
|
535
|
+
handle = await createKumikoServer({
|
|
536
|
+
features: [probeHttpRouteFeature],
|
|
537
|
+
port: 0,
|
|
538
|
+
installSignalHandlers: false,
|
|
539
|
+
});
|
|
540
|
+
return handle;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
test("gematchte httpRoute mit bewusster 404 bleibt 404 (keine SPA-Maskierung)", async () => {
|
|
544
|
+
const h = await bootWithProbeRoute();
|
|
545
|
+
const res = await h.fetch(new Request("http://localhost/probe/missing"));
|
|
546
|
+
expect(res.status).toBe(404);
|
|
547
|
+
expect(res.headers.get("content-type") ?? "").not.toMatch(/text\/html/);
|
|
548
|
+
expect(await res.text()).toBe("not found");
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
test("gematchte httpRoute mit Treffer antwortet normal (200)", async () => {
|
|
552
|
+
const h = await bootWithProbeRoute();
|
|
553
|
+
const res = await h.fetch(new Request("http://localhost/probe/exists"));
|
|
554
|
+
expect(res.status).toBe(200);
|
|
555
|
+
expect(await res.text()).toBe("probe:exists");
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
test("unbekannte SPA-Route liefert weiterhin die SPA-Shell (200 HTML)", async () => {
|
|
559
|
+
const h = await bootWithProbeRoute();
|
|
560
|
+
const res = await h.fetch(new Request("http://localhost/some/client-side/route"));
|
|
561
|
+
expect(res.status).toBe(200);
|
|
562
|
+
expect(res.headers.get("content-type")).toMatch(/text\/html/);
|
|
563
|
+
expect(await res.text()).toMatch(/<div id="root">/);
|
|
564
|
+
});
|
|
565
|
+
|
|
566
|
+
test("kein Router-miss-Marker leakt an den Client", async () => {
|
|
567
|
+
const h = await bootWithProbeRoute();
|
|
568
|
+
const missRes = await h.fetch(new Request("http://localhost/some/client-side/route"));
|
|
569
|
+
const deniedRes = await h.fetch(new Request("http://localhost/probe/missing"));
|
|
570
|
+
expect(missRes.headers.has(NO_ROUTE_MATCH_HEADER_NAME)).toBe(false);
|
|
571
|
+
expect(deniedRes.headers.has(NO_ROUTE_MATCH_HEADER_NAME)).toBe(false);
|
|
572
|
+
});
|
|
573
|
+
});
|
|
@@ -42,6 +42,11 @@ describe("public-export smoke", () => {
|
|
|
42
42
|
it("re-exports frameworkCoreEnvSchema via dev-server's package entry", () => {
|
|
43
43
|
expect(devServerPublicApi.frameworkCoreEnvSchema).toBe(frameworkCoreEnvSchema);
|
|
44
44
|
});
|
|
45
|
+
|
|
46
|
+
it("re-exports frameworkCoreEnvSchema via the env-schema subpath", async () => {
|
|
47
|
+
const viaSubpath = await import("@cosmicdrift/kumiko-dev-server/env-schema");
|
|
48
|
+
expect(viaSubpath.frameworkCoreEnvSchema).toBe(frameworkCoreEnvSchema);
|
|
49
|
+
});
|
|
45
50
|
});
|
|
46
51
|
|
|
47
52
|
describe("runProdApp envSchema integration", () => {
|
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "bun:test";
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
-
import { frameworkCoreEnvSchema as envSchemaViaRelative } from "../env-schema";
|
|
5
4
|
|
|
6
5
|
// Mirrors infra/guards/runtime-isolation-classify.ts `classifyByDirective`
|
|
7
6
|
// as of kumiko-framework#2337 (first 600 bytes, first 8 lines,
|
|
@@ -25,17 +24,3 @@ describe("compose-stacks.ts runtime directive", () => {
|
|
|
25
24
|
expect(classifyByDirective(composeStacksPath)).toBe("runtime");
|
|
26
25
|
});
|
|
27
26
|
});
|
|
28
|
-
|
|
29
|
-
describe("@cosmicdrift/kumiko-dev-server/env-schema subpath", () => {
|
|
30
|
-
it("resolves the same schema as the relative import, and it parses", async () => {
|
|
31
|
-
const { frameworkCoreEnvSchema: envSchemaViaSubpath } = await import(
|
|
32
|
-
"@cosmicdrift/kumiko-dev-server/env-schema"
|
|
33
|
-
);
|
|
34
|
-
expect(envSchemaViaSubpath).toBe(envSchemaViaRelative);
|
|
35
|
-
const parsed = envSchemaViaSubpath.parse({
|
|
36
|
-
DATABASE_URL: "postgres://localhost:5432/db",
|
|
37
|
-
REDIS_URL: "redis://localhost:6379",
|
|
38
|
-
});
|
|
39
|
-
expect(parsed.PORT).toBe("3000");
|
|
40
|
-
});
|
|
41
|
-
});
|
|
@@ -276,8 +276,10 @@ describe("scaffoldApp", () => {
|
|
|
276
276
|
|
|
277
277
|
const main = readFileSync(join(dest, "bin/main.ts"), "utf-8");
|
|
278
278
|
expect(main).toContain('from "@cosmicdrift/kumiko-framework/crypto"');
|
|
279
|
+
expect(main).toContain("requireKmsWiring(process.env");
|
|
279
280
|
expect(main).toContain("resolveKmsWiring(process.env");
|
|
280
|
-
expect(main).toContain('
|
|
281
|
+
expect(main).toContain('process.env["NODE_ENV"] === "production"');
|
|
282
|
+
expect(main).not.toContain('if ("allowPlaintextPii" in kmsWiring)');
|
|
281
283
|
expect(main).toContain("...kmsWiring,");
|
|
282
284
|
});
|
|
283
285
|
|
|
@@ -12,11 +12,31 @@ import { tmpdir } from "node:os";
|
|
|
12
12
|
import { join } from "node:path";
|
|
13
13
|
import {
|
|
14
14
|
asRawClient,
|
|
15
|
+
buildEntityTable,
|
|
15
16
|
createDbConnection,
|
|
17
|
+
createEventStoreExecutor,
|
|
18
|
+
createTenantDb,
|
|
16
19
|
type DbConnection,
|
|
20
|
+
integer,
|
|
21
|
+
table as pgTable,
|
|
22
|
+
selectMany,
|
|
17
23
|
tableExists,
|
|
24
|
+
uuid,
|
|
18
25
|
} from "@cosmicdrift/kumiko-framework/db";
|
|
19
|
-
import {
|
|
26
|
+
import {
|
|
27
|
+
createEntity,
|
|
28
|
+
createTextField,
|
|
29
|
+
defineApply,
|
|
30
|
+
defineFeature,
|
|
31
|
+
type ProjectionDefinition,
|
|
32
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
33
|
+
import {
|
|
34
|
+
createTestDb,
|
|
35
|
+
type TestDb,
|
|
36
|
+
TestUsers,
|
|
37
|
+
unsafeCreateEntityTable,
|
|
38
|
+
unsafePushTables,
|
|
39
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
20
40
|
import { runSchemaApply } from "../schema-apply";
|
|
21
41
|
|
|
22
42
|
let testDb: TestDb;
|
|
@@ -76,7 +96,7 @@ describe("runSchemaApply", () => {
|
|
|
76
96
|
expect(await tableExists(conn.db, "public.read_thing")).toBe(true);
|
|
77
97
|
});
|
|
78
98
|
|
|
79
|
-
test("rebuild-Marker für nicht-registrierte Tabelle → kein Crash, 0, aber laut warnen (522/3)", async () => {
|
|
99
|
+
test("rebuild-Marker für nicht-registrierte Tabelle → kein Crash, 0, aber laut warnen (522/3, #2464)", async () => {
|
|
80
100
|
writeFileSync(
|
|
81
101
|
join(migDir, "0002_more.sql"),
|
|
82
102
|
`CREATE TABLE "read_more" ("id" text PRIMARY KEY);`,
|
|
@@ -86,10 +106,86 @@ describe("runSchemaApply", () => {
|
|
|
86
106
|
JSON.stringify({ version: 1, tables: ["read_more"] }),
|
|
87
107
|
);
|
|
88
108
|
|
|
89
|
-
|
|
109
|
+
// runPendingRebuilds (not the old local helper) owns this warning now —
|
|
110
|
+
// it logs via createFallbackLogger(...).error(...), not console.warn.
|
|
111
|
+
const error = spyOn(console, "error").mockImplementation(() => {});
|
|
90
112
|
expect(await runSchemaApply({ ...APPLY, appCwd })).toBe(0);
|
|
91
113
|
expect(await tableExists(conn.db, "public.read_more")).toBe(true);
|
|
92
|
-
expect(
|
|
93
|
-
|
|
114
|
+
expect(error).toHaveBeenCalledWith(expect.stringContaining("read_more"), expect.anything());
|
|
115
|
+
error.mockRestore();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("failed projection rebuild stays queued and is retried on a later apply with zero new migrations (#2464)", async () => {
|
|
119
|
+
// Isolate from any pending-rebuild rows the earlier tests in this file left.
|
|
120
|
+
await asRawClient(conn.db).unsafe(`DROP TABLE IF EXISTS kumiko_pending_rebuilds`);
|
|
121
|
+
|
|
122
|
+
const groupId = "00000000-0000-4000-8000-0000000000b1";
|
|
123
|
+
let failApply = true;
|
|
124
|
+
|
|
125
|
+
const failItemEntity = createEntity({
|
|
126
|
+
table: "read_apply_fail_items",
|
|
127
|
+
fields: {
|
|
128
|
+
groupId: createTextField({ required: true }),
|
|
129
|
+
name: createTextField({ required: true }),
|
|
130
|
+
},
|
|
131
|
+
});
|
|
132
|
+
const failItemTable = buildEntityTable("apply-fail-item", failItemEntity);
|
|
133
|
+
const failCountsTable = pgTable("read_apply_fail_counts", {
|
|
134
|
+
groupId: uuid("group_id").primaryKey(),
|
|
135
|
+
tenantId: uuid("tenant_id").notNull(),
|
|
136
|
+
itemCount: integer("item_count").notNull().default(0),
|
|
137
|
+
});
|
|
138
|
+
const failCountsProjection: ProjectionDefinition = {
|
|
139
|
+
name: "apply-fail-counts",
|
|
140
|
+
source: "apply-fail-item",
|
|
141
|
+
table: failCountsTable,
|
|
142
|
+
apply: {
|
|
143
|
+
"apply-fail-item.created": defineApply<{ groupId: string }>(async (event, tx) => {
|
|
144
|
+
if (failApply) throw new Error("simulated rebuild failure (test)");
|
|
145
|
+
await asRawClient(tx).unsafe(
|
|
146
|
+
`INSERT INTO "read_apply_fail_counts" (group_id, tenant_id, item_count) VALUES ($1::uuid, $2::uuid, 1)
|
|
147
|
+
ON CONFLICT (group_id) DO UPDATE SET item_count = read_apply_fail_counts.item_count + 1`,
|
|
148
|
+
[event.payload.groupId, event.tenantId],
|
|
149
|
+
);
|
|
150
|
+
}),
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
const feature = defineFeature("applyfailtest", (r) => {
|
|
154
|
+
r.entity("apply-fail-item", failItemEntity);
|
|
155
|
+
r.projection(failCountsProjection);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
await unsafeCreateEntityTable(conn.db, failItemEntity, "apply-fail-item");
|
|
159
|
+
await unsafePushTables(conn.db, { readApplyFailCounts: failCountsTable });
|
|
160
|
+
|
|
161
|
+
const tdb = createTenantDb(conn.db, TestUsers.admin.tenantId);
|
|
162
|
+
const executor = createEventStoreExecutor(failItemTable, failItemEntity, {
|
|
163
|
+
entityName: "apply-fail-item",
|
|
164
|
+
});
|
|
165
|
+
await executor.create({ groupId, name: "x" }, TestUsers.admin, tdb);
|
|
166
|
+
|
|
167
|
+
writeFileSync(join(migDir, "0003_touch_fail_counts.sql"), "SELECT 1;\n");
|
|
168
|
+
writeFileSync(
|
|
169
|
+
join(migDir, "0003_touch_fail_counts.rebuild.json"),
|
|
170
|
+
JSON.stringify({ version: 1, tables: ["read_apply_fail_counts"] }),
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
const error = spyOn(console, "error").mockImplementation(() => {});
|
|
174
|
+
const firstRun = await runSchemaApply({ features: [feature], includeBundled: false, appCwd });
|
|
175
|
+
error.mockRestore();
|
|
176
|
+
// Fail-loud: a failed rebuild must surface as a non-zero exit, not a
|
|
177
|
+
// silent 0 — the migration itself is now tracked applied, so without a
|
|
178
|
+
// persisted queue this table's rebuild would never be retried again.
|
|
179
|
+
expect(firstRun).toBe(1);
|
|
180
|
+
const [rowAfterFail] = await selectMany(conn.db, failCountsTable, { groupId });
|
|
181
|
+
expect(rowAfterFail).toBeUndefined();
|
|
182
|
+
|
|
183
|
+
// Second apply: no new migrations (0003 is already tracked), yet the
|
|
184
|
+
// queued table must still be retried from kumiko_pending_rebuilds.
|
|
185
|
+
failApply = false;
|
|
186
|
+
const secondRun = await runSchemaApply({ features: [feature], includeBundled: false, appCwd });
|
|
187
|
+
expect(secondRun).toBe(0);
|
|
188
|
+
const [rowAfterRetry] = await selectMany(conn.db, failCountsTable, { groupId });
|
|
189
|
+
expect(rowAfterRetry?.itemCount).toBe(1);
|
|
94
190
|
});
|
|
95
191
|
});
|
|
@@ -41,7 +41,10 @@ import {
|
|
|
41
41
|
canResolveTailwindStylesheet,
|
|
42
42
|
resolveTailwindCli,
|
|
43
43
|
} from "@cosmicdrift/kumiko-server-runtime/resolve-tailwind-cli";
|
|
44
|
-
import {
|
|
44
|
+
import {
|
|
45
|
+
stripNoRouteMatchHeader,
|
|
46
|
+
tryHonoFirst,
|
|
47
|
+
} from "@cosmicdrift/kumiko-server-runtime/try-hono-first";
|
|
45
48
|
|
|
46
49
|
// Runtime-detection. The dev-server is meant to run under Bun (Kumiko's
|
|
47
50
|
// target runtime), but the test-suite runs under vitest on Node — we
|
|
@@ -941,7 +944,11 @@ export async function createKumikoServer(
|
|
|
941
944
|
return htmlResponse("client", true);
|
|
942
945
|
}
|
|
943
946
|
|
|
944
|
-
|
|
947
|
+
// Bypasses tryHonoFirst entirely (API paths, dotted paths, /sse,
|
|
948
|
+
// non-GET/HEAD), so the router-miss marker must be stripped here too —
|
|
949
|
+
// otherwise an unmatched path would leak it straight to the client
|
|
950
|
+
// (see try-hono-first.ts's header-hygiene note).
|
|
951
|
+
return stripNoRouteMatchHeader(await stack.app.fetch(req));
|
|
945
952
|
};
|
|
946
953
|
|
|
947
954
|
// --- HTTP server (Bun only) ---
|
package/src/env-schema.ts
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
// @runtime runtime
|
|
2
|
+
// Consumed at prod boot via composeEnvSchema (same as compose-stacks),
|
|
3
|
+
// not only by tooling — keep runtime-isolation-safe (kumiko-framework#2340).
|
|
4
|
+
//
|
|
1
5
|
// Framework-core env-schema — the vars that runProdApp + buildServer
|
|
2
6
|
// + the connection-pool read directly from process.env today. Apps merge
|
|
3
7
|
// this into their app-wide schema via `composeEnvSchema({ core, ... })`.
|
package/src/scaffold-app.ts
CHANGED
|
@@ -439,7 +439,7 @@ function renderMain(appName: string): string {
|
|
|
439
439
|
});
|
|
440
440
|
sf.addImportDeclaration({
|
|
441
441
|
moduleSpecifier: "@cosmicdrift/kumiko-framework/crypto",
|
|
442
|
-
namedImports: ["resolveKmsWiring"],
|
|
442
|
+
namedImports: ["requireKmsWiring", "resolveKmsWiring"],
|
|
443
443
|
});
|
|
444
444
|
sf.addImportDeclaration({
|
|
445
445
|
moduleSpecifier: "../src/run-config",
|
|
@@ -482,34 +482,40 @@ function renderMain(appName: string): string {
|
|
|
482
482
|
});
|
|
483
483
|
|
|
484
484
|
// Subject-key KMS for the pii-annotated entities the --yes recommended set
|
|
485
|
-
// mounts (user, tenant-invitation, fileRef).
|
|
486
|
-
//
|
|
487
|
-
//
|
|
488
|
-
//
|
|
485
|
+
// mounts (user, tenant-invitation, fileRef). Local/dev may omit the trio
|
|
486
|
+
// (plaintext + loud warning); NODE_ENV=production uses requireKmsWiring so
|
|
487
|
+
// a real deploy cannot silently store plaintext PII (fw#2317 / #2339).
|
|
488
|
+
// assertPiiBootInvariants already warns on the plaintext path — no duplicate console.warn.
|
|
489
489
|
sf.addVariableStatement({
|
|
490
490
|
declarationKind: VariableDeclarationKind.Const,
|
|
491
491
|
declarations: [
|
|
492
492
|
{
|
|
493
|
-
name: "
|
|
493
|
+
name: "kmsOpts",
|
|
494
494
|
initializer: (writer) => {
|
|
495
|
-
writer.write("
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
});
|
|
501
|
-
writer.write(")");
|
|
495
|
+
writer.write("{").newLine();
|
|
496
|
+
writer.writeLine(` logPrefix: "[${appName}]",`);
|
|
497
|
+
writer.writeLine(
|
|
498
|
+
` plaintextReason: "no PLATFORM_KEK / SUBJECT_KEYS_DATABASE_URL / KUMIKO_BLIND_INDEX_KEY set — see .env.example",`,
|
|
499
|
+
);
|
|
500
|
+
writer.write("} as const");
|
|
502
501
|
},
|
|
503
502
|
},
|
|
504
503
|
],
|
|
505
504
|
});
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
505
|
+
sf.addVariableStatement({
|
|
506
|
+
declarationKind: VariableDeclarationKind.Const,
|
|
507
|
+
declarations: [
|
|
508
|
+
{
|
|
509
|
+
name: "kmsWiring",
|
|
510
|
+
initializer: (writer) => {
|
|
511
|
+
writer.write(`process.env["NODE_ENV"] === "production"`);
|
|
512
|
+
writer.newLine();
|
|
513
|
+
writer.write(" ? requireKmsWiring(process.env, kmsOpts)");
|
|
514
|
+
writer.newLine();
|
|
515
|
+
writer.write(" : resolveKmsWiring(process.env, kmsOpts)");
|
|
516
|
+
},
|
|
517
|
+
},
|
|
518
|
+
],
|
|
513
519
|
});
|
|
514
520
|
|
|
515
521
|
sf.addStatements((writer) => {
|
|
@@ -697,11 +703,14 @@ JWT_SECRET=change-me-min-32-chars-change-me-min-32
|
|
|
697
703
|
# Generate with: openssl rand -base64 32
|
|
698
704
|
KUMIKO_SECRETS_MASTER_KEY_V1=
|
|
699
705
|
|
|
700
|
-
# Subject-keys KMS for PII fields (user, tenant-invitation, fileRef).
|
|
701
|
-
#
|
|
702
|
-
#
|
|
703
|
-
#
|
|
706
|
+
# Subject-keys KMS for PII fields (user, tenant-invitation, fileRef). All-or-none.
|
|
707
|
+
# Unset → local boot with plaintext PII + warning; NODE_ENV=production requires
|
|
708
|
+
# the trio (requireKmsWiring). Generate PLATFORM_KEK / KUMIKO_BLIND_INDEX_KEY
|
|
709
|
+
# with: openssl rand -base64 32. SUBJECT_KEYS_DATABASE_URL must be a dedicated
|
|
710
|
+
# subject-keys cluster (not the app DB) — see kms-adapter.md.
|
|
704
711
|
PLATFORM_KEK=
|
|
712
|
+
# Separate Postgres instance for subject keys — never DATABASE_URL (retention
|
|
713
|
+
# must outlive app-DB backups for crypto-shredding).
|
|
705
714
|
SUBJECT_KEYS_DATABASE_URL=
|
|
706
715
|
KUMIKO_BLIND_INDEX_KEY=
|
|
707
716
|
|
|
@@ -215,7 +215,6 @@ export function renderDemoTasksIndex(): string {
|
|
|
215
215
|
|
|
216
216
|
export function renderDemoSeedFile(): string {
|
|
217
217
|
return `// Demo seed — a few tasks so \`bun dev\` shows a non-empty list.
|
|
218
|
-
// Idempotent: skips when the tenant already has tasks (persistent dev DB).
|
|
219
218
|
|
|
220
219
|
import type { SeedFn } from "@cosmicdrift/kumiko-dev-server";
|
|
221
220
|
import { TestUsers } from "@cosmicdrift/kumiko-framework/stack";
|
package/src/schema-apply.ts
CHANGED
|
@@ -8,19 +8,16 @@
|
|
|
8
8
|
|
|
9
9
|
import { existsSync } from "node:fs";
|
|
10
10
|
import { join } from "node:path";
|
|
11
|
-
import {
|
|
12
|
-
createDbConnection,
|
|
13
|
-
type DbConnection,
|
|
14
|
-
readRebuildMarker,
|
|
15
|
-
runMigrationsFromDir,
|
|
16
|
-
} from "@cosmicdrift/kumiko-framework/db";
|
|
11
|
+
import { createDbConnection, runMigrationsFromDir } from "@cosmicdrift/kumiko-framework/db";
|
|
17
12
|
import { createRegistry, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
|
|
18
13
|
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
19
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
queueRebuildsFromMarkers,
|
|
16
|
+
runPendingRebuilds,
|
|
17
|
+
} from "@cosmicdrift/kumiko-framework/migrations";
|
|
20
18
|
import {
|
|
21
19
|
createEventConsumerStateTable,
|
|
22
20
|
createProjectionStateTable,
|
|
23
|
-
rebuildProjection,
|
|
24
21
|
} from "@cosmicdrift/kumiko-framework/pipeline";
|
|
25
22
|
import {
|
|
26
23
|
type ComposeFeaturesOptions,
|
|
@@ -72,15 +69,34 @@ export async function runSchemaApply(opts: SchemaApplyOptions): Promise<number>
|
|
|
72
69
|
console.log("");
|
|
73
70
|
}
|
|
74
71
|
|
|
75
|
-
// Projection
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
72
|
+
// Projection rebuild: persistent queue instead of "only this run's
|
|
73
|
+
// result.applied" — otherwise a failed rebuild stays silently stuck
|
|
74
|
+
// forever, since the migration is already tracked applied and gets
|
|
75
|
+
// skipped on the next apply (#2464). queueRebuildsFromMarkers persists
|
|
76
|
+
// the marker tables BEFORE the rebuild; runPendingRebuilds unconditionally
|
|
77
|
+
// also picks up open entries from earlier, failed runs — not just the
|
|
78
|
+
// ones this run freshly applied.
|
|
79
|
+
const thisRunTables = await queueRebuildsFromMarkers(db, {
|
|
80
|
+
migrationsDir,
|
|
81
|
+
appliedIds: result.applied,
|
|
82
|
+
});
|
|
83
|
+
const registry = createRegistry(composeFeatures([...opts.features], opts));
|
|
84
|
+
const rebuildRun = await runPendingRebuilds(db, registry, { thisRunTables });
|
|
85
|
+
if (rebuildRun.rebuilt.length > 0) {
|
|
86
|
+
console.log(` Rebuild ${rebuildRun.rebuilt.length} Projection(s)…`);
|
|
87
|
+
for (const r of rebuildRun.rebuilt) {
|
|
88
|
+
console.log(` ↻ ${r.projection} (${r.eventsProcessed} events)`);
|
|
89
|
+
}
|
|
90
|
+
console.log("");
|
|
81
91
|
}
|
|
82
|
-
if (
|
|
83
|
-
|
|
92
|
+
if (rebuildRun.failed.length > 0) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`Projection rebuild failed for: ${rebuildRun.failed
|
|
95
|
+
.map((f) => `${f.projection} (${f.error})`)
|
|
96
|
+
.join(
|
|
97
|
+
"; ",
|
|
98
|
+
)}. Table(s) stay queued in kumiko_pending_rebuilds — retried on the next apply.`,
|
|
99
|
+
);
|
|
84
100
|
}
|
|
85
101
|
|
|
86
102
|
return 0;
|
|
@@ -92,39 +108,6 @@ export async function runSchemaApply(opts: SchemaApplyOptions): Promise<number>
|
|
|
92
108
|
}
|
|
93
109
|
}
|
|
94
110
|
|
|
95
|
-
async function rebuildAffectedProjections(
|
|
96
|
-
db: DbConnection,
|
|
97
|
-
changedTables: readonly string[],
|
|
98
|
-
opts: SchemaApplyOptions,
|
|
99
|
-
): Promise<void> {
|
|
100
|
-
const registry = createRegistry(composeFeatures([...opts.features], opts));
|
|
101
|
-
const tableToProjection = buildProjectionTableIndex(registry);
|
|
102
|
-
|
|
103
|
-
const projections = new Set<string>();
|
|
104
|
-
for (const table of changedTables) {
|
|
105
|
-
const name = tableToProjection.get(table);
|
|
106
|
-
if (name) {
|
|
107
|
-
projections.add(name);
|
|
108
|
-
} else {
|
|
109
|
-
// 522/3: a table in a .rebuild.json marker that no longer matches any
|
|
110
|
-
// registered projection would otherwise rebuild nothing and exit 0 —
|
|
111
|
-
// indistinguishable from "nothing needed a rebuild".
|
|
112
|
-
console.warn(
|
|
113
|
-
` ⚠ Table "${table}" is in a rebuild marker but matches no registered projection — skipped.`,
|
|
114
|
-
);
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
// skip: no projections matched the changed tables, nothing to rebuild
|
|
118
|
-
if (projections.size === 0) return;
|
|
119
|
-
|
|
120
|
-
console.log(` Rebuild ${projections.size} Projection(s)…`);
|
|
121
|
-
for (const name of projections) {
|
|
122
|
-
const r = await rebuildProjection(name, { db, registry });
|
|
123
|
-
console.log(` ↻ ${name} (${r.eventsProcessed} events, ${r.durationMs}ms)`);
|
|
124
|
-
}
|
|
125
|
-
console.log("");
|
|
126
|
-
}
|
|
127
|
-
|
|
128
111
|
export async function runStandaloneSchemaCli(opts: SchemaApplyOptions): Promise<never> {
|
|
129
112
|
const cmd = Bun.argv[2];
|
|
130
113
|
const sub = Bun.argv[3];
|