@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.
- package/package.json +1 -1
- package/src/__tests__/schema-cli-status.integration.test.ts +95 -0
- package/src/__tests__/schema-cli.integration.test.ts +233 -0
- package/src/db/__tests__/rebuild-marker.test.ts +49 -1
- package/src/db/__tests__/sql-inventory.test.ts +1 -1
- package/src/db/rebuild-marker.ts +11 -5
- package/src/db/schema-inspection.ts +14 -1
- package/src/engine/boot-validator/screens-nav.ts +12 -1
- package/src/engine/index.ts +3 -1
- package/src/engine/types/index.ts +3 -1
- package/src/engine/types/screen.ts +20 -1
- package/src/files/__tests__/file-field-pipeline.integration.test.ts +2 -1
- package/src/files/__tests__/files.integration.test.ts +10 -9
- package/src/files/__tests__/storage-tracking.integration.test.ts +2 -1
- package/src/files/feature.ts +21 -0
- package/src/files/file-ref-entity.ts +30 -0
- package/src/files/file-ref-table.ts +12 -21
- package/src/files/file-routes.ts +47 -81
- package/src/files/index.ts +3 -7
- package/src/files/storage-tracking.ts +36 -6
- package/src/migrations/__tests__/kumiko-drift.integration.test.ts +33 -0
- package/src/migrations/__tests__/kumiko-drift.report.test.ts +92 -0
- package/src/migrations/kumiko-drift.ts +27 -1
- package/src/schema-cli.ts +14 -12
- package/src/seeding/__tests__/entity-seed.test.ts +14 -3
- package/src/seeding/entity-seed.ts +12 -8
- package/src/testing/e2e-generator.ts +4 -0
- package/src/ui-types/index.ts +7 -1
|
@@ -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
|
-
|
|
2
|
-
import {
|
|
1
|
+
import { buildEntityTable } from "../db/table-builder";
|
|
2
|
+
import { fileRefEntity } from "./file-ref-entity";
|
|
3
3
|
|
|
4
|
-
// `
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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);
|
package/src/files/file-routes.ts
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
import {
|
|
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
|
|
6
|
-
import
|
|
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
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
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
|
-
//
|
|
173
|
-
//
|
|
174
|
-
//
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
//
|
|
269
|
-
//
|
|
270
|
-
//
|
|
271
|
-
//
|
|
272
|
-
//
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
await
|
|
276
|
-
|
|
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 cleanup — same 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
|
-
|
|
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
|
|
package/src/files/index.ts
CHANGED
|
@@ -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
|
|
16
|
-
|
|
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
|
-
[
|
|
36
|
-
const
|
|
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:
|
|
46
|
-
{ totalBytes:
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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
|
-
//
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
}
|