@cosmicdrift/kumiko-framework 0.15.0 → 0.18.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.
Files changed (58) hide show
  1. package/package.json +1 -1
  2. package/src/api/auth-routes.ts +2 -5
  3. package/src/api/routes.ts +9 -3
  4. package/src/bun-db/__tests__/query-guards.test.ts +53 -0
  5. package/src/bun-db/query.ts +164 -23
  6. package/src/compliance/profiles.ts +1 -4
  7. package/src/db/__tests__/cursor.test.ts +17 -0
  8. package/src/db/__tests__/migrate-generator.test.ts +71 -0
  9. package/src/db/__tests__/migrate-runner.test.ts +19 -0
  10. package/src/db/__tests__/pg-error.test.ts +43 -0
  11. package/src/db/assert-exists-in.ts +5 -1
  12. package/src/db/dialect.ts +11 -6
  13. package/src/db/entity-table-meta.ts +23 -8
  14. package/src/db/index.ts +25 -0
  15. package/src/db/migrate-runner.ts +35 -1
  16. package/src/db/money.ts +5 -0
  17. package/src/db/queries/test-stack.ts +3 -1
  18. package/src/db/table-builder.ts +6 -6
  19. package/src/engine/__tests__/duration-utils.test.ts +16 -0
  20. package/src/engine/__tests__/field-access.test.ts +38 -0
  21. package/src/engine/__tests__/no-return-guard.test.ts +17 -0
  22. package/src/engine/__tests__/unmanaged-table.test.ts +98 -0
  23. package/src/engine/define-feature.ts +36 -0
  24. package/src/engine/feature-ast/extractors/shared.ts +2 -3
  25. package/src/engine/index.ts +1 -1
  26. package/src/engine/registry.ts +19 -0
  27. package/src/engine/schema-builder.ts +1 -1
  28. package/src/engine/types/feature.ts +40 -0
  29. package/src/engine/types/index.ts +2 -0
  30. package/src/errors/__tests__/error-helpers.test.ts +44 -0
  31. package/src/errors/__tests__/field-issue-compat.test.ts +16 -0
  32. package/src/errors/classes.ts +5 -19
  33. package/src/errors/field-issue.ts +11 -0
  34. package/src/errors/index.ts +1 -0
  35. package/src/errors/zod-bridge.ts +3 -2
  36. package/src/es-ops/context.ts +2 -13
  37. package/src/event-store/__tests__/get-stream-version-perf.integration.test.ts +15 -12
  38. package/src/event-store/admin-api.ts +5 -4
  39. package/src/event-store/event-store.ts +3 -2
  40. package/src/event-store/snapshot.ts +2 -1
  41. package/src/migrations/__tests__/kumiko-drift.integration.test.ts +161 -0
  42. package/src/migrations/index.ts +11 -1
  43. package/src/migrations/kumiko-drift.ts +122 -0
  44. package/src/pipeline/__tests__/dispatcher-utils.test.ts +107 -0
  45. package/src/pipeline/__tests__/redis-keys.test.ts +12 -0
  46. package/src/pipeline/dispatcher-utils.ts +8 -7
  47. package/src/stack/request-helper.ts +39 -4
  48. package/src/stack/table-helpers.ts +1 -1
  49. package/src/stack/test-stack.ts +2 -2
  50. package/src/utils/__tests__/case.test.ts +16 -0
  51. package/src/utils/__tests__/is-plain-object.test.ts +16 -0
  52. package/src/utils/__tests__/parse-string-array-json.test.ts +16 -0
  53. package/src/utils/__tests__/safe-json.test.ts +22 -0
  54. package/src/utils/case.ts +6 -0
  55. package/src/utils/index.ts +4 -1
  56. package/src/utils/is-plain-object.ts +4 -0
  57. package/src/utils/parse-string-array-json.ts +14 -0
  58. package/src/utils/safe-json.ts +19 -0
@@ -0,0 +1,44 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { NotFoundError } from "../classes";
3
+ import { FrameworkReasons } from "../reasons";
4
+ import { buildInvalidTransitionDetails } from "../transition-details";
5
+ import { reraiseAsKumikoError, toWriteErrorInfo, writeFailure } from "../write-error-info";
6
+
7
+ describe("writeFailure", () => {
8
+ test("wraps KumikoError into WriteFailure envelope", () => {
9
+ const failure = writeFailure(new NotFoundError("invoice", "inv-1"));
10
+ expect(failure.isSuccess).toBe(false);
11
+ expect(failure.error.code).toBe("not_found");
12
+ expect(failure.error.httpStatus).toBe(404);
13
+ });
14
+ });
15
+
16
+ describe("reraiseAsKumikoError", () => {
17
+ test("round-trips WriteErrorInfo through KumikoError", () => {
18
+ const info = toWriteErrorInfo(new NotFoundError("task", 7));
19
+ const err = reraiseAsKumikoError(info);
20
+ expect(err.code).toBe("not_found");
21
+ expect(err.httpStatus).toBe(404);
22
+ expect(err.message).toContain("task");
23
+ });
24
+ });
25
+
26
+ describe("buildInvalidTransitionDetails", () => {
27
+ test("builds structured from/to/allowed + message", () => {
28
+ const details = buildInvalidTransitionDetails("draft", "paid", ["sent"]);
29
+ expect(details).toMatchObject({
30
+ from: "draft",
31
+ to: "paid",
32
+ allowed: ["sent"],
33
+ });
34
+ expect(details.message).toContain("draft");
35
+ expect(details.message).toContain("sent");
36
+ });
37
+ });
38
+
39
+ describe("FrameworkReasons", () => {
40
+ test("exposes stable snake_case reason codes", () => {
41
+ expect(FrameworkReasons.invalidTransition).toBe("invalid_transition");
42
+ expect(FrameworkReasons.staleState).toBe("stale_state");
43
+ });
44
+ });
@@ -0,0 +1,16 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import type { FieldIssue as FrameworkFieldIssue } from "@cosmicdrift/kumiko-framework/errors";
3
+ import type { FieldIssue as HeadlessFieldIssue } from "@cosmicdrift/kumiko-headless";
4
+
5
+ describe("FieldIssue cross-package contract", () => {
6
+ test("framework and headless FieldIssue shapes are assignable", () => {
7
+ const frameworkIssue: FrameworkFieldIssue = {
8
+ path: "title",
9
+ code: "too_small",
10
+ i18nKey: "errors.validation.too_small",
11
+ params: { minimum: 1 },
12
+ };
13
+ const headlessIssue: HeadlessFieldIssue = frameworkIssue;
14
+ expect(headlessIssue.path).toBe("title");
15
+ });
16
+ });
@@ -1,16 +1,11 @@
1
+ import { toSnakeCase } from "../utils/case";
2
+ import type { FieldIssue } from "./field-issue";
1
3
  import { type ErrorOpts, KumikoError } from "./kumiko-error";
2
4
 
3
- // Per-field validation issue. Shared shape between Zod-derived and
4
- // hook-derived validation errors so the client sees one list.
5
- export type ValidationFieldIssue = {
6
- readonly path: string;
7
- readonly code: string;
8
- readonly i18nKey: string;
9
- readonly params?: Readonly<Record<string, unknown>>;
10
- };
5
+ export type { FieldIssue, ValidationFieldIssue } from "./field-issue";
11
6
 
12
7
  export type ValidationDetails = {
13
- readonly fields: readonly ValidationFieldIssue[];
8
+ readonly fields: readonly FieldIssue[];
14
9
  };
15
10
 
16
11
  export class ValidationError extends KumikoError {
@@ -89,7 +84,7 @@ export class NotFoundError extends KumikoError {
89
84
  // The reason string follows `<snake_entity>_not_found` — keeps a stable,
90
85
  // client-friendly tag that survives wire serialization even if the entity
91
86
  // name is later renamed for display purposes.
92
- const reason = `${toSnake(entity)}_not_found`;
87
+ const reason = `${toSnakeCase(entity)}_not_found`;
93
88
  const details: NotFoundDetails & { reason: string } = { reason, entity, id: idStr };
94
89
  super({
95
90
  message: idStr !== undefined ? `${entity} ${idStr} not found` : `${entity} not found`,
@@ -101,15 +96,6 @@ export class NotFoundError extends KumikoError {
101
96
  }
102
97
  }
103
98
 
104
- // Accepts camelCase OR kebab-case entity names and produces snake_case for
105
- // the reason tag. New code uses kebab; legacy camelCase still flows through.
106
- function toSnake(s: string): string {
107
- return s
108
- .replace(/-/g, "_")
109
- .replace(/([a-z])([A-Z])/g, "$1_$2")
110
- .toLowerCase();
111
- }
112
-
113
99
  // Generic 409. Features that need a narrower shape should subclass (see
114
100
  // VersionConflictError) — this way the HTTP layer stays uniform while callers
115
101
  // can still instanceof on the concrete subtype in handlers.
@@ -0,0 +1,11 @@
1
+ // Canonical per-field validation issue shape — shared between server-side
2
+ // ValidationError, Zod-bridge, and client-side DispatcherError.details.fields.
3
+ export type FieldIssue = {
4
+ readonly path: string;
5
+ readonly code: string;
6
+ readonly i18nKey: string;
7
+ readonly params?: Readonly<Record<string, unknown>>;
8
+ };
9
+
10
+ /** @deprecated Use `FieldIssue` — kept for existing imports. */
11
+ export type ValidationFieldIssue = FieldIssue;
@@ -1,5 +1,6 @@
1
1
  export type {
2
2
  FeatureDisabledDetails,
3
+ FieldIssue,
3
4
  NotFoundDetails,
4
5
  RateLimitDetails,
5
6
  UniqueViolationDetails,
@@ -1,5 +1,6 @@
1
1
  import type { ZodError, ZodIssue } from "zod";
2
- import { ValidationError, type ValidationFieldIssue } from "./classes";
2
+ import { ValidationError } from "./classes";
3
+ import type { FieldIssue } from "./field-issue";
3
4
 
4
5
  // Zod issues carry a .code and sometimes issue-specific params (min, max, etc).
5
6
  // We surface those under `params` so the client can render "must be at least N"
@@ -24,7 +25,7 @@ const ISSUE_PARAM_KEYS = [
24
25
  ] as const;
25
26
 
26
27
  export function validationErrorFromZod(error: ZodError): ValidationError {
27
- const fields = error.issues.map<ValidationFieldIssue>((issue) => {
28
+ const fields = error.issues.map<FieldIssue>((issue) => {
28
29
  const params = extractIssueParams(issue);
29
30
  return {
30
31
  path: issue.path.map(String).join(".") || "(root)",
@@ -15,6 +15,7 @@ import {
15
15
  } from "../db/queries/seed-context";
16
16
  import { createSystemUser, SYSTEM_TENANT_ID } from "../engine";
17
17
  import type { Dispatcher } from "../pipeline/dispatcher";
18
+ import { parseStringArrayJson } from "../utils/parse-string-array-json";
18
19
  import type { SeedMembershipRow, SeedMigrationContext, SeedTenantRow } from "./types";
19
20
 
20
21
  export type CreateSeedMigrationContextArgs = {
@@ -75,7 +76,7 @@ export function createSeedMigrationContext(
75
76
  userId: r.user_id,
76
77
  tenantId: r.tenant_id,
77
78
  streamTenantId: r.stream_tenant_id,
78
- roles: safeParseRolesJson(r.roles),
79
+ roles: parseStringArrayJson(r.roles),
79
80
  }),
80
81
  );
81
82
  },
@@ -89,17 +90,5 @@ export function createSeedMigrationContext(
89
90
  };
90
91
  }
91
92
 
92
- function safeParseRolesJson(raw: string): readonly string[] {
93
- try {
94
- const parsed: unknown = JSON.parse(raw);
95
- if (Array.isArray(parsed) && parsed.every((x) => typeof x === "string")) {
96
- return parsed;
97
- }
98
- } catch {
99
- // Fallthrough — return empty rather than throwing in a seed context.
100
- }
101
- return [];
102
- }
103
-
104
93
  // Re-export für Caller-Convenience.
105
94
  export type { SeedMigrationContext } from "./types";
@@ -11,10 +11,11 @@
11
11
 
12
12
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
13
13
  import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
14
+ import { insertMany } from "../../bun-db/query";
14
15
  import type { TenantId } from "../../engine/types";
15
16
  import { ensureTemporalPolyfill } from "../../time/polyfill";
16
17
  import { generateId as uuid } from "../../utils";
17
- import { append, createEventsTable, getStreamVersion } from "../index";
18
+ import { createEventsTable, eventsTable, getStreamVersion } from "../index";
18
19
 
19
20
  let testDb: BunTestDb;
20
21
  const tenantId: TenantId = uuid();
@@ -31,17 +32,19 @@ afterAll(async () => {
31
32
  });
32
33
 
33
34
  async function seedStream(aggregateId: string, count: number): Promise<void> {
34
- for (let v = 0; v < count; v++) {
35
- await append(testDb.db, {
36
- aggregateId,
37
- aggregateType: "perfAgg",
38
- tenantId,
39
- expectedVersion: v,
40
- type: "perfAgg.created",
41
- payload: { seq: v },
42
- metadata: { userId },
43
- });
44
- }
35
+ // Bulk-seed 2000 sequential append() calls dominate runtime and flake
36
+ // under load. We measure getStreamVersion(), not append latency.
37
+ const rows = Array.from({ length: count }, (_, i) => ({
38
+ aggregateId,
39
+ aggregateType: "perfAgg",
40
+ tenantId,
41
+ version: i + 1,
42
+ type: "perfAgg.created",
43
+ payload: { seq: i },
44
+ metadata: { userId },
45
+ createdBy: userId,
46
+ }));
47
+ await insertMany(testDb.db, eventsTable, rows);
45
48
  }
46
49
 
47
50
  describe("event-store: getStreamVersion perf on hot streams", () => {
@@ -17,6 +17,7 @@ import {
17
17
  insertRawSubsequentEvent,
18
18
  } from "../db/queries/event-store-admin";
19
19
  import type { TenantId } from "../engine/types";
20
+ import { stringifyJson } from "../utils/safe-json";
20
21
  import { VersionConflictError } from "./errors";
21
22
  import type { EventMetadata } from "./event-store";
22
23
 
@@ -68,8 +69,8 @@ function rawEventParams(event: RawEventToAppend, newVersion: number, eventVersio
68
69
  newVersion,
69
70
  type: event.type,
70
71
  eventVersion,
71
- payloadJson: JSON.stringify(event.payload),
72
- metadataJson: JSON.stringify(event.metadata),
72
+ payloadJson: stringifyJson(event.payload),
73
+ metadataJson: stringifyJson(event.metadata),
73
74
  createdAt: event.createdAt.toString(),
74
75
  createdBy: event.createdBy,
75
76
  };
@@ -129,8 +130,8 @@ export async function appendRawBatch(
129
130
  newVersion,
130
131
  e.type,
131
132
  eventVersion,
132
- JSON.stringify(e.payload),
133
- JSON.stringify(e.metadata),
133
+ stringifyJson(e.payload),
134
+ stringifyJson(e.metadata),
134
135
  e.createdAt.toString(),
135
136
  e.createdBy,
136
137
  );
@@ -9,6 +9,7 @@ import {
9
9
  } from "../db/queries/event-store";
10
10
  import { insertOne, selectMany } from "../db/query";
11
11
  import type { TenantId } from "../engine/types";
12
+ import { stringifyJson } from "../utils/safe-json";
12
13
  import { isStreamArchived } from "./archive";
13
14
  import { VersionConflictError } from "./errors";
14
15
  import { eventsTable } from "./events-schema";
@@ -173,8 +174,8 @@ async function insertSubsequentEvent(
173
174
  newVersion,
174
175
  type: event.type,
175
176
  eventVersion,
176
- payloadJson: JSON.stringify(event.payload),
177
- metadataJson: JSON.stringify(event.metadata),
177
+ payloadJson: stringifyJson(event.payload),
178
+ metadataJson: stringifyJson(event.metadata),
178
179
  createdBy: event.metadata.userId,
179
180
  expectedVersion: event.expectedVersion,
180
181
  });
@@ -17,6 +17,7 @@ import { selectMany } from "../db/query";
17
17
  import { tableExists } from "../db/schema-inspection";
18
18
  import type { TenantId } from "../engine/types";
19
19
  import { unsafePushTables } from "../stack";
20
+ import { stringifyJson } from "../utils/safe-json";
20
21
  import { isStreamArchived } from "./archive";
21
22
  import { loadEventsAfterVersion, type StoredEvent } from "./event-store";
22
23
 
@@ -107,7 +108,7 @@ export async function saveSnapshot(db: DbRunner, args: SaveSnapshotArgs): Promis
107
108
  tenantId: args.tenantId,
108
109
  aggregateType: args.aggregateType,
109
110
  version: args.version,
110
- stateJson: JSON.stringify(args.state),
111
+ stateJson: stringifyJson(args.state),
111
112
  });
112
113
  }
113
114
 
@@ -0,0 +1,161 @@
1
+ // Integration-Test für das drizzle-freie Boot-Gate (detectKumikoDrift /
2
+ // assertKumikoSchemaCurrent). Production-Behavior: dieses Gate blockiert
3
+ // Container-Starts — jeder False-Positive blockt Boot, jeder False-Negative
4
+ // lässt Schema-Drift durch.
5
+
6
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
7
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+ import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
11
+ import { buildEntityTableMeta } from "../../db/entity-table-meta";
12
+ import { generateMigration, writeSnapshotJson } from "../../db/migrate-generator";
13
+ import {
14
+ baselineMigrations,
15
+ loadMigrationsFromDir,
16
+ runMigrationsFromDir,
17
+ } from "../../db/migrate-runner";
18
+ import { asRawClient } from "../../db/query";
19
+ import { createEntity, createTextField } from "../../engine";
20
+ import { ensureTemporalPolyfill } from "../../time/polyfill";
21
+ import { assertKumikoSchemaCurrent, detectKumikoDrift, SchemaDriftError } from "../kumiko-drift";
22
+
23
+ let testDb: BunTestDb;
24
+ let dir: string;
25
+
26
+ beforeAll(async () => {
27
+ await ensureTemporalPolyfill();
28
+ testDb = await createTestDb();
29
+ });
30
+
31
+ afterAll(async () => {
32
+ await testDb.cleanup();
33
+ });
34
+
35
+ beforeEach(async () => {
36
+ dir = mkdtempSync(join(tmpdir(), "kumiko-mig-"));
37
+ // Isoliere: tracking-table + Test-Tabellen pro Test zurücksetzen.
38
+ await asRawClient(testDb.db).unsafe(`DROP TABLE IF EXISTS "_kumiko_migrations"`);
39
+ await asRawClient(testDb.db).unsafe(`DROP TABLE IF EXISTS "kdrift_widget"`);
40
+ await asRawClient(testDb.db).unsafe(`DROP TABLE IF EXISTS "kdrift_gen"`);
41
+ });
42
+
43
+ afterEach(() => {
44
+ rmSync(dir, { recursive: true, force: true });
45
+ });
46
+
47
+ function writeMigration(file: string, sql: string): void {
48
+ writeFileSync(join(dir, file), sql);
49
+ }
50
+
51
+ function writeSnapshot(tableNames: readonly string[]): void {
52
+ const tables = tableNames.map((tableName) => ({ tableName, columns: [] }));
53
+ writeFileSync(join(dir, ".snapshot.json"), JSON.stringify({ version: 1, tables }));
54
+ }
55
+
56
+ describe("kumiko-drift boot-gate", () => {
57
+ test("applied + table exists → ok", async () => {
58
+ writeMigration("0001_init.sql", `CREATE TABLE "kdrift_widget" ("id" text PRIMARY KEY);`);
59
+ writeSnapshot(["kdrift_widget"]);
60
+ await runMigrationsFromDir(testDb.db, dir);
61
+
62
+ const report = await detectKumikoDrift(testDb.db, dir);
63
+ expect(report.ok).toBe(true);
64
+ await expect(assertKumikoSchemaCurrent(testDb.db, dir)).resolves.toBeUndefined();
65
+ });
66
+
67
+ test("checked-in migration not applied → pending drift", async () => {
68
+ writeMigration("0001_init.sql", `CREATE TABLE "kdrift_widget" ("id" text PRIMARY KEY);`);
69
+ writeSnapshot(["kdrift_widget"]);
70
+ // NICHT applyen.
71
+ const report = await detectKumikoDrift(testDb.db, dir);
72
+ expect(report.ok).toBe(false);
73
+ expect(report.pending).toEqual(["0001_init"]);
74
+ await expect(assertKumikoSchemaCurrent(testDb.db, dir)).rejects.toBeInstanceOf(
75
+ SchemaDriftError,
76
+ );
77
+ });
78
+
79
+ test("applied migration edited afterwards → checksum mismatch", async () => {
80
+ writeMigration("0001_init.sql", `CREATE TABLE "kdrift_widget" ("id" text PRIMARY KEY);`);
81
+ writeSnapshot(["kdrift_widget"]);
82
+ await runMigrationsFromDir(testDb.db, dir);
83
+
84
+ // File nachträglich editieren (anderer Inhalt → andere checksum).
85
+ writeMigration(
86
+ "0001_init.sql",
87
+ `CREATE TABLE "kdrift_widget" ("id" text PRIMARY KEY, "x" int);`,
88
+ );
89
+ const report = await detectKumikoDrift(testDb.db, dir);
90
+ expect(report.ok).toBe(false);
91
+ expect(report.checksumMismatches.map((m) => m.id)).toEqual(["0001_init"]);
92
+ });
93
+
94
+ test("snapshot table missing in DB → missingTables", async () => {
95
+ writeMigration("0001_init.sql", `SELECT 1;`); // applied, aber legt die Tabelle NICHT an
96
+ writeSnapshot(["kdrift_widget"]);
97
+ await runMigrationsFromDir(testDb.db, dir);
98
+
99
+ const report = await detectKumikoDrift(testDb.db, dir);
100
+ expect(report.ok).toBe(false);
101
+ expect(report.missingTables).toEqual(["kdrift_widget"]);
102
+ });
103
+
104
+ test("baseline marks migrations applied without running SQL", async () => {
105
+ // Tabelle existiert schon (wie eine adoptierte Prod-DB), Migration NICHT applyen.
106
+ await asRawClient(testDb.db).unsafe(`CREATE TABLE "kdrift_widget" ("id" text PRIMARY KEY)`);
107
+ writeMigration("0001_init.sql", `CREATE TABLE "kdrift_widget" ("id" text PRIMARY KEY);`);
108
+ writeSnapshot(["kdrift_widget"]);
109
+
110
+ const result = await baselineMigrations(testDb.db, loadMigrationsFromDir(dir));
111
+ expect(result.marked).toEqual(["0001_init"]);
112
+
113
+ // Danach drift-frei (applied via baseline, Tabelle existiert), und re-baseline ist no-op.
114
+ const report = await detectKumikoDrift(testDb.db, dir);
115
+ expect(report.ok).toBe(true);
116
+ const again = await baselineMigrations(testDb.db, loadMigrationsFromDir(dir));
117
+ expect(again.marked).toEqual([]);
118
+ expect(again.alreadyTracked).toEqual(["0001_init"]);
119
+ });
120
+ });
121
+
122
+ describe("kumiko-drift end-to-end (generate → apply → gate)", () => {
123
+ test("generate from entity metas → apply → gate ok (the local-verify proof)", async () => {
124
+ const entity = createEntity({
125
+ table: "kdrift_gen",
126
+ fields: { name: createTextField({ required: true }) },
127
+ });
128
+ const meta = buildEntityTableMeta("kdriftGen", entity);
129
+ const result = generateMigration({
130
+ metas: [meta],
131
+ prevSnapshot: null,
132
+ name: "init",
133
+ sequenceNumber: 1,
134
+ });
135
+
136
+ writeFileSync(join(dir, result.filename), result.sqlContent);
137
+ writeSnapshotJson(join(dir, ".snapshot.json"), result.snapshot);
138
+
139
+ await runMigrationsFromDir(testDb.db, dir);
140
+ const report = await detectKumikoDrift(testDb.db, dir);
141
+ expect(report.ok).toBe(true);
142
+ });
143
+
144
+ test("prod adoption via commented-out SQL: apply is a recorded no-op, gate ok when tables pre-exist", async () => {
145
+ // Prod-Szenario: Tabelle existiert schon (drizzle-Ära). Das Migration-File
146
+ // ist auskommentiert → apply legt nichts an, RECORDED aber den Eintrag in
147
+ // _kumiko_migrations. Gate: applied ✓ + Tabelle existiert ✓ → Boot läuft.
148
+ await asRawClient(testDb.db).unsafe(`CREATE TABLE "kdrift_gen" ("id" text PRIMARY KEY)`);
149
+ writeMigration(
150
+ "0001_init.sql",
151
+ `-- CREATE TABLE "kdrift_gen" ("id" text PRIMARY KEY); -- commented for prod adoption`,
152
+ );
153
+ writeSnapshot(["kdrift_gen"]);
154
+
155
+ const applyResult = await runMigrationsFromDir(testDb.db, dir);
156
+ expect(applyResult.applied).toEqual(["0001_init"]); // recorded trotz no-op-SQL
157
+
158
+ const report = await detectKumikoDrift(testDb.db, dir);
159
+ expect(report.ok).toBe(true);
160
+ });
161
+ });
@@ -1,3 +1,14 @@
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
+ export {
5
+ assertKumikoSchemaCurrent,
6
+ type ChecksumMismatch,
7
+ detectKumikoDrift,
8
+ formatKumikoDriftReport,
9
+ type KumikoDriftReport,
10
+ SchemaDriftError,
11
+ } from "./kumiko-drift";
1
12
  export {
2
13
  buildProjectionTableIndex,
3
14
  type ChangedTable,
@@ -22,7 +33,6 @@ export {
22
33
  loadLatestSnapshot,
23
34
  loadPreviousSnapshot,
24
35
  loadSnapshot,
25
- SchemaDriftError,
26
36
  type Snapshot,
27
37
  type SnapshotTable,
28
38
  } from "./schema-drift";
@@ -0,0 +1,122 @@
1
+ // Drizzle-free schema-drift gate for the `kumiko/migrations` system.
2
+ //
3
+ // Replaces the drizzle-journal gate (schema-drift.ts). Validates two layers
4
+ // against the checked-in artifacts:
5
+ //
6
+ // 1. Migrations applied: every `kumiko/migrations/*.sql` has a row in
7
+ // `_kumiko_migrations`. Applied-but-edited (checksum mismatch) is drift.
8
+ // 2. Tables exist: every table in `kumiko/migrations/.snapshot.json` exists.
9
+ //
10
+ // Contract (unchanged from the legacy gate): boot VALIDATES only, never
11
+ // applies. Apply is the deploy-step `kumiko schema apply` (runMigrationsFromDir).
12
+ //
13
+ // Layer 3 (column-diff against the snapshot's ColumnMeta — catches manual
14
+ // ALTERs / stale defs) is a documented follow-up; see
15
+ // docs/plans/migration-system-consolidation.md.
16
+
17
+ import { join } from "node:path";
18
+ import type { DbConnection } from "../db/connection";
19
+ import { loadSnapshotJson } from "../db/migrate-generator";
20
+ import { fetchAppliedMigrations, loadMigrationsFromDir } from "../db/migrate-runner";
21
+ import { tableExists } from "../db/schema-inspection";
22
+
23
+ const SNAPSHOT_FILENAME = ".snapshot.json";
24
+
25
+ export type ChecksumMismatch = {
26
+ readonly id: string;
27
+ readonly expected: string; // checksum recorded in _kumiko_migrations
28
+ readonly actual: string; // checksum of the file on disk now
29
+ };
30
+
31
+ export type KumikoDriftReport = {
32
+ readonly ok: boolean;
33
+ readonly pending: readonly string[];
34
+ readonly checksumMismatches: readonly ChecksumMismatch[];
35
+ readonly missingTables: readonly string[];
36
+ };
37
+
38
+ export class SchemaDriftError extends Error {
39
+ readonly report: KumikoDriftReport;
40
+ constructor(message: string, report: KumikoDriftReport) {
41
+ super(message);
42
+ this.name = "SchemaDriftError";
43
+ this.report = report;
44
+ }
45
+ }
46
+
47
+ export async function detectKumikoDrift(
48
+ db: DbConnection,
49
+ migrationsDir: string,
50
+ ): Promise<KumikoDriftReport> {
51
+ const local = loadMigrationsFromDir(migrationsDir);
52
+ // Frische DB ohne je gelaufenes `kumiko schema apply` → tracking-table fehlt.
53
+ // Das ist kein Fehler, sondern "nichts applied" → alle local sind pending.
54
+ const trackingExists = await tableExists(db, "_kumiko_migrations");
55
+ const applied = trackingExists
56
+ ? new Map((await fetchAppliedMigrations(db)).map((a) => [a.id, a.checksum] as const))
57
+ : new Map<string, string>();
58
+
59
+ const pending: string[] = [];
60
+ const checksumMismatches: ChecksumMismatch[] = [];
61
+ for (const m of local) {
62
+ const appliedChecksum = applied.get(m.id);
63
+ if (appliedChecksum === undefined) {
64
+ pending.push(m.id);
65
+ } else if (appliedChecksum !== m.checksum) {
66
+ checksumMismatches.push({ id: m.id, expected: appliedChecksum, actual: m.checksum });
67
+ }
68
+ }
69
+
70
+ // Layer 2 — tables from the latest snapshot must exist. No snapshot (app
71
+ // hasn't generated one yet) → skip table-existence, the migrations-applied
72
+ // layer still gates.
73
+ const snapshot = loadSnapshotJson(join(migrationsDir, SNAPSHOT_FILENAME));
74
+ const missingTables: string[] = [];
75
+ if (snapshot) {
76
+ const checks = await Promise.all(
77
+ snapshot.tables.map((t) =>
78
+ tableExists(db, t.tableName).then((exists) => ({ name: t.tableName, exists })),
79
+ ),
80
+ );
81
+ for (const c of checks) if (!c.exists) missingTables.push(c.name);
82
+ }
83
+
84
+ return {
85
+ ok: pending.length === 0 && checksumMismatches.length === 0 && missingTables.length === 0,
86
+ pending,
87
+ checksumMismatches,
88
+ missingTables,
89
+ };
90
+ }
91
+
92
+ export function formatKumikoDriftReport(report: KumikoDriftReport): string {
93
+ if (report.ok) return "Schema is current.";
94
+ const lines: string[] = ["Schema drift detected:"];
95
+ if (report.pending.length > 0) {
96
+ lines.push(` ${report.pending.length} unapplied migration(s):`);
97
+ for (const id of report.pending) lines.push(` - ${id}`);
98
+ }
99
+ if (report.checksumMismatches.length > 0) {
100
+ lines.push(` ${report.checksumMismatches.length} edited-after-apply migration(s):`);
101
+ for (const m of report.checksumMismatches) {
102
+ lines.push(` - ${m.id}: db ${m.expected.slice(0, 12)}…, file ${m.actual.slice(0, 12)}…`);
103
+ }
104
+ }
105
+ if (report.missingTables.length > 0) {
106
+ lines.push(` ${report.missingTables.length} missing table(s):`);
107
+ for (const t of report.missingTables) lines.push(` - ${t}`);
108
+ }
109
+ lines.push("");
110
+ lines.push("Run 'kumiko schema apply' to bring the DB up-to-date.");
111
+ return lines.join("\n");
112
+ }
113
+
114
+ /** Throws SchemaDriftError with a human-readable message when the DB is not
115
+ * current with the checked-in kumiko/migrations. */
116
+ export async function assertKumikoSchemaCurrent(
117
+ db: DbConnection,
118
+ migrationsDir: string,
119
+ ): Promise<void> {
120
+ const report = await detectKumikoDrift(db, migrationsDir);
121
+ if (!report.ok) throw new SchemaDriftError(formatKumikoDriftReport(report), report);
122
+ }