@cosmicdrift/kumiko-dev-server 0.221.0 → 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
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": {
|
|
@@ -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
|
});
|
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];
|