@cosmicdrift/kumiko-framework 0.19.1 → 0.21.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/LICENSE CHANGED
@@ -21,7 +21,7 @@ managed offering of the Licensed Work.
21
21
  This restriction does not apply to the Licensor, any entity controlled by,
22
22
  controlling, or under common control with the Licensor ("Affiliates"), or
23
23
  contractors acting on their behalf. The Licensor remains free to use the
24
- Licensed Work for any purpose, including for the operation of kumiko.so.
24
+ Licensed Work for any purpose, including for the operation of kumiko.rocks.
25
25
 
26
26
  Change Date: 2030-05-05
27
27
  Change License: Apache License, Version 2.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.19.1",
3
+ "version": "0.21.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>",
@@ -12,7 +12,7 @@
12
12
  "bugs": {
13
13
  "url": "https://github.com/CosmicDriftGameStudio/kumiko-framework/issues"
14
14
  },
15
- "homepage": "https://kumiko.so",
15
+ "homepage": "https://kumiko.rocks",
16
16
  "keywords": [
17
17
  "framework",
18
18
  "multi-tenant",
@@ -4,7 +4,7 @@
4
4
  // Plattform-Betrieb einsetzt. Wird oeffentlich exposed unter
5
5
  // /api/compliance/sub-processors (JSON, Sprint 1)
6
6
  // /api/compliance/sub-processors.rss (RSS, Sprint 1)
7
- // kumiko.so/subprocessors (HTML, Marketing-Repo)
7
+ // kumiko.rocks/subprocessors (HTML, Marketing-Repo)
8
8
  //
9
9
  // Tenant-Admins muessen ueber Add/Change/Remove informiert werden mit
10
10
  // Lead-Time aus dem Compliance-Profile (typisch 30d). Cron-Job kommt
@@ -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
+ }
@@ -614,7 +614,7 @@ export type TypedAppendEventArgs<TMap extends object, K extends keyof TMap> = {
614
614
  // We pick the first option and force the wrong path to fail visibly.
615
615
  //
616
616
  // How this is wired in practice:
617
- // - Apps run `yarn kumiko codegen`, which writes `.kumiko/define.ts`
617
+ // - Apps run `bun kumiko codegen`, which writes `.kumiko/define.ts`
618
618
  // with locally-bound `defineWriteHandler<TName, TSchema, TData,
619
619
  // KumikoEventTypeMap>(...)` wrappers. Handlers inside those wrappers
620
620
  // get a strict ctx.appendEvent.
@@ -30,22 +30,22 @@ describe("KumikoError: abstract base", () => {
30
30
  describe("docsUrl getter — Self-Service-Link", () => {
31
31
  test("uses details.reason when set (NotFoundError sets entity-specific reason)", () => {
32
32
  const err = new NotFoundError("order", 42);
33
- expect(err.docsUrl).toBe("https://docs.kumiko.so/errors/order_not_found");
33
+ expect(err.docsUrl).toBe("https://docs.kumiko.rocks/errors/order_not_found");
34
34
  });
35
35
 
36
36
  test("uses details.reason when explicitly set (ConflictError-style)", () => {
37
37
  const err = new ConflictError({ details: { reason: "stale_state" } });
38
- expect(err.docsUrl).toBe("https://docs.kumiko.so/errors/stale_state");
38
+ expect(err.docsUrl).toBe("https://docs.kumiko.rocks/errors/stale_state");
39
39
  });
40
40
 
41
41
  test("falls back to code when details has no reason field", () => {
42
42
  const err = new ConflictError({ details: { foo: "bar" } });
43
- expect(err.docsUrl).toBe("https://docs.kumiko.so/errors/conflict");
43
+ expect(err.docsUrl).toBe("https://docs.kumiko.rocks/errors/conflict");
44
44
  });
45
45
 
46
46
  test("falls back to code when details is undefined", () => {
47
47
  const err = new ConflictError();
48
- expect(err.docsUrl).toBe("https://docs.kumiko.so/errors/conflict");
48
+ expect(err.docsUrl).toBe("https://docs.kumiko.rocks/errors/conflict");
49
49
  });
50
50
 
51
51
  test("respects KUMIKO_DOCS_URL env override (Self-Hosted-Kunden)", () => {
@@ -63,7 +63,7 @@ describe("KumikoError: abstract base", () => {
63
63
  test("serializeError exposes docsUrl in the wire response", () => {
64
64
  const err = new ConflictError({ details: { reason: "stale_state" } });
65
65
  const body = serializeError(err);
66
- expect(body.error.docsUrl).toBe("https://docs.kumiko.so/errors/stale_state");
66
+ expect(body.error.docsUrl).toBe("https://docs.kumiko.rocks/errors/stale_state");
67
67
  });
68
68
  });
69
69
  });
@@ -22,7 +22,7 @@ export type ErrorCtorInput = {
22
22
  // Default-Doku-URL für Self-Service-Errors. Kann via env-var
23
23
  // `KUMIKO_DOCS_URL` überschrieben werden — z.B. für Self-Hosted-Kunden
24
24
  // die ihre eigene Doku-Instanz hosten.
25
- const DEFAULT_DOCS_BASE_URL = "https://docs.kumiko.so";
25
+ const DEFAULT_DOCS_BASE_URL = "https://docs.kumiko.rocks";
26
26
 
27
27
  function docsBaseUrl(): string {
28
28
  return process.env["KUMIKO_DOCS_URL"] ?? DEFAULT_DOCS_BASE_URL;
@@ -1,6 +1,4 @@
1
1
  // Drizzle-free gate (kumiko/migrations system) — the canonical boot-gate.
2
- // `SchemaDriftError` is re-exported from here; the legacy drizzle gate above
3
- // keeps its own internal error until Phase 3 removes schema-drift.ts.
4
2
  export {
5
3
  assertKumikoSchemaCurrent,
6
4
  type ChecksumMismatch,
@@ -9,30 +7,5 @@ export {
9
7
  type KumikoDriftReport,
10
8
  SchemaDriftError,
11
9
  } from "./kumiko-drift";
12
- export {
13
- buildProjectionTableIndex,
14
- type ChangedTable,
15
- compareSnapshots,
16
- detectProjectionsToRebuild,
17
- latestMigrationTag,
18
- projectionsFromChanges,
19
- } from "./projection-detection";
20
- export { type RebuildMarker, readRebuildMarker, writeRebuildMarker } from "./rebuild-marker";
21
- export {
22
- type AppliedMigration,
23
- assertSchemaCurrent,
24
- type ColumnIssue,
25
- type ColumnSpec,
26
- type DriftReport,
27
- detectDrift,
28
- formatDriftReport,
29
- type Journal,
30
- type JournalEntry,
31
- loadAppliedMigrations,
32
- loadJournal,
33
- loadLatestSnapshot,
34
- loadPreviousSnapshot,
35
- loadSnapshot,
36
- type Snapshot,
37
- type SnapshotTable,
38
- } from "./schema-drift";
10
+ // tableName → projection-name, für den app-seitigen Projection-Rebuild.
11
+ export { buildProjectionTableIndex } from "./projection-table-index";
@@ -0,0 +1,35 @@
1
+ // Index `tableName → projection-name` aus der Registry. Genutzt vom
2
+ // app-seitigen Projection-Rebuild (`kumiko schema apply` liest die
3
+ // rebuild-Marker → mappt Tabellen auf Projektionen → rebuildProjection).
4
+ //
5
+ // Drizzle-frei: der Tabellen-Name kommt aus dem kumiko-Symbol das
6
+ // buildEntityTable/buildEntityTableMeta an die Table-Definition hängt.
7
+
8
+ import type { Registry } from "../engine/types/feature";
9
+
10
+ const KUMIKO_NAME_SYMBOL = Symbol.for("kumiko:schema:Name");
11
+
12
+ function getTableName(table: unknown): string {
13
+ if (typeof table !== "object" || table === null) {
14
+ throw new Error("projection-table-index: table is not a table object");
15
+ }
16
+ const name = (table as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL];
17
+ if (typeof name !== "string") {
18
+ throw new Error("projection-table-index: table missing kumiko name symbol");
19
+ }
20
+ return name;
21
+ }
22
+
23
+ /** Index `tableName → projection-name` aus der Registry. Nur Projections mit
24
+ * table-Definition (single-stream + multi-stream-with-table) zählen.
25
+ * Side-effect-only MSPs (table omitted) haben keinen Rebuild-Sinn. */
26
+ export function buildProjectionTableIndex(registry: Registry): ReadonlyMap<string, string> {
27
+ const index = new Map<string, string>();
28
+ for (const [name, def] of registry.getAllProjections()) {
29
+ index.set(getTableName(def.table), name);
30
+ }
31
+ for (const [name, def] of registry.getAllMultiStreamProjections()) {
32
+ if (def.table) index.set(getTableName(def.table), name);
33
+ }
34
+ return index;
35
+ }
@@ -99,7 +99,7 @@ export async function rebuildMultiStreamProjection(
99
99
  }
100
100
  if (!msp.table) {
101
101
  throw new Error(
102
- `MultiStreamProjection "${mspName}" has no backing table — it is a pure side-effect consumer (webhooks, notifications, external sync). Rebuild would re-invoke those side-effects by replaying the log. For poison events use yarn kumiko consumer skip / restart; there is no analogous "rebuild" concept for side-effect sinks.`,
102
+ `MultiStreamProjection "${mspName}" has no backing table — it is a pure side-effect consumer (webhooks, notifications, external sync). Rebuild would re-invoke those side-effects by replaying the log. For poison events use bun kumiko consumer skip / restart; there is no analogous "rebuild" concept for side-effect sinks.`,
103
103
  );
104
104
  }
105
105
 
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("");
@@ -1,35 +0,0 @@
1
- import type { AnyDb } from "../query";
2
- import { asRawClient } from "../query";
3
-
4
- export type AppliedMigrationRow = {
5
- readonly hash: string;
6
- readonly created_at: bigint | number | null;
7
- };
8
-
9
- export type DbColumnInfoRow = {
10
- readonly column_name: string;
11
- readonly data_type: string;
12
- readonly is_nullable: "YES" | "NO";
13
- };
14
-
15
- /** tableRef must be `drizzle.__drizzle_migrations` or `public.__drizzle_migrations`. */
16
- export async function selectAppliedMigrations(
17
- db: AnyDb,
18
- tableRef: "drizzle.__drizzle_migrations" | "public.__drizzle_migrations",
19
- ): Promise<readonly AppliedMigrationRow[]> {
20
- return (await asRawClient(db).unsafe(
21
- `SELECT hash, created_at FROM ${tableRef} ORDER BY id`,
22
- )) as readonly AppliedMigrationRow[];
23
- }
24
-
25
- export async function selectPublicTableColumns(
26
- db: AnyDb,
27
- tableName: string,
28
- ): Promise<readonly DbColumnInfoRow[]> {
29
- return (await asRawClient(db).unsafe(
30
- `SELECT column_name, data_type, is_nullable
31
- FROM information_schema.columns
32
- WHERE table_schema = 'public' AND table_name = $1`,
33
- [tableName],
34
- )) as readonly DbColumnInfoRow[];
35
- }
@@ -1,150 +0,0 @@
1
- // Unit-Tests für compareSnapshots — der Diff-Algorithmus zwischen zwei
2
- // Drizzle-Snapshots. Production-Behavior: bei Schema-Drift einer
3
- // Projection-Tabelle muss der Detector die richtigen Tabellen-Namen
4
- // melden, damit migrate apply den richtigen Rebuild triggert.
5
-
6
- import { describe, expect, test } from "bun:test";
7
- import { compareSnapshots } from "../projection-detection";
8
- import type { Snapshot, SnapshotTable } from "../schema-drift";
9
-
10
- function snapshot(tables: Record<string, Partial<SnapshotTable>>): Snapshot {
11
- const out: Record<string, SnapshotTable> = {};
12
- for (const [key, partial] of Object.entries(tables)) {
13
- out[key] = {
14
- schema: partial.schema ?? "",
15
- name: partial.name ?? key.replace(/^public\./, ""),
16
- columns: partial.columns ?? {},
17
- };
18
- }
19
- return { tables: out };
20
- }
21
-
22
- const userTable: SnapshotTable = {
23
- schema: "",
24
- name: "users",
25
- columns: {
26
- id: { name: "id", type: "uuid", primaryKey: true, notNull: true },
27
- email: { name: "email", type: "text", notNull: true },
28
- },
29
- };
30
-
31
- describe("compareSnapshots", () => {
32
- test("prev=null → all current tables marked as added", () => {
33
- const current = snapshot({ "public.users": userTable });
34
- const changes = compareSnapshots(null, current);
35
- expect(changes).toHaveLength(1);
36
- expect(changes[0]).toMatchObject({ tableName: "users", kind: "added" });
37
- });
38
-
39
- test("identical snapshots → no changes", () => {
40
- const s = snapshot({ "public.users": userTable });
41
- expect(compareSnapshots(s, s)).toHaveLength(0);
42
- });
43
-
44
- test("table appears in current → kind=added", () => {
45
- const prev = snapshot({});
46
- const current = snapshot({ "public.users": userTable });
47
- const changes = compareSnapshots(prev, current);
48
- expect(changes).toEqual([{ fullName: "users", tableName: "users", kind: "added" }]);
49
- });
50
-
51
- test("table missing in current → kind=removed", () => {
52
- const prev = snapshot({ "public.users": userTable });
53
- const current = snapshot({});
54
- const changes = compareSnapshots(prev, current);
55
- expect(changes).toEqual([{ fullName: "users", tableName: "users", kind: "removed" }]);
56
- });
57
-
58
- test("column added → kind=modified", () => {
59
- const prev = snapshot({ "public.users": userTable });
60
- const current = snapshot({
61
- "public.users": {
62
- ...userTable,
63
- columns: { ...userTable.columns, name: { name: "name", type: "text" } },
64
- },
65
- });
66
- const changes = compareSnapshots(prev, current);
67
- expect(changes).toEqual([{ fullName: "users", tableName: "users", kind: "modified" }]);
68
- });
69
-
70
- test("column type changed → kind=modified", () => {
71
- const prev = snapshot({ "public.users": userTable });
72
- const current = snapshot({
73
- "public.users": {
74
- ...userTable,
75
- columns: {
76
- ...userTable.columns,
77
- email: { name: "email", type: "varchar(255)", notNull: true },
78
- },
79
- },
80
- });
81
- expect(compareSnapshots(prev, current)).toEqual([
82
- { fullName: "users", tableName: "users", kind: "modified" },
83
- ]);
84
- });
85
-
86
- test("notNull flipped → kind=modified", () => {
87
- const prev = snapshot({ "public.users": userTable });
88
- const current = snapshot({
89
- "public.users": {
90
- ...userTable,
91
- columns: { ...userTable.columns, email: { name: "email", type: "text", notNull: false } },
92
- },
93
- });
94
- expect(compareSnapshots(prev, current)).toEqual([
95
- { fullName: "users", tableName: "users", kind: "modified" },
96
- ]);
97
- });
98
-
99
- test("default value changed → kind=modified", () => {
100
- const prev = snapshot({
101
- "public.users": {
102
- ...userTable,
103
- columns: {
104
- ...userTable.columns,
105
- status: { name: "status", type: "text", default: "'active'" },
106
- },
107
- },
108
- });
109
- const current = snapshot({
110
- "public.users": {
111
- ...userTable,
112
- columns: {
113
- ...userTable.columns,
114
- status: { name: "status", type: "text", default: "'pending'" },
115
- },
116
- },
117
- });
118
- expect(compareSnapshots(prev, current)).toEqual([
119
- { fullName: "users", tableName: "users", kind: "modified" },
120
- ]);
121
- });
122
-
123
- test("schema-prefix in fullName when set", () => {
124
- const prev = snapshot({});
125
- const current = snapshot({
126
- "auth.users": { ...userTable, schema: "auth" },
127
- });
128
- const changes = compareSnapshots(prev, current);
129
- expect(changes[0]?.fullName).toBe("auth.users");
130
- });
131
-
132
- test("multiple changes preserved with stable kind classification", () => {
133
- const tableA: SnapshotTable = { ...userTable, name: "a" };
134
- const tableB: SnapshotTable = { ...userTable, name: "b" };
135
- const tableC: SnapshotTable = { ...userTable, name: "c" };
136
- const prev = snapshot({
137
- "public.a": tableA,
138
- "public.b": tableB,
139
- });
140
- const current = snapshot({
141
- "public.a": tableA, // unchanged
142
- "public.c": tableC, // added
143
- // b removed
144
- });
145
- const changes = compareSnapshots(prev, current);
146
- expect(changes).toHaveLength(2);
147
- expect(changes.find((c) => c.tableName === "c")?.kind).toBe("added");
148
- expect(changes.find((c) => c.tableName === "b")?.kind).toBe("removed");
149
- });
150
- });