@cosmicdrift/kumiko-framework 0.21.1 → 0.23.1

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.
@@ -14,6 +14,7 @@ import type { SessionUser } from "../../engine";
14
14
  import { createTestUser, setupTestStack, type TestStack, TestUsers } from "../../stack";
15
15
  import { buildMultipartBody, patchFileInstanceofForBunTest } from "../../testing";
16
16
  import {
17
+ createFilesFeature,
17
18
  createInMemoryFileProvider,
18
19
  filesStorageTrackingFeature,
19
20
  type InMemoryFileProvider,
@@ -40,7 +41,7 @@ beforeAll(async () => {
40
41
  patchFileInstanceofForBunTest();
41
42
  provider = createInMemoryFileProvider();
42
43
  stack = await setupTestStack({
43
- features: [filesStorageTrackingFeature],
44
+ features: [createFilesFeature(), filesStorageTrackingFeature],
44
45
  files: { storageProvider: provider },
45
46
  });
46
47
  });
@@ -0,0 +1,21 @@
1
+ import { defineFeature, type FeatureDefinition } from "../engine";
2
+ import { fileRefEntity } from "./file-ref-entity";
3
+
4
+ export { fileRefEntity } from "./file-ref-entity";
5
+
6
+ // files — `fileRef` als ganz normales ES-Entity. Upload/Delete laufen über
7
+ // den Standard-Entity-Executor (file-routes.ts: executor.create/delete →
8
+ // `fileRef.created`/`fileRef.deleted` → applyEntityEvent materialisiert
9
+ // `file_refs`). Soft-Delete/Anonymize/Restore/Retention kommen damit
10
+ // generisch aus dem Entity-Lifecycle + data-retention — keine
11
+ // file-spezifische Lösch-Logik.
12
+ //
13
+ // `r.entity` macht die Tabelle für PII-Boot-Validation +
14
+ // userData/tenantData-Extensions sichtbar und registriert die implizite
15
+ // Entity-Projektion (für rebuildProjection). Liegt im Framework neben
16
+ // file-routes + fileRefsTable; bundled-features/files re-exportiert nur.
17
+ export function createFilesFeature(): FeatureDefinition {
18
+ return defineFeature("files", (r) => {
19
+ r.entity("fileRef", fileRefEntity);
20
+ });
21
+ }
@@ -0,0 +1,30 @@
1
+ import { createEntity, createNumberField, createTextField, createTimestampField } from "../engine";
2
+
3
+ // fileRef — das File-Metadata-Entity. Ganz normales ES-Entity: Upload/Delete
4
+ // laufen über den Standard-Executor (file-routes.ts), die Tabelle `file_refs`
5
+ // wird via buildEntityTable aus dieser Definition gebaut.
6
+ //
7
+ // softDelete: true — wie das `user`-Entity + die data-retention-Strategien.
8
+ // Ein Delete markiert `isDeleted=true` (wiederherstellbar, kein "sofort weg");
9
+ // echtes Erasure (Art. 17) läuft über den Forget-Hook + Retention-Cleanup.
10
+ //
11
+ // PII-Annotations:
12
+ // - fileName → pii: true (Originalname enthält oft Personen-Bezug:
13
+ // "Marc-Lebenslauf.pdf", "Krankheitsattest-Mai.pdf"). Andere Felder
14
+ // (storageKey, mimeType, size, entityType, entityId, fieldName,
15
+ // insertedById, insertedAt) treffen die PII-Heuristik nicht.
16
+ export const fileRefEntity = createEntity({
17
+ table: "file_refs",
18
+ softDelete: true,
19
+ fields: {
20
+ storageKey: createTextField({ required: true }),
21
+ fileName: createTextField({ required: true, pii: true }),
22
+ mimeType: createTextField({ required: true }),
23
+ size: createNumberField({ required: true }),
24
+ entityType: createTextField(),
25
+ entityId: createTextField(),
26
+ fieldName: createTextField(),
27
+ insertedAt: createTimestampField({ sortable: true, filterable: true }),
28
+ insertedById: createTextField(),
29
+ },
30
+ });
@@ -1,22 +1,13 @@
1
- // sql now comes from native dialect
2
- import { instant, integer, table as pgTable, sql, text, uuid } from "../db/dialect";
1
+ import { buildEntityTable } from "../db/table-builder";
2
+ import { fileRefEntity } from "./file-ref-entity";
3
3
 
4
- // `id` is a UUID (not serial): it doubles as the aggregate-id for the
5
- // `fileRef` event stream every upload appends exactly one
6
- // `files:event:uploaded` event keyed by this id. UUIDs also close the
7
- // enumeration-attack vector on /files/:id URLs.
8
- export const fileRefsTable = pgTable("file_refs", {
9
- id: uuid("id").primaryKey(),
10
- tenantId: uuid("tenant_id").notNull(),
11
- storageKey: text("storage_key").notNull(),
12
- fileName: text("file_name").notNull(),
13
- mimeType: text("mime_type").notNull(),
14
- size: integer("size").notNull(),
15
- entityType: text("entity_type"),
16
- // entityId references any entity (mostly UUID-keyed under ES). Text keeps
17
- // the column backward-compat with older integer-keyed entities too.
18
- entityId: text("entity_id"),
19
- fieldName: text("field_name"),
20
- insertedAt: instant("inserted_at").default(sql`now()`).notNull(),
21
- insertedById: text("inserted_by_id"),
22
- });
4
+ // `file_refs` ist die read-table des `fileRef`-Entity. Aus der Entity-
5
+ // Definition gebaut (kein hand-gepflegtes pgTable mehr), damit die implizite
6
+ // Entity-Projektion (Rebuild) und der Live-Executor-Write spaltengleich auf
7
+ // dieselbe Tabelle schreiben Single Source, keine Dual-Definition-Drift.
8
+ //
9
+ // `id` ist UUID und doppelt als Aggregate-Id des fileRef-Event-Streams:
10
+ // jeder Upload appended `fileRef.created`, jeder Delete `fileRef.deleted`
11
+ // (Standard-Entity-Auto-Verben). UUIDs schließen die Enumeration-Attacke
12
+ // auf /files/:id.
13
+ export const fileRefsTable = buildEntityTable("fileRef", fileRefEntity);
@@ -1,13 +1,13 @@
1
- import { deleteMany, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
1
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
2
  import { Hono } from "hono";
3
- import { z } from "zod";
4
3
  import { getUser } from "../api/auth-middleware";
5
- import type { DbConnection, DbTx } from "../db/connection";
6
- import type { EventDef } from "../engine/types";
4
+ import type { DbConnection } from "../db/connection";
5
+ import { createEventStoreExecutor } from "../db/event-store-executor";
6
+ import { createTenantDb } from "../db/tenant-db";
7
7
  import { isFileField, type Registry, type SessionUser, type TenantId } from "../engine/types";
8
- import { append as appendEvent } from "../event-store/event-store";
9
8
  import { generateId } from "../utils";
10
9
  import { buildContentDispositionHeader } from "./content-disposition";
10
+ import { fileRefEntity } from "./file-ref-entity";
11
11
  import { fileRefsTable } from "./file-ref-table";
12
12
  import type { FileStorageProvider } from "./types";
13
13
  import { buildStorageKey, validateFile } from "./types";
@@ -29,38 +29,11 @@ export type FileRef = {
29
29
  insertedById: string | null;
30
30
  };
31
31
 
32
- // Event emitted after a successful upload. Downstream hooks / MSPs subscribe
33
- // on `fileUploadedEvent.name` via an r.multiStreamProjection apply map. The
34
- // payload carries metadata + the storage key — never the binary itself.
35
- // Consumers that need the bytes call `ctx.files.ref(payload.storageKey).read()`.
36
- //
37
- // Packaged as a framework-owned EventDef so consumers can narrow the raw
38
- // StoredEvent.payload via `typedPayload(event, fileUploadedEvent)` instead
39
- // of hand-casting. Schema version starts at 1; bump in lockstep with an
40
- // r.eventMigration when the shape breaks.
41
- export const fileUploadedPayloadSchema = z.object({
42
- fileRefId: z.uuid(),
43
- storageKey: z.string().min(1),
44
- fileName: z.string().min(1),
45
- mimeType: z.string().min(1),
46
- size: z.number().int().nonnegative(),
47
- entityType: z.string().nullable(),
48
- entityId: z.string().nullable(),
49
- fieldName: z.string().nullable(),
50
- insertedById: z.string().min(1),
51
- });
52
-
53
- export type FileUploadedPayload = z.infer<typeof fileUploadedPayloadSchema>;
54
-
55
- export const fileUploadedEvent: EventDef<FileUploadedPayload> = {
56
- name: "files:event:uploaded",
57
- schema: fileUploadedPayloadSchema,
58
- version: 1,
59
- };
60
-
61
- // Convenience re-export so apps that only reach for the event name (e.g. as
62
- // an apply-map key) don't have to dereference `.name` everywhere.
63
- export const FILE_UPLOADED_EVENT_TYPE = fileUploadedEvent.name;
32
+ // fileRef is a standard ES entity: upload/delete go through the entity
33
+ // executor (executor.create/delete below), which emits `fileRef.created` /
34
+ // `fileRef.deleted` and materialises file_refs via applyEntityEvent in one
35
+ // tx. Downstream MSPs (e.g. storage-tracking) subscribe on those entity
36
+ // event types — there is no bespoke files:event:* anymore.
64
37
 
65
38
  // Checks whether `user` may read/delete the given file. The default guard
66
39
  // (ownerOrPrivilegedGuard) approves uploaders + any role in privilegedRoles.
@@ -110,6 +83,13 @@ export function createFileRoutes(options: FileRoutesOptions): Hono {
110
83
  const { db, storageProvider } = options;
111
84
  const privilegedRoles = options.privilegedRoles ?? DEFAULT_PRIVILEGED_ROLES;
112
85
  const guard: FileAccessGuard = options.accessGuard ?? createDefaultGuard(privilegedRoles);
86
+ // Standard entity executor for fileRef — self-contained (table + entity),
87
+ // no registry needed. create/delete emit fileRef.created/deleted and write
88
+ // file_refs via applyEntityEvent in one tx (read-your-own-write), exactly
89
+ // like any other entity's lifecycle.
90
+ const executor = createEventStoreExecutor(fileRefsTable, fileRefEntity, {
91
+ entityName: "fileRef",
92
+ });
113
93
  const api = new Hono();
114
94
 
115
95
  // POST /files — multipart upload.
@@ -169,26 +149,16 @@ export function createFileRoutes(options: FileRoutesOptions): Hono {
169
149
  const data = new Uint8Array(await file.arrayBuffer());
170
150
  await storageProvider.write(storageKey, data, file.type);
171
151
 
172
- // Atomic: insert FileRef + append files:event:uploaded in one tx. Either
173
- // both land or neither no dangling FileRef without event, no event
174
- // referencing a row that doesn't exist.
175
- await db.begin(async (tx: DbTx) => {
176
- const { insertOne } = await import("../bun-db/query");
177
- await insertOne(tx, fileRefsTable, {
152
+ // Create via the standard entity executor: emits fileRef.created +
153
+ // materialises the file_refs row in one tx (read-your-own-write). id is
154
+ // explicit so the response + storageKey stay consistent; tenantId,
155
+ // insertedAt and insertedById are set by applyEntityEvent from the event
156
+ // metadata. The binary was written above, outside the tx — a create
157
+ // failure orphans bytes (cleanup-job sweeps) rather than committing a row
158
+ // whose binary is missing.
159
+ const result = await executor.create(
160
+ {
178
161
  id: fileRefId,
179
- tenantId: user.tenantId,
180
- storageKey,
181
- fileName: file.name,
182
- mimeType: file.type,
183
- size: file.size,
184
- entityType: entityType ?? null,
185
- entityId: entityId ?? null,
186
- fieldName: fieldName ?? null,
187
- insertedById: user.id,
188
- });
189
-
190
- const payload: FileUploadedPayload = {
191
- fileRefId,
192
162
  storageKey,
193
163
  fileName: file.name,
194
164
  mimeType: file.type,
@@ -196,21 +166,13 @@ export function createFileRoutes(options: FileRoutesOptions): Hono {
196
166
  entityType: entityType ?? null,
197
167
  entityId: entityId ?? null,
198
168
  fieldName: fieldName ?? null,
199
- insertedById: user.id,
200
- };
201
- await appendEvent(tx, {
202
- aggregateId: fileRefId,
203
- aggregateType: "fileRef",
204
- tenantId: user.tenantId,
205
- expectedVersion: 0,
206
- type: fileUploadedEvent.name,
207
- // EventToAppend wants a Record — payload is typed through the
208
- // EventDef so the cast collapses to a single boundary, not a
209
- // double-`as unknown as` at the call site.
210
- payload: payload as Record<string, unknown>, // @cast-boundary engine-payload
211
- metadata: { userId: user.id },
212
- });
213
- });
169
+ },
170
+ user,
171
+ createTenantDb(db, user.tenantId),
172
+ );
173
+ if (!result.isSuccess) {
174
+ return c.json({ error: "upload_failed" }, 500);
175
+ }
214
176
 
215
177
  return c.json(
216
178
  {
@@ -265,15 +227,17 @@ export function createFileRoutes(options: FileRoutesOptions): Hono {
265
227
  const decision = await guard({ fileRef, user, operation: "delete" });
266
228
  if (decision === "deny") return c.json({ error: "not_found" }, 404);
267
229
 
268
- // Tombstone-Reihenfolge: DB-Row löschen FIRST, Bytes danach. Wenn der
269
- // Storage-Call hängt oder fehlschlägt, sieht der User trotzdem den
270
- // konsistenten "weg"-Zustand (kein Read findet die Row mehr) und der
271
- // Cleanup-Job sweept die orphan'd bytes wie beim fehlgeschlagenen
272
- // Upload. Umgekehrt liesse ein storage-success + db-fail eine Row mit
273
- // permanent-broken Reference zurück aus Sicht der API "Datei
274
- // existiert" aber jeder Read 404t aus dem Provider.
275
- await deleteMany(db, fileRefsTable, { id: id });
276
- await storageProvider.delete(fileRef.storageKey);
230
+ // Delete via the standard entity executor: emits fileRef.deleted and
231
+ // applies it in one tx. fileRef is softDelete → the row is flagged
232
+ // isDeleted=true (reads filter it out) and stays recoverable; the binary
233
+ // is intentionally KEPT so a restore can bring the file back. Hard
234
+ // erasure of row + binary is the job of the forget-flow (Art. 17) and the
235
+ // generic data-retention cleanupsame lifecycle as any soft-delete
236
+ // entity, not a files-specific path.
237
+ const result = await executor.delete({ id }, user, createTenantDb(db, user.tenantId));
238
+ if (!result.isSuccess) {
239
+ return c.json({ error: "not_found" }, 404);
240
+ }
277
241
  return c.json({ ok: true });
278
242
  });
279
243
 
@@ -343,7 +307,9 @@ export function createFileRoutes(options: FileRoutesOptions): Hono {
343
307
  });
344
308
 
345
309
  async function loadFileForTenant(id: string, tenantId: TenantId): Promise<FileRef | null> {
346
- const [row] = await selectMany(db, fileRefsTable, { id, tenantId });
310
+ // isDeleted:false soft-deleted (trashed) rows stay recoverable but must
311
+ // never surface to reads/guards.
312
+ const [row] = await selectMany(db, fileRefsTable, { id, tenantId, isDeleted: false });
347
313
  return (row as FileRef | undefined) ?? null; // @cast-boundary db-row
348
314
  }
349
315
 
@@ -1,21 +1,17 @@
1
+ export { createFilesFeature } from "./feature";
1
2
  export type { FileContext, FileHandle } from "./file-handle";
2
3
  // `createFileHandle` is an implementation detail — construct handles via
3
4
  // `createFileContext(provider).ref(key)`, which is the AppContext surface.
4
5
  export { createFileContext, deriveKey } from "./file-handle";
6
+ export { fileRefEntity } from "./file-ref-entity";
5
7
  export { fileRefsTable } from "./file-ref-table";
6
8
  export type {
7
9
  FileAccessDecision,
8
10
  FileAccessGuard,
9
11
  FileRef,
10
12
  FileRoutesOptions,
11
- FileUploadedPayload,
12
- } from "./file-routes";
13
- export {
14
- createFileRoutes,
15
- FILE_UPLOADED_EVENT_TYPE,
16
- fileUploadedEvent,
17
- fileUploadedPayloadSchema,
18
13
  } from "./file-routes";
14
+ export { createFileRoutes } from "./file-routes";
19
15
  export type { InMemoryFileProvider } from "./in-memory-provider";
20
16
  export { createInMemoryFileProvider } from "./in-memory-provider";
21
17
  export { createLocalProvider } from "./local-provider";
@@ -10,10 +10,21 @@
10
10
  // consumer-cursor row. Apps that want it pass filesStorageTrackingFeature
11
11
  // into createApp / setupTestStack alongside their domain features.
12
12
 
13
+ import { entityEventName } from "../db";
13
14
  import { bigint, instant, integer, table as pgTable, sql, uuid } from "../db/dialect";
14
15
  import { incrementCounter } from "../db/query";
15
- import { defineFeature, typedPayload } from "../engine";
16
- import { fileUploadedEvent } from "./file-routes";
16
+ import { defineFeature } from "../engine";
17
+
18
+ // fileRef is a standard ES entity, so usage tracking subscribes to its
19
+ // auto-verb events. `fileRef.created` payload carries the entity fields
20
+ // (incl. size); `fileRef.deleted` carries `{ previous }` (the pre-delete
21
+ // row), so the byte count to reverse lives at previous.size.
22
+ const FILE_REF_CREATED = entityEventName("fileRef", "created");
23
+ const FILE_REF_DELETED = entityEventName("fileRef", "deleted");
24
+
25
+ function readNumber(value: unknown): number {
26
+ return typeof value === "number" ? value : 0;
27
+ }
17
28
 
18
29
  // bigint in `mode: "number"` returns a JS number (safe up to 2^53 ≈ 9e15
19
30
  // bytes ≈ 8 petabytes per tenant — large enough for any practical storage
@@ -32,8 +43,8 @@ export const filesStorageTrackingFeature = defineFeature("files-storage-tracking
32
43
  name: "tenant-storage-usage",
33
44
  table: tenantStorageUsageTable,
34
45
  apply: {
35
- [fileUploadedEvent.name]: async (event, tx) => {
36
- const payload = typedPayload(event, fileUploadedEvent);
46
+ [FILE_REF_CREATED]: async (event, tx) => {
47
+ const size = readNumber(event.payload["size"]);
37
48
 
38
49
  // UPSERT: INSERT on first upload per tenant, otherwise atomic increment.
39
50
  // The SQL increment guarantees correctness under concurrent dispatcher
@@ -42,8 +53,27 @@ export const filesStorageTrackingFeature = defineFeature("files-storage-tracking
42
53
  await incrementCounter(
43
54
  tx,
44
55
  tenantStorageUsageTable,
45
- { tenantId: event.tenantId, totalBytes: payload.size, fileCount: 1 },
46
- { totalBytes: payload.size, fileCount: 1 },
56
+ { tenantId: event.tenantId, totalBytes: size, fileCount: 1 },
57
+ { totalBytes: size, fileCount: 1 },
58
+ { set: { lastUpdatedAt: sql`now()` } },
59
+ );
60
+ },
61
+ [FILE_REF_DELETED]: async (event, tx) => {
62
+ // Delete events carry the pre-delete row under `previous`.
63
+ const previous = event.payload["previous"];
64
+ const size =
65
+ previous && typeof previous === "object" && !Array.isArray(previous)
66
+ ? readNumber((previous as Record<string, unknown>)["size"]) // @cast-boundary engine-payload
67
+ : 0;
68
+ // Decrement on delete. INSERT values are 0/0 so a delete that somehow
69
+ // precedes any upload can't create negative usage; the on-conflict
70
+ // path applies the real negative delta. Async (dispatcher) —
71
+ // eventually-consistent with the write-tx that emitted fileRef.deleted.
72
+ await incrementCounter(
73
+ tx,
74
+ tenantStorageUsageTable,
75
+ { tenantId: event.tenantId, totalBytes: 0, fileCount: 0 },
76
+ { totalBytes: -size, fileCount: -1 },
47
77
  { set: { lastUpdatedAt: sql`now()` } },
48
78
  );
49
79
  },
@@ -16,6 +16,7 @@ import {
16
16
  runMigrationsFromDir,
17
17
  } from "../../db/migrate-runner";
18
18
  import { asRawClient } from "../../db/query";
19
+ import { tableExists } from "../../db/schema-inspection";
19
20
  import { createEntity, createTextField } from "../../engine";
20
21
  import { ensureTemporalPolyfill } from "../../time/polyfill";
21
22
  import { assertKumikoSchemaCurrent, detectKumikoDrift, SchemaDriftError } from "../kumiko-drift";
@@ -38,6 +39,7 @@ beforeEach(async () => {
38
39
  await asRawClient(testDb.db).unsafe(`DROP TABLE IF EXISTS "_kumiko_migrations"`);
39
40
  await asRawClient(testDb.db).unsafe(`DROP TABLE IF EXISTS "kdrift_widget"`);
40
41
  await asRawClient(testDb.db).unsafe(`DROP TABLE IF EXISTS "kdrift_gen"`);
42
+ await asRawClient(testDb.db).unsafe(`DROP TABLE IF EXISTS "kdriftMixed"`);
41
43
  });
42
44
 
43
45
  afterEach(() => {
@@ -101,6 +103,37 @@ describe("kumiko-drift boot-gate", () => {
101
103
  expect(report.missingTables).toEqual(["kdrift_widget"]);
102
104
  });
103
105
 
106
+ test("missing migrations dir → ok (no migrations to validate)", async () => {
107
+ // Regression für review #155 finding 1: existing-App-Upgrade ohne
108
+ // ./kumiko/migrations darf nicht roh ENOENT werfen (würde sonst plain
109
+ // Error statt SchemaDriftError → kein Remediation-Hint im Boot-Log).
110
+ rmSync(dir, { recursive: true, force: true });
111
+ const report = await detectKumikoDrift(testDb.db, dir);
112
+ expect(report.ok).toBe(true);
113
+ expect(report.pending).toEqual([]);
114
+ await expect(assertKumikoSchemaCurrent(testDb.db, dir)).resolves.toBeUndefined();
115
+ });
116
+
117
+ test("mixed-case snapshot tableName resolves via quote_ident round-trip", async () => {
118
+ // Regression für review #155 finding 3: postgres folded unquoted
119
+ // identifier case-insensitiv (myWidget → mywidget) in to_regclass, während
120
+ // die DDL via quoteIdent("kdriftMixed") → "kdriftMixed" case-preserved
121
+ // schreibt. tableExists muss quote_ident-Round-trip machen, sonst False-
122
+ // positive-Drift bei jeder mixed-case Entity-Tabelle.
123
+ await asRawClient(testDb.db).unsafe(`CREATE TABLE "kdriftMixed" ("id" text PRIMARY KEY)`);
124
+ expect(await tableExists(testDb.db, "kdriftMixed")).toBe(true);
125
+ // Lowercase-Variante DARF nicht matchen — Round-trip preserved case.
126
+ expect(await tableExists(testDb.db, "kdriftmixed")).toBe(false);
127
+
128
+ writeMigration("0001_init.sql", `CREATE TABLE "kdriftMixed" ("id" text PRIMARY KEY);`);
129
+ writeSnapshot(["kdriftMixed"]);
130
+ await asRawClient(testDb.db).unsafe(`DROP TABLE "kdriftMixed"`);
131
+ await runMigrationsFromDir(testDb.db, dir);
132
+ const report = await detectKumikoDrift(testDb.db, dir);
133
+ expect(report.missingTables).toEqual([]);
134
+ expect(report.ok).toBe(true);
135
+ });
136
+
104
137
  test("baseline marks migrations applied without running SQL", async () => {
105
138
  // Tabelle existiert schon (wie eine adoptierte Prod-DB), Migration NICHT applyen.
106
139
  await asRawClient(testDb.db).unsafe(`CREATE TABLE "kdrift_widget" ("id" text PRIMARY KEY)`);
@@ -0,0 +1,92 @@
1
+ // Unit-Tests für formatKumikoDriftReport — per-cause Remediation.
2
+ // Regression für review #155 finding 2: vor dem Fix hat der Report immer den
3
+ // "kumiko schema apply"-Hint gezeigt, auch wenn das Problem ein checksum-
4
+ // mismatch war (den apply NICHT löst). Operator folgte der Anweisung und
5
+ // landete in einer Sackgasse.
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+ import { formatKumikoDriftReport, type KumikoDriftReport } from "../kumiko-drift";
9
+
10
+ const empty: KumikoDriftReport = {
11
+ ok: true,
12
+ pending: [],
13
+ checksumMismatches: [],
14
+ missingTables: [],
15
+ };
16
+
17
+ describe("formatKumikoDriftReport", () => {
18
+ test("ok report → short success line", () => {
19
+ expect(formatKumikoDriftReport(empty)).toBe("Schema is current.");
20
+ });
21
+
22
+ test("pending only → recommends 'schema apply'", () => {
23
+ const report: KumikoDriftReport = {
24
+ ...empty,
25
+ ok: false,
26
+ pending: ["0001_init", "0002_add_locale"],
27
+ };
28
+ const out = formatKumikoDriftReport(report);
29
+ expect(out).toContain("2 unapplied migration(s)");
30
+ expect(out).toContain("0001_init");
31
+ expect(out).toContain("Run 'kumiko schema apply'");
32
+ expect(out).not.toContain("checksum");
33
+ expect(out).not.toContain("dropped after apply");
34
+ });
35
+
36
+ test("checksum mismatch only → suggests revert/hand-correct, NOT 'schema apply'", () => {
37
+ const report: KumikoDriftReport = {
38
+ ...empty,
39
+ ok: false,
40
+ checksumMismatches: [
41
+ { id: "0001_init", expected: "abcdef0123456789", actual: "fedcba9876543210" },
42
+ ],
43
+ };
44
+ const out = formatKumikoDriftReport(report);
45
+ expect(out).toContain("1 edited-after-apply");
46
+ expect(out).toContain("Revert the edited migration");
47
+ expect(out).toContain("cannot resolve a");
48
+ expect(out).toContain("checksum mismatch");
49
+ expect(out).not.toContain("Run 'kumiko schema apply'");
50
+ });
51
+
52
+ test("missing tables without pending → suggests backup/regen, NOT 'schema apply'", () => {
53
+ const report: KumikoDriftReport = {
54
+ ...empty,
55
+ ok: false,
56
+ missingTables: ["widget"],
57
+ };
58
+ const out = formatKumikoDriftReport(report);
59
+ expect(out).toContain("1 missing table(s)");
60
+ expect(out).toContain("dropped after apply");
61
+ expect(out).toContain("Restore from backup");
62
+ expect(out).not.toContain("Run 'kumiko schema apply'");
63
+ });
64
+
65
+ test("missing tables WITH pending → only the 'schema apply' hint (pending covers it)", () => {
66
+ // Wenn pending da ist, applied das die noch fehlenden Tabellen → kein
67
+ // Backup-Restore nötig. Verhindert verwirrenden Doppel-Hint.
68
+ const report: KumikoDriftReport = {
69
+ ...empty,
70
+ ok: false,
71
+ pending: ["0002_add_widget"],
72
+ missingTables: ["widget"],
73
+ };
74
+ const out = formatKumikoDriftReport(report);
75
+ expect(out).toContain("Run 'kumiko schema apply'");
76
+ expect(out).not.toContain("Restore from backup");
77
+ });
78
+
79
+ test("pending + mismatch combined → both remediation lines", () => {
80
+ const report: KumikoDriftReport = {
81
+ ...empty,
82
+ ok: false,
83
+ pending: ["0002_add_locale"],
84
+ checksumMismatches: [
85
+ { id: "0001_init", expected: "abcdef0123456789", actual: "fedcba9876543210" },
86
+ ],
87
+ };
88
+ const out = formatKumikoDriftReport(report);
89
+ expect(out).toContain("Run 'kumiko schema apply'");
90
+ expect(out).toContain("Revert the edited migration");
91
+ });
92
+ });
@@ -14,6 +14,7 @@
14
14
  // ALTERs / stale defs) is a documented follow-up; see
15
15
  // docs/plans/migration-system-consolidation.md.
16
16
 
17
+ import { existsSync } from "node:fs";
17
18
  import { join } from "node:path";
18
19
  import type { DbConnection } from "../db/connection";
19
20
  import { loadSnapshotJson } from "../db/migrate-generator";
@@ -48,6 +49,15 @@ export async function detectKumikoDrift(
48
49
  db: DbConnection,
49
50
  migrationsDir: string,
50
51
  ): Promise<KumikoDriftReport> {
52
+ // App auf neuer Framework-Version, hat aber `./kumiko/migrations` noch nicht
53
+ // generiert (z.B. erstes Upgrade, custom Schema-Setup ohne kumiko schema
54
+ // generate) → keine local migrations → keine drift. Konsistent zum
55
+ // status-Subcommand, der ebenfalls existsSync-guarded ist. Ohne diesen Guard
56
+ // würde loadMigrationsFromDir → readdirSync synchron ENOENT werfen
57
+ // (plain Error, kein SchemaDriftError) und der Boot crasht roh.
58
+ if (!existsSync(migrationsDir)) {
59
+ return { ok: true, pending: [], checksumMismatches: [], missingTables: [] };
60
+ }
51
61
  const local = loadMigrationsFromDir(migrationsDir);
52
62
  // Frische DB ohne je gelaufenes `kumiko schema apply` → tracking-table fehlt.
53
63
  // Das ist kein Fehler, sondern "nichts applied" → alle local sind pending.
@@ -106,8 +116,24 @@ export function formatKumikoDriftReport(report: KumikoDriftReport): string {
106
116
  lines.push(` ${report.missingTables.length} missing table(s):`);
107
117
  for (const t of report.missingTables) lines.push(` - ${t}`);
108
118
  }
119
+ // Per-Cause Remediation — `kumiko schema apply` löst NUR pending. Checksum-
120
+ // mismatch ist eine Sackgasse für apply (MigrationChecksumMismatchError) und
121
+ // baseline (ON CONFLICT DO NOTHING → landet in alreadyTracked). Missing
122
+ // tables ohne pending = manuell gelöschte Tabelle nach apply → ebenfalls
123
+ // nicht durch apply heilbar.
109
124
  lines.push("");
110
- lines.push("Run 'kumiko schema apply' to bring the DB up-to-date.");
125
+ if (report.pending.length > 0) {
126
+ lines.push("Run 'kumiko schema apply' to apply the pending migration(s).");
127
+ }
128
+ if (report.checksumMismatches.length > 0) {
129
+ lines.push("Revert the edited migration file(s) to their applied content, or hand-correct");
130
+ lines.push("the checksum in _kumiko_migrations — 'kumiko schema apply' cannot resolve a");
131
+ lines.push("checksum mismatch.");
132
+ }
133
+ if (report.missingTables.length > 0 && report.pending.length === 0) {
134
+ lines.push("Missing table(s) without pending migration(s) — table was dropped after apply.");
135
+ lines.push("Restore from backup, or generate a new migration that re-creates the table.");
136
+ }
111
137
  return lines.join("\n");
112
138
  }
113
139
 
package/src/schema-cli.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  rebuildTablesFromDiff,
21
21
  type renderTablesDdl,
22
22
  runMigrationsFromDir,
23
+ tableExists,
23
24
  writeRebuildMarker,
24
25
  writeSnapshotJson,
25
26
  } from "./db";
@@ -75,7 +76,7 @@ export async function runSchemaCli(
75
76
  case "generate": {
76
77
  const name = argv[1];
77
78
  if (!name) {
78
- out.err(" Usage: kumiko-schema generate <name>");
79
+ out.err(" Usage: schema generate <name>");
79
80
  return 1;
80
81
  }
81
82
  if (!existsSync(schemaFile)) {
@@ -124,7 +125,7 @@ export async function runSchemaCli(
124
125
  );
125
126
  }
126
127
  out.log("");
127
- out.log(" Review + ggf. hand-edit + git add + commit. Apply via: kumiko-schema apply");
128
+ out.log(" Review + ggf. hand-edit + git add + commit. Apply via: schema apply");
128
129
  out.log("");
129
130
  return 0;
130
131
  }
@@ -136,7 +137,7 @@ export async function runSchemaCli(
136
137
  return 1;
137
138
  }
138
139
  if (!existsSync(migrationsDir)) {
139
- out.err(` ${migrationsDir} fehlt — erst kumiko-schema generate <name>.`);
140
+ out.err(` ${migrationsDir} fehlt — erst schema generate <name>.`);
140
141
  return 1;
141
142
  }
142
143
  const { db, close } = createDbConnection(dbUrl);
@@ -172,7 +173,7 @@ export async function runSchemaCli(
172
173
  return 1;
173
174
  }
174
175
  if (!existsSync(migrationsDir)) {
175
- out.err(` ${migrationsDir} fehlt — erst kumiko-schema generate <name>.`);
176
+ out.err(` ${migrationsDir} fehlt — erst schema generate <name>.`);
176
177
  return 1;
177
178
  }
178
179
  const { db, close } = createDbConnection(dbUrl);
@@ -207,13 +208,14 @@ export async function runSchemaCli(
207
208
  const local = loadMigrationsFromDir(migrationsDir);
208
209
  const { db, close } = createDbConnection(dbUrl);
209
210
  try {
210
- // fetchAppliedMigrations wirft wenn die tracking-table noch nicht da ist.
211
- let applied: Set<string>;
212
- try {
213
- applied = new Set((await fetchAppliedMigrations(db)).map((a) => a.id));
214
- } catch {
215
- applied = new Set();
216
- }
211
+ // Frische DB ohne je gelaufenes `kumiko schema apply` → tracking-table
212
+ // fehlt = "nichts applied". Connection-/Permission-Fehler dagegen
213
+ // sollen NICHT geschluckt werden (False-pending verschleiert das Problem) —
214
+ // tableExists prüft gezielt nur Existenz, alles andere propagiert.
215
+ const trackingExists = await tableExists(db, "_kumiko_migrations");
216
+ const applied = trackingExists
217
+ ? new Set((await fetchAppliedMigrations(db)).map((a) => a.id))
218
+ : new Set<string>();
217
219
  out.log("");
218
220
  out.log(` ${local.length} migrations in ${migrationsDir}:`);
219
221
  for (const m of local) out.log(` ${applied.has(m.id) ? "✓" : " "} ${m.id}`);
@@ -221,7 +223,7 @@ export async function runSchemaCli(
221
223
  out.log("");
222
224
  out.log(` ${applied.size} applied, ${pending} pending.`);
223
225
  out.log("");
224
- return 0;
226
+ return pending === 0 ? 0 : 1;
225
227
  } finally {
226
228
  await close();
227
229
  }