@cosmicdrift/kumiko-framework 0.23.1 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -9,13 +9,17 @@
|
|
|
9
9
|
// Drizzle's mode:"number", so arithmetic in assertions Just Works).
|
|
10
10
|
|
|
11
11
|
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
12
|
+
import { createEventStoreExecutor } from "../../db/event-store-executor";
|
|
12
13
|
import { asRawClient, selectMany } from "../../db/query";
|
|
14
|
+
import { createTenantDb } from "../../db/tenant-db";
|
|
13
15
|
import type { SessionUser } from "../../engine";
|
|
14
16
|
import { createTestUser, setupTestStack, type TestStack, TestUsers } from "../../stack";
|
|
15
17
|
import { buildMultipartBody, patchFileInstanceofForBunTest } from "../../testing";
|
|
16
18
|
import {
|
|
17
19
|
createFilesFeature,
|
|
18
20
|
createInMemoryFileProvider,
|
|
21
|
+
fileRefEntity,
|
|
22
|
+
fileRefsTable,
|
|
19
23
|
filesStorageTrackingFeature,
|
|
20
24
|
type InMemoryFileProvider,
|
|
21
25
|
tenantStorageUsageTable,
|
|
@@ -63,7 +67,7 @@ beforeEach(async () => {
|
|
|
63
67
|
await stack.eventDispatcher?.ensureRegistered();
|
|
64
68
|
});
|
|
65
69
|
|
|
66
|
-
async function upload(user: SessionUser, name: string, content: Uint8Array): Promise<
|
|
70
|
+
async function upload(user: SessionUser, name: string, content: Uint8Array): Promise<string> {
|
|
67
71
|
const token = await stack.jwt.sign(user);
|
|
68
72
|
const formData = new FormData();
|
|
69
73
|
formData.append("file", new File([Buffer.from(content)], name, { type: "image/png" }));
|
|
@@ -74,6 +78,28 @@ async function upload(user: SessionUser, name: string, content: Uint8Array): Pro
|
|
|
74
78
|
body: multipartBody,
|
|
75
79
|
});
|
|
76
80
|
expect(res.status).toBe(201);
|
|
81
|
+
const body = (await res.json()) as { id: string };
|
|
82
|
+
return body.id;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function deleteFile(user: SessionUser, id: string): Promise<void> {
|
|
86
|
+
const token = await stack.jwt.sign(user);
|
|
87
|
+
const res = await stack.app.request(`/api/files/${id}`, {
|
|
88
|
+
method: "DELETE",
|
|
89
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
90
|
+
});
|
|
91
|
+
expect(res.status).toBe(200);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function restoreFile(user: SessionUser, id: string): Promise<void> {
|
|
95
|
+
// No HTTP route for restore — drive the entity executor directly, which is
|
|
96
|
+
// the same path file-routes uses for create/delete. Emits fileRef.restored
|
|
97
|
+
// with { previous } that the MSP re-increments on.
|
|
98
|
+
const executor = createEventStoreExecutor(fileRefsTable, fileRefEntity, {
|
|
99
|
+
entityName: "fileRef",
|
|
100
|
+
});
|
|
101
|
+
const result = await executor.restore({ id }, user, createTenantDb(stack.db, user.tenantId));
|
|
102
|
+
if (!result.isSuccess) throw new Error(`restore failed: ${JSON.stringify(result)}`);
|
|
77
103
|
}
|
|
78
104
|
|
|
79
105
|
async function usageFor(tenantId: string): Promise<{ totalBytes: number; fileCount: number }> {
|
|
@@ -121,6 +147,32 @@ describe("tenant-storage-usage MSP", () => {
|
|
|
121
147
|
expect(otherUsage).toEqual({ totalBytes: LARGE.length, fileCount: 1 });
|
|
122
148
|
});
|
|
123
149
|
|
|
150
|
+
test("delete decrements totalBytes and fileCount by the deleted file's size", async () => {
|
|
151
|
+
const idSmall = await upload(admin, "a.png", SMALL);
|
|
152
|
+
await upload(admin, "b.png", LARGE);
|
|
153
|
+
await stack.eventDispatcher?.runOnce();
|
|
154
|
+
|
|
155
|
+
await deleteFile(admin, idSmall);
|
|
156
|
+
await stack.eventDispatcher?.runOnce();
|
|
157
|
+
|
|
158
|
+
const usage = await usageFor(admin.tenantId);
|
|
159
|
+
expect(usage).toEqual({ totalBytes: LARGE.length, fileCount: 1 });
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test("restore re-increments after delete — round-trip leaves usage unchanged", async () => {
|
|
163
|
+
const id = await upload(admin, "a.png", SMALL);
|
|
164
|
+
await stack.eventDispatcher?.runOnce();
|
|
165
|
+
expect(await usageFor(admin.tenantId)).toEqual({ totalBytes: SMALL.length, fileCount: 1 });
|
|
166
|
+
|
|
167
|
+
await deleteFile(admin, id);
|
|
168
|
+
await stack.eventDispatcher?.runOnce();
|
|
169
|
+
expect(await usageFor(admin.tenantId)).toEqual({ totalBytes: 0, fileCount: 0 });
|
|
170
|
+
|
|
171
|
+
await restoreFile(admin, id);
|
|
172
|
+
await stack.eventDispatcher?.runOnce();
|
|
173
|
+
expect(await usageFor(admin.tenantId)).toEqual({ totalBytes: SMALL.length, fileCount: 1 });
|
|
174
|
+
});
|
|
175
|
+
|
|
124
176
|
test("lastUpdatedAt is set and advances on subsequent uploads", async () => {
|
|
125
177
|
await upload(admin, "a.png", SMALL);
|
|
126
178
|
await stack.eventDispatcher?.runOnce();
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { createEntity, createNumberField, createTextField
|
|
1
|
+
import { createEntity, createNumberField, createTextField } from "../engine";
|
|
2
2
|
|
|
3
3
|
// fileRef — das File-Metadata-Entity. Ganz normales ES-Entity: Upload/Delete
|
|
4
4
|
// laufen über den Standard-Executor (file-routes.ts), die Tabelle `file_refs`
|
|
@@ -8,11 +8,16 @@ import { createEntity, createNumberField, createTextField, createTimestampField
|
|
|
8
8
|
// Ein Delete markiert `isDeleted=true` (wiederherstellbar, kein "sofort weg");
|
|
9
9
|
// echtes Erasure (Art. 17) läuft über den Forget-Hook + Retention-Cleanup.
|
|
10
10
|
//
|
|
11
|
+
// `insertedAt`/`insertedById` sind framework-managed base columns (siehe
|
|
12
|
+
// buildBaseColumns in table-builder.ts) und dürfen NICHT als Entity-Fields
|
|
13
|
+
// dupliziert werden — fieldColumns gewinnen beim Merge, und die Field-Variante
|
|
14
|
+
// ohne `.default(now()).notNull()` macht inserted_at still nullable.
|
|
15
|
+
//
|
|
11
16
|
// PII-Annotations:
|
|
12
17
|
// - fileName → pii: true (Originalname enthält oft Personen-Bezug:
|
|
13
18
|
// "Marc-Lebenslauf.pdf", "Krankheitsattest-Mai.pdf"). Andere Felder
|
|
14
|
-
// (storageKey, mimeType, size, entityType, entityId, fieldName
|
|
15
|
-
//
|
|
19
|
+
// (storageKey, mimeType, size, entityType, entityId, fieldName) treffen
|
|
20
|
+
// die PII-Heuristik nicht.
|
|
16
21
|
export const fileRefEntity = createEntity({
|
|
17
22
|
table: "file_refs",
|
|
18
23
|
softDelete: true,
|
|
@@ -24,7 +29,5 @@ export const fileRefEntity = createEntity({
|
|
|
24
29
|
entityType: createTextField(),
|
|
25
30
|
entityId: createTextField(),
|
|
26
31
|
fieldName: createTextField(),
|
|
27
|
-
insertedAt: createTimestampField({ sortable: true, filterable: true }),
|
|
28
|
-
insertedById: createTextField(),
|
|
29
32
|
},
|
|
30
33
|
});
|
package/src/files/file-routes.ts
CHANGED
|
@@ -236,7 +236,14 @@ export function createFileRoutes(options: FileRoutesOptions): Hono {
|
|
|
236
236
|
// entity, not a files-specific path.
|
|
237
237
|
const result = await executor.delete({ id }, user, createTenantDb(db, user.tenantId));
|
|
238
238
|
if (!result.isSuccess) {
|
|
239
|
-
|
|
239
|
+
// NotFound (race between guard and executor) reuses the 404-masking
|
|
240
|
+
// pattern from the access-deny path above. Everything else (version
|
|
241
|
+
// conflict 409, ownership 403, validation 422, internal 500) gets the
|
|
242
|
+
// real status off the error so callers can distinguish recoverable
|
|
243
|
+
// from terminal — pauschales 404 hier hat genau das verschleiert.
|
|
244
|
+
const status = result.error.httpStatus as 400 | 403 | 404 | 409 | 422 | 500;
|
|
245
|
+
const code = status === 404 ? "not_found" : result.error.code;
|
|
246
|
+
return c.json({ error: code }, status);
|
|
240
247
|
}
|
|
241
248
|
return c.json({ ok: true });
|
|
242
249
|
});
|
|
@@ -17,15 +17,24 @@ import { defineFeature } from "../engine";
|
|
|
17
17
|
|
|
18
18
|
// fileRef is a standard ES entity, so usage tracking subscribes to its
|
|
19
19
|
// auto-verb events. `fileRef.created` payload carries the entity fields
|
|
20
|
-
// (incl. size); `fileRef.deleted`
|
|
21
|
-
// row), so the byte count to
|
|
20
|
+
// (incl. size); `fileRef.deleted` / `fileRef.restored` carry `{ previous }`
|
|
21
|
+
// (the pre-event row), so the byte count to apply lives at previous.size.
|
|
22
22
|
const FILE_REF_CREATED = entityEventName("fileRef", "created");
|
|
23
23
|
const FILE_REF_DELETED = entityEventName("fileRef", "deleted");
|
|
24
|
+
const FILE_REF_RESTORED = entityEventName("fileRef", "restored");
|
|
24
25
|
|
|
25
26
|
function readNumber(value: unknown): number {
|
|
26
27
|
return typeof value === "number" ? value : 0;
|
|
27
28
|
}
|
|
28
29
|
|
|
30
|
+
function sizeFromPrevious(payload: Record<string, unknown>): number {
|
|
31
|
+
const previous = payload["previous"];
|
|
32
|
+
if (previous && typeof previous === "object" && !Array.isArray(previous)) {
|
|
33
|
+
return readNumber((previous as Record<string, unknown>)["size"]); // @cast-boundary engine-payload
|
|
34
|
+
}
|
|
35
|
+
return 0;
|
|
36
|
+
}
|
|
37
|
+
|
|
29
38
|
// bigint in `mode: "number"` returns a JS number (safe up to 2^53 ≈ 9e15
|
|
30
39
|
// bytes ≈ 8 petabytes per tenant — large enough for any practical storage
|
|
31
40
|
// quota). Default "bigint" mode would hand back a bigint value, which
|
|
@@ -59,12 +68,7 @@ export const filesStorageTrackingFeature = defineFeature("files-storage-tracking
|
|
|
59
68
|
);
|
|
60
69
|
},
|
|
61
70
|
[FILE_REF_DELETED]: async (event, tx) => {
|
|
62
|
-
|
|
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;
|
|
71
|
+
const size = sizeFromPrevious(event.payload);
|
|
68
72
|
// Decrement on delete. INSERT values are 0/0 so a delete that somehow
|
|
69
73
|
// precedes any upload can't create negative usage; the on-conflict
|
|
70
74
|
// path applies the real negative delta. Async (dispatcher) —
|
|
@@ -77,6 +81,19 @@ export const filesStorageTrackingFeature = defineFeature("files-storage-tracking
|
|
|
77
81
|
{ set: { lastUpdatedAt: sql`now()` } },
|
|
78
82
|
);
|
|
79
83
|
},
|
|
84
|
+
[FILE_REF_RESTORED]: async (event, tx) => {
|
|
85
|
+
// Restore re-increments by the soft-deleted row's size — symmetric
|
|
86
|
+
// to delete. Without this handler totalBytes/fileCount drift low
|
|
87
|
+
// after every delete→restore round-trip.
|
|
88
|
+
const size = sizeFromPrevious(event.payload);
|
|
89
|
+
await incrementCounter(
|
|
90
|
+
tx,
|
|
91
|
+
tenantStorageUsageTable,
|
|
92
|
+
{ tenantId: event.tenantId, totalBytes: size, fileCount: 1 },
|
|
93
|
+
{ totalBytes: size, fileCount: 1 },
|
|
94
|
+
{ set: { lastUpdatedAt: sql`now()` } },
|
|
95
|
+
);
|
|
96
|
+
},
|
|
80
97
|
},
|
|
81
98
|
});
|
|
82
99
|
});
|