@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.23.1",
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<void> {
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, createTimestampField } from "../engine";
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
- // insertedById, insertedAt) treffen die PII-Heuristik nicht.
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
  });
@@ -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
- return c.json({ error: "not_found" }, 404);
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` carries `{ previous }` (the pre-delete
21
- // row), so the byte count to reverse lives at previous.size.
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
- // 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;
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
  });