@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 +1 -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/testing/e2e-generator.ts +4 -0
- package/src/ui-types/index.ts +7 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
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}" ` +
|
package/src/engine/index.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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
|
|
252
|
-
//
|
|
253
|
-
//
|
|
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(
|
|
258
|
+
expect(event?.type).toBe("fileRef.created");
|
|
259
259
|
expect(event?.version).toBe(1);
|
|
260
260
|
|
|
261
|
-
type
|
|
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
|
-
|
|
273
|
-
|
|
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
|
-
|
|
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
|
},
|
|
@@ -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;
|
package/src/ui-types/index.ts
CHANGED
|
@@ -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 {
|
|
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";
|