@cosmicdrift/kumiko-framework 0.21.1 → 0.22.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.21.1",
3
+ "version": "0.22.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>",
@@ -1,7 +1,7 @@
1
1
  import { qualifyEntityName } from "../qualified-name";
2
2
  import { getAllowedFilterOps, isFieldFilterable } from "../screen-filter-ops";
3
3
  import type { FeatureDefinition, NavDefinition, WorkspaceDefinition } from "../types";
4
- import { normalizeEditField, normalizeListColumn } from "../types/screen";
4
+ import { isExtensionEditSection, normalizeEditField, normalizeListColumn } from "../types/screen";
5
5
 
6
6
  // --- Screen validation ---
7
7
  //
@@ -62,6 +62,7 @@ export function validateScreens(
62
62
  );
63
63
  }
64
64
  for (const section of screen.layout.sections) {
65
+ if (isExtensionEditSection(section)) continue;
65
66
  if (section.fields.length === 0) {
66
67
  throw new Error(
67
68
  `[Feature ${feature.name}] Screen "${screenId}" (configEdit) has a section "${section.title}" ` +
@@ -160,6 +161,7 @@ export function validateScreens(
160
161
  );
161
162
  }
162
163
  for (const section of screen.layout.sections) {
164
+ if (isExtensionEditSection(section)) continue;
163
165
  if (section.fields.length === 0) {
164
166
  throw new Error(
165
167
  `[Feature ${feature.name}] Screen "${screenId}" (actionForm) has a section "${section.title}" ` +
@@ -341,6 +343,15 @@ export function validateScreens(
341
343
  );
342
344
  }
343
345
  for (const section of screen.layout.sections) {
346
+ if (isExtensionEditSection(section)) {
347
+ if (section.component.react === undefined && section.component.native === undefined) {
348
+ throw new Error(
349
+ `[Feature ${feature.name}] Screen "${screenId}" (entityEdit) extension section ` +
350
+ `"${section.title}" has no component — declare a react/native component marker.`,
351
+ );
352
+ }
353
+ continue;
354
+ }
344
355
  if (section.fields.length === 0) {
345
356
  throw new Error(
346
357
  `[Feature ${feature.name}] Screen "${screenId}" (entityEdit) has a section "${section.title}" ` +
@@ -226,7 +226,9 @@ export type {
226
226
  CustomScreenRoute,
227
227
  DateFieldDef,
228
228
  DeleteContext,
229
+ EditExtensionSection,
229
230
  EditFieldSpec,
231
+ EditFieldsSection,
230
232
  EditLayout,
231
233
  EditSectionSpec,
232
234
  EntityDefinition,
@@ -325,7 +327,7 @@ export type {
325
327
  export { DEFAULT_CURRENCIES, HookPhases } from "./types";
326
328
  export { resolveName, withResponseData } from "./types/handlers";
327
329
  export { isSystemTenant, parseTenantId, SYSTEM_TENANT_ID } from "./types/identifiers";
328
- export { normalizeEditField, normalizeListColumn } from "./types/screen";
330
+ export { isExtensionEditSection, normalizeEditField, normalizeListColumn } from "./types/screen";
329
331
  export type {
330
332
  PipelineBuildCtx,
331
333
  PipelineCtx,
@@ -204,7 +204,9 @@ export type {
204
204
  ConfigEditScreenDefinition,
205
205
  CustomScreenDefinition,
206
206
  CustomScreenRoute,
207
+ EditExtensionSection,
207
208
  EditFieldSpec,
209
+ EditFieldsSection,
208
210
  EditLayout,
209
211
  EditSectionSpec,
210
212
  EntityEditScreenDefinition,
@@ -222,7 +224,7 @@ export type {
222
224
  ScreenSlots,
223
225
  ToolbarAction,
224
226
  } from "./screen";
225
- export { normalizeEditField, normalizeListColumn } from "./screen";
227
+ export { isExtensionEditSection, normalizeEditField, normalizeListColumn } from "./screen";
226
228
  export type { TargetRef } from "./target-ref";
227
229
  export type {
228
230
  Subscribe,
@@ -280,12 +280,31 @@ export type EditFieldSpec =
280
280
  readonly renderer?: FieldRenderer;
281
281
  };
282
282
 
283
- export type EditSectionSpec = {
283
+ // A section is either a normal field-grid (default — `kind` omitted keeps
284
+ // every existing screen-def working) or an extension slot that mounts a
285
+ // feature-provided component. The extension component is resolved client-
286
+ // side by name (same `__component` marker as custom screens / column
287
+ // renderers) and receives the host entity name + id, so a bundled feature
288
+ // (e.g. custom-fields) can load and persist its own data inside the form.
289
+ export type EditSectionSpec = EditFieldsSection | EditExtensionSection;
290
+
291
+ export type EditFieldsSection = {
292
+ readonly kind?: "fields";
284
293
  readonly title: string;
285
294
  readonly columns?: number;
286
295
  readonly fields: readonly EditFieldSpec[];
287
296
  };
288
297
 
298
+ export type EditExtensionSection = {
299
+ readonly kind: "extension";
300
+ readonly title: string;
301
+ readonly component: PlatformComponent;
302
+ };
303
+
304
+ export function isExtensionEditSection(section: EditSectionSpec): section is EditExtensionSection {
305
+ return section.kind === "extension";
306
+ }
307
+
289
308
  export type EditLayout = {
290
309
  readonly sections: readonly EditSectionSpec[];
291
310
  };
@@ -37,6 +37,7 @@ import {
37
37
  unsafeCreateEntityTable,
38
38
  } from "../../stack";
39
39
  import { buildMultipartBody, patchFileInstanceofForBunTest } from "../../testing";
40
+ import { createFilesFeature } from "../feature";
40
41
  import { createLocalProvider } from "../local-provider";
41
42
 
42
43
  // Covers ALL four file-field variants: singular (file/image) stores a UUID in
@@ -73,7 +74,7 @@ beforeAll(async () => {
73
74
  patchFileInstanceofForBunTest();
74
75
  storagePath = await mkdtemp(join(tmpdir(), "kumiko-file-field-pipeline-"));
75
76
  stack = await setupTestStack({
76
- features: [documentFeature],
77
+ features: [createFilesFeature(), documentFeature],
77
78
  files: { storageProvider: createLocalProvider(storagePath) },
78
79
  });
79
80
  await unsafeCreateEntityTable(stack.db, documentEntity);
@@ -28,7 +28,7 @@ import {
28
28
  patchFileInstanceofForBunTest,
29
29
  } from "../../testing";
30
30
  import { fileRefsTable } from "../file-ref-table";
31
- import { FILE_UPLOADED_EVENT_TYPE, type FileRoutesOptions } from "../file-routes";
31
+ import type { FileRoutesOptions } from "../file-routes";
32
32
  import { createInMemoryFileProvider } from "../in-memory-provider";
33
33
  import { createLocalProvider } from "../local-provider";
34
34
  import type { FileStorageProvider } from "../types";
@@ -248,18 +248,17 @@ describe("file upload flow via API", () => {
248
248
  expect(downloaded[0]).toBe(0x89); // PNG magic byte
249
249
  });
250
250
 
251
- test("upload appends files:event:uploaded to the fileRef stream", async () => {
252
- // Load the full event stream for the just-uploaded FileRef. Phase 1
253
- // guarantees exactly one event per upload "uploaded" at version 1.
251
+ test("upload emits fileRef.created to the entity stream", async () => {
252
+ // fileRef is a standard ES entity the upload goes through the entity
253
+ // executor, so the stream carries exactly one `fileRef.created` at v1.
254
254
  const events = await loadAggregate(testDb.db, uploadedFileId, adminUser.tenantId);
255
255
 
256
256
  expect(events).toHaveLength(1);
257
257
  const event = events[0];
258
- expect(event?.type).toBe(FILE_UPLOADED_EVENT_TYPE);
258
+ expect(event?.type).toBe("fileRef.created");
259
259
  expect(event?.version).toBe(1);
260
260
 
261
- type UploadedPayload = {
262
- fileRefId: string;
261
+ type CreatedPayload = {
263
262
  fileName: string;
264
263
  mimeType: string;
265
264
  size: number;
@@ -269,8 +268,10 @@ describe("file upload flow via API", () => {
269
268
  data?: unknown;
270
269
  binary?: unknown;
271
270
  };
272
- const payload = event!.payload as UploadedPayload;
273
- expect(payload.fileRefId).toBe(uploadedFileId);
271
+ // The entity executor strips `id` from the event payload (it's the
272
+ // aggregateId / stream id, which we loaded by above) and threads
273
+ // insertedById through metadata — neither appears in payload.
274
+ const payload = event!.payload as CreatedPayload;
274
275
  expect(payload.fileName).toBe("logo.png");
275
276
  expect(payload.mimeType).toBe("image/png");
276
277
  expect(payload.size).toBe(testPngContent.length);
@@ -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
  },
@@ -30,6 +30,7 @@ import {
30
30
  type EntityEditScreenDefinition,
31
31
  type EntityListScreenDefinition,
32
32
  type FieldDefinition,
33
+ isExtensionEditSection,
33
34
  normalizeEditField,
34
35
  normalizeListColumn,
35
36
  parseQn,
@@ -234,6 +235,7 @@ function collectRequiredEditFields(
234
235
  ): string[] {
235
236
  const out: string[] = [];
236
237
  for (const section of screen.layout.sections) {
238
+ if (isExtensionEditSection(section)) continue;
237
239
  for (const rawField of section.fields) {
238
240
  const { field } = normalizeEditField(rawField);
239
241
  const def = entity.fields[field];
@@ -271,6 +273,7 @@ function buildEditFillOps(
271
273
  // form; Felder ohne Fixture-Wert (file/image/…) werden übersprungen.
272
274
  const ops: EditFillOp[] = [];
273
275
  for (const section of screen.layout.sections) {
276
+ if (isExtensionEditSection(section)) continue;
274
277
  for (const raw of section.fields) {
275
278
  const { field } = normalizeEditField(raw);
276
279
  const def = entity.fields[field];
@@ -317,6 +320,7 @@ function pickIdentifyingForEdit(
317
320
  : undefined;
318
321
 
319
322
  for (const section of editScreen.layout.sections) {
323
+ if (isExtensionEditSection(section)) continue;
320
324
  for (const rawField of section.fields) {
321
325
  const { field } = normalizeEditField(rawField);
322
326
  if (entity.fields[field]?.type !== "text") continue;
@@ -46,7 +46,9 @@ export type {
46
46
  ConfigEditScreenDefinition,
47
47
  CustomScreenDefinition,
48
48
  CustomScreenRoute,
49
+ EditExtensionSection,
49
50
  EditFieldSpec,
51
+ EditFieldsSection,
50
52
  EditLayout,
51
53
  EditSectionSpec,
52
54
  EntityEditScreenDefinition,
@@ -64,6 +66,10 @@ export type {
64
66
  ScreenSlots,
65
67
  ToolbarAction,
66
68
  } from "../engine/types/screen";
67
- export { normalizeEditField, normalizeListColumn } from "../engine/types/screen";
69
+ export {
70
+ isExtensionEditSection,
71
+ normalizeEditField,
72
+ normalizeListColumn,
73
+ } from "../engine/types/screen";
68
74
  export type { WorkspaceDefinition } from "../engine/types/workspace";
69
75
  export type { AppSchema, FeatureSchema, WorkspaceSchema } from "./app-schema";