@cosmicdrift/kumiko-framework 0.19.1 → 0.20.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-framework",
3
- "version": "0.19.1",
3
+ "version": "0.20.0",
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>",
@@ -0,0 +1,86 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type { EntityTableMeta } from "../entity-table-meta";
6
+ import { diffSnapshots, snapshotFromMetas } from "../migrate-generator";
7
+ import { readRebuildMarker, rebuildTablesFromDiff, writeRebuildMarker } from "../rebuild-marker";
8
+
9
+ function meta(
10
+ tableName: string,
11
+ extraColumn?: EntityTableMeta["columns"][number],
12
+ ): EntityTableMeta {
13
+ return {
14
+ tableName,
15
+ source: "unmanaged",
16
+ indexes: [],
17
+ columns: [
18
+ { name: "id", pgType: "uuid", notNull: true, primaryKey: true },
19
+ ...(extraColumn ? [extraColumn] : []),
20
+ ],
21
+ };
22
+ }
23
+
24
+ function tmpDir(): string {
25
+ return mkdtempSync(join(tmpdir(), "rebuild-marker-"));
26
+ }
27
+
28
+ describe("rebuildTablesFromDiff", () => {
29
+ test("includes new + changed tables, excludes dropped, sorted + unique", () => {
30
+ const prev = snapshotFromMetas([meta("read_a"), meta("read_c")]);
31
+ const next = snapshotFromMetas([
32
+ meta("read_a", { name: "title", pgType: "text", notNull: true }),
33
+ meta("read_b"),
34
+ ]);
35
+ const diff = diffSnapshots(prev, next);
36
+ expect(rebuildTablesFromDiff(diff)).toEqual(["read_a", "read_b"]);
37
+ });
38
+
39
+ test("no schema change → empty", () => {
40
+ const snap = snapshotFromMetas([meta("read_a")]);
41
+ expect(rebuildTablesFromDiff(diffSnapshots(snap, snap))).toEqual([]);
42
+ });
43
+ });
44
+
45
+ describe("write/read marker", () => {
46
+ test("roundtrip: write tables → read them back via migration-id", () => {
47
+ const dir = tmpDir();
48
+ try {
49
+ writeRebuildMarker(dir, "0002_add_locale.sql", ["read_users", "read_text_blocks"]);
50
+ expect(existsSync(join(dir, "0002_add_locale.rebuild.json"))).toBe(true);
51
+ expect(readRebuildMarker(dir, "0002_add_locale")).toEqual(["read_users", "read_text_blocks"]);
52
+ } finally {
53
+ rmSync(dir, { recursive: true, force: true });
54
+ }
55
+ });
56
+
57
+ test("empty table list → no marker file written, read returns []", () => {
58
+ const dir = tmpDir();
59
+ try {
60
+ writeRebuildMarker(dir, "0003_noop.sql", []);
61
+ expect(existsSync(join(dir, "0003_noop.rebuild.json"))).toBe(false);
62
+ expect(readRebuildMarker(dir, "0003_noop")).toEqual([]);
63
+ } finally {
64
+ rmSync(dir, { recursive: true, force: true });
65
+ }
66
+ });
67
+
68
+ test("missing marker → []", () => {
69
+ const dir = tmpDir();
70
+ try {
71
+ expect(readRebuildMarker(dir, "9999_absent")).toEqual([]);
72
+ } finally {
73
+ rmSync(dir, { recursive: true, force: true });
74
+ }
75
+ });
76
+
77
+ test("corrupt marker file → [] (does not throw)", () => {
78
+ const dir = tmpDir();
79
+ try {
80
+ writeFileSync(join(dir, "0004_broken.rebuild.json"), "{ not json");
81
+ expect(readRebuildMarker(dir, "0004_broken")).toEqual([]);
82
+ } finally {
83
+ rmSync(dir, { recursive: true, force: true });
84
+ }
85
+ });
86
+ });
package/src/db/index.ts CHANGED
@@ -95,6 +95,11 @@ export {
95
95
  transaction,
96
96
  updateMany,
97
97
  } from "./query-api";
98
+ export {
99
+ readRebuildMarker,
100
+ rebuildTablesFromDiff,
101
+ writeRebuildMarker,
102
+ } from "./rebuild-marker";
98
103
  export { seedReferenceData } from "./reference-data";
99
104
  export { renderTableDdl, renderTablesDdl } from "./render-ddl";
100
105
  export { tableExists } from "./schema-inspection";
@@ -0,0 +1,75 @@
1
+ // Rebuild-Marker für den drizzle-freien Migrations-Pfad.
2
+ //
3
+ // `kumiko schema generate` schreibt neben jedes `NNNN_<name>.sql` einen
4
+ // Sibling-Marker `NNNN_<name>.rebuild.json`, der die in dieser Migration
5
+ // geänderten/neu angelegten Tabellen listet. `kumiko schema apply` liest den
6
+ // Marker für jede frisch applizierte Migration und rebuildet die betroffenen
7
+ // Projektionen.
8
+ //
9
+ // Bewusst nur **Tabellennamen** (kein Projection-Name): der Generator ist
10
+ // registry-frei (kennt die App-Projektionen nicht). Die Auflösung
11
+ // Tabelle→Projection passiert app-seitig beim Apply via
12
+ // `buildProjectionTableIndex(registry)`. Tabellen ohne zugehörige Projektion
13
+ // werden dort einfach übersprungen.
14
+ //
15
+ // Marker werden zum generate-Zeitpunkt aus dem strukturierten `SchemaDiff`
16
+ // geschrieben — nicht beim Apply aus dem (ggf. hand-editierten) SQL
17
+ // re-derived.
18
+
19
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
20
+ import { join } from "node:path";
21
+
22
+ import type { SchemaDiff } from "./migrate-generator";
23
+
24
+ const MARKER_VERSION = 1 as const;
25
+
26
+ type RebuildMarker = {
27
+ readonly version: typeof MARKER_VERSION;
28
+ readonly tables: readonly string[];
29
+ };
30
+
31
+ function markerPathFor(migrationsDir: string, migrationId: string): string {
32
+ return join(migrationsDir, `${migrationId}.rebuild.json`);
33
+ }
34
+
35
+ // Tabellen die nach dieser Migration einen Projection-Rebuild brauchen können:
36
+ // neu angelegte + spalten-/index-geänderte. Gelöschte Tabellen NICHT (die
37
+ // Projektion ist mit der Tabelle weg). Sortiert + dedupliziert für stabilen
38
+ // PR-Diff.
39
+ export function rebuildTablesFromDiff(diff: SchemaDiff): readonly string[] {
40
+ const names = new Set<string>();
41
+ for (const t of diff.changedTables) names.add(t.tableName);
42
+ for (const t of diff.newTables) names.add(t.tableName);
43
+ return [...names].sort();
44
+ }
45
+
46
+ // Schreibt `<sqlFilename ohne .sql>.rebuild.json`. Leere Tabellen-Liste →
47
+ // kein Marker (z.B. reine Drop-Migration).
48
+ export function writeRebuildMarker(
49
+ migrationsDir: string,
50
+ sqlFilename: string,
51
+ tables: readonly string[],
52
+ ): void {
53
+ // skip: leere Tabellen-Liste → kein Marker (z.B. reine Drop-Migration).
54
+ if (tables.length === 0) return;
55
+ const migrationId = sqlFilename.replace(/\.sql$/, "");
56
+ const marker: RebuildMarker = { version: MARKER_VERSION, tables };
57
+ writeFileSync(markerPathFor(migrationsDir, migrationId), `${JSON.stringify(marker, null, 2)}\n`);
58
+ }
59
+
60
+ // Liest die Tabellen-Liste für eine applizierte Migration. Kein Marker /
61
+ // kaputtes File → leere Liste (Migration ohne Projection-Impact).
62
+ export function readRebuildMarker(migrationsDir: string, migrationId: string): readonly string[] {
63
+ const path = markerPathFor(migrationsDir, migrationId);
64
+ if (!existsSync(path)) return [];
65
+ let parsed: unknown;
66
+ try {
67
+ parsed = JSON.parse(readFileSync(path, "utf8"));
68
+ } catch {
69
+ return [];
70
+ }
71
+ if (typeof parsed !== "object" || parsed === null || !("tables" in parsed)) return [];
72
+ const { tables } = parsed;
73
+ if (!Array.isArray(tables)) return [];
74
+ return tables.filter((t): t is string => typeof t === "string");
75
+ }
package/src/schema-cli.ts CHANGED
@@ -17,8 +17,10 @@ import {
17
17
  generateMigration,
18
18
  loadMigrationsFromDir,
19
19
  loadSnapshotJson,
20
+ rebuildTablesFromDiff,
20
21
  type renderTablesDdl,
21
22
  runMigrationsFromDir,
23
+ writeRebuildMarker,
22
24
  writeSnapshotJson,
23
25
  } from "./db";
24
26
 
@@ -105,11 +107,22 @@ export async function runSchemaCli(
105
107
  writeFileSync(join(migrationsDir, result.filename), result.sqlContent);
106
108
  writeSnapshotJson(snapshotPath, result.snapshot);
107
109
 
110
+ // Rebuild-Marker nur für inkrementelle Migrationen — die Init-Migration
111
+ // (prevSnapshot===null) legt nur Tabellen an, es gibt keine historischen
112
+ // Events zum Replayen.
113
+ const rebuildTables = prevSnapshot === null ? [] : rebuildTablesFromDiff(result.diff);
114
+ writeRebuildMarker(migrationsDir, result.filename, rebuildTables);
115
+
108
116
  out.log("");
109
117
  out.log(` ✓ ${result.filename}`);
110
118
  out.log(
111
119
  ` new tables: ${result.diff.newTables.length}, changed: ${result.diff.changedTables.length}, dropped: ${result.diff.droppedTables.length}`,
112
120
  );
121
+ if (rebuildTables.length > 0) {
122
+ out.log(
123
+ ` rebuild-marker: ${result.filename.replace(/\.sql$/, ".rebuild.json")} (${rebuildTables.length} table(s))`,
124
+ );
125
+ }
113
126
  out.log("");
114
127
  out.log(" Review + ggf. hand-edit + git add + commit. Apply via: kumiko-schema apply");
115
128
  out.log("");