@cosmicdrift/kumiko-bundled-features 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 +2 -1
- package/src/custom-fields/__tests__/custom-fields.integration.test.ts +58 -0
- package/src/custom-fields/constants.ts +23 -0
- package/src/custom-fields/handlers/set-custom-field.write.ts +8 -4
- package/src/custom-fields/lib/value-schema.ts +51 -17
- package/src/custom-fields/web/__tests__/custom-fields-form-section.test.tsx +144 -0
- package/src/custom-fields/web/client-plugin.tsx +25 -0
- package/src/custom-fields/web/custom-fields-form-section.tsx +181 -0
- package/src/custom-fields/web/index.ts +8 -0
- package/src/files/__tests__/files.integration.test.ts +3 -23
- package/src/files/feature.ts +7 -34
- package/src/files-provider-s3/__tests__/s3-provider.integration.test.ts +27 -0
- package/src/files-provider-s3/s3-provider.ts +8 -13
- package/src/user-data-rights/__tests__/cross-data-matrix.integration.test.ts +4 -15
- package/src/user-data-rights/__tests__/file-retention.integration.test.ts +231 -0
- package/src/user-data-rights/__tests__/run-forget-cleanup.integration.test.ts +4 -15
- package/src/user-data-rights/__tests__/run-user-export.integration.test.ts +4 -15
- package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts +6 -18
- package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +3 -0
- package/src/files/schema/file-ref.ts +0 -58
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.0",
|
|
4
4
|
"description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"./tier-engine": "./src/tier-engine/index.ts",
|
|
27
27
|
"./cap-counter": "./src/cap-counter/index.ts",
|
|
28
28
|
"./custom-fields": "./src/custom-fields/index.ts",
|
|
29
|
+
"./custom-fields/web": "./src/custom-fields/web/index.ts",
|
|
29
30
|
"./billing-foundation": "./src/billing-foundation/index.ts",
|
|
30
31
|
"./subscription-stripe": "./src/subscription-stripe/index.ts",
|
|
31
32
|
"./subscription-mollie": "./src/subscription-mollie/index.ts",
|
|
@@ -335,6 +335,64 @@ describe("custom-fields integration — value validation (Builder-Reuse)", () =>
|
|
|
335
335
|
expect(await countSetEvents(id)).toBe(0);
|
|
336
336
|
});
|
|
337
337
|
|
|
338
|
+
test("required-text accepts empty + over-maxLength strings (constraint-keys stripped)", async () => {
|
|
339
|
+
const id = "bbbbbbbb-bbbb-4000-8000-00000000000b";
|
|
340
|
+
// serializedField carries required + maxLength + format — value-schema
|
|
341
|
+
// strips these before fieldToZod, so the runtime schema collapses to a
|
|
342
|
+
// bare z.string(). Required-on-set + length/format-enforcement remain
|
|
343
|
+
// out-of-scope (Plan-Doc "Stammfeld-Identität").
|
|
344
|
+
await stack.http.writeOk(
|
|
345
|
+
"custom-fields:write:define-tenant-field",
|
|
346
|
+
{
|
|
347
|
+
entityName: "property",
|
|
348
|
+
fieldKey: "note",
|
|
349
|
+
serializedField: { type: "text", required: true, maxLength: 5, format: "email" },
|
|
350
|
+
required: false,
|
|
351
|
+
searchable: false,
|
|
352
|
+
displayOrder: 0,
|
|
353
|
+
},
|
|
354
|
+
admin,
|
|
355
|
+
);
|
|
356
|
+
await createProperty(id, "TypeOnly");
|
|
357
|
+
|
|
358
|
+
await setCustomField("property", id, "note", "");
|
|
359
|
+
await setCustomField("property", id, "note", "not-an-email-and-way-too-long");
|
|
360
|
+
await stack.eventDispatcher?.runOnce();
|
|
361
|
+
|
|
362
|
+
expect(await rawCustomFields(id)).toMatchObject({ note: "not-an-email-and-way-too-long" });
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
test("default-having field validates as plain type (default-key stripped)", async () => {
|
|
366
|
+
const id = "cccccccc-cccc-4000-8000-00000000000c";
|
|
367
|
+
// Pre-fix: fieldToZod folded `default` into `.default(...)`. Combined with
|
|
368
|
+
// emitting `payload.value` (not `parsed.data`) the in-code path would skip
|
|
369
|
+
// the type-check for a missing value. value-schema now strips `default`
|
|
370
|
+
// before fieldToZod so the runtime schema is bare `z.number()` — matching
|
|
371
|
+
// values still pass, type-mismatches still 422.
|
|
372
|
+
await stack.http.writeOk(
|
|
373
|
+
"custom-fields:write:define-tenant-field",
|
|
374
|
+
{
|
|
375
|
+
entityName: "property",
|
|
376
|
+
fieldKey: "score",
|
|
377
|
+
serializedField: { type: "number", default: 0 },
|
|
378
|
+
required: false,
|
|
379
|
+
searchable: false,
|
|
380
|
+
displayOrder: 0,
|
|
381
|
+
},
|
|
382
|
+
admin,
|
|
383
|
+
);
|
|
384
|
+
await createProperty(id, "DefaultStripped");
|
|
385
|
+
|
|
386
|
+
const err = await setErr(id, "score", "not-a-number");
|
|
387
|
+
expect(err.httpStatus).toBe(422);
|
|
388
|
+
expect(err.details).toMatchObject({ reason: "custom_field_value_invalid" });
|
|
389
|
+
expect(await countSetEvents(id)).toBe(0);
|
|
390
|
+
|
|
391
|
+
await setCustomField("property", id, "score", 7);
|
|
392
|
+
await stack.eventDispatcher?.runOnce();
|
|
393
|
+
expect(await rawCustomFields(id)).toMatchObject({ score: 7 });
|
|
394
|
+
});
|
|
395
|
+
|
|
338
396
|
test("embedded field rejects a non-object value → 422, no event", async () => {
|
|
339
397
|
const id = "aaaaaaaa-aaaa-4000-8000-00000000000a";
|
|
340
398
|
// embedded carries a sub-field schema in serializedField — exercises
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
// @runtime client
|
|
1
2
|
// custom-fields bundle constants — feature-name + event-names.
|
|
2
3
|
//
|
|
3
4
|
// Spec: kumiko-platform/docs/plans/features/custom-fields.md
|
|
@@ -5,6 +6,28 @@
|
|
|
5
6
|
|
|
6
7
|
export const CUSTOM_FIELDS_FEATURE_NAME = "custom-fields";
|
|
7
8
|
|
|
9
|
+
// Qualified handler names (QN format: scope:type:name). Mirror text-
|
|
10
|
+
// content's Handler/Queries object pattern — Clients (z.B. die
|
|
11
|
+
// CustomFieldsFormSection web-component) referenzieren über das Object
|
|
12
|
+
// statt magic-strings.
|
|
13
|
+
export const CustomFieldsHandlers = {
|
|
14
|
+
defineTenantField: "custom-fields:write:define-tenant-field",
|
|
15
|
+
defineSystemField: "custom-fields:write:define-system-field",
|
|
16
|
+
deleteTenantField: "custom-fields:write:delete-tenant-field",
|
|
17
|
+
deleteSystemField: "custom-fields:write:delete-system-field",
|
|
18
|
+
setCustomField: "custom-fields:write:set-custom-field",
|
|
19
|
+
clearCustomField: "custom-fields:write:clear-custom-field",
|
|
20
|
+
} as const;
|
|
21
|
+
|
|
22
|
+
export const CustomFieldsQueries = {
|
|
23
|
+
fieldDefinitionList: "custom-fields:query:field-definition:list",
|
|
24
|
+
} as const;
|
|
25
|
+
|
|
26
|
+
// Name unter dem die web-component im ExtensionSectionsProvider
|
|
27
|
+
// registriert wird — Apps referenzieren ihn im Screen-Schema via
|
|
28
|
+
// `component: { react: { __component: CUSTOM_FIELDS_FORM_EXTENSION_NAME } }`.
|
|
29
|
+
export const CUSTOM_FIELDS_FORM_EXTENSION_NAME = "CustomFieldsFormSection";
|
|
30
|
+
|
|
8
31
|
// Event-Type-Names (qualified at registration via r.defineEvent — final
|
|
9
32
|
// names are `custom-fields:event:field-definition-created` etc.).
|
|
10
33
|
// Short-names MUST be in kebab-case (no dots): qualifyEntityName runs toKebab
|
|
@@ -32,11 +32,15 @@ export type SetCustomFieldPayload = z.infer<typeof setCustomFieldPayloadSchema>;
|
|
|
32
32
|
// 3. Value-Validation (Builder-Reuse): der Wert wird gegen das aus
|
|
33
33
|
// serializedField rehydrierte fieldToZod-Schema geparst. Type-Mismatch →
|
|
34
34
|
// 422, KEIN Event entsteht (Projection bleibt typed — Plan-Doc
|
|
35
|
-
// Stammfeld-Identität). `value: null`
|
|
36
|
-
// Type-
|
|
35
|
+
// Stammfeld-Identität). `value: null` ODER `value: undefined` auf einem
|
|
36
|
+
// typisierten Feld sind Type-Mismatches → 422; zum Entfernen eines Werts
|
|
37
|
+
// dient clear-custom-field.
|
|
37
38
|
//
|
|
38
|
-
// Scope:
|
|
39
|
-
//
|
|
39
|
+
// Scope: ECHTE type-only Validation. value-schema.ts strippt `required`,
|
|
40
|
+
// `maxLength`, `format` und `default` aus dem serializedField bevor fieldToZod
|
|
41
|
+
// daraus ein Schema baut — wir prüfen nur die Type-Shape. Required-on-set,
|
|
42
|
+
// Default-Application und Length-/Format-Enforcement bleiben out-of-scope
|
|
43
|
+
// (Plan-Doc "Stammfeld-Identität" listet sie als eigene Zeilen).
|
|
40
44
|
export const setCustomFieldHandler: WriteHandlerDef = {
|
|
41
45
|
name: "set-custom-field",
|
|
42
46
|
schema: setCustomFieldPayloadSchema,
|
|
@@ -4,29 +4,67 @@ import {
|
|
|
4
4
|
fieldToZod,
|
|
5
5
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
6
6
|
import type { z } from "zod";
|
|
7
|
+
import { SUPPORTED_FIELD_TYPES } from "../constants";
|
|
7
8
|
|
|
8
9
|
// Builds a Zod schema that validates a custom-field VALUE against its
|
|
9
10
|
// fieldDefinition. Reuses the framework's `fieldToZod` (Builder-Reuse /
|
|
10
11
|
// Stammfeld-Identität, Plan-Doc) — one field-type-schema source, no drift
|
|
11
12
|
// between Stammfeld- and Custom-Field-validation.
|
|
12
13
|
//
|
|
14
|
+
// Scope: type-shape only. fieldToZod also folds `required`, `maxLength`,
|
|
15
|
+
// `format`, and `default` into Zod refinements; we strip those before the
|
|
16
|
+
// call so set-custom-field rejects type-mismatches and only type-mismatches.
|
|
17
|
+
// Required-on-set, default-application, and length/format-enforcement remain
|
|
18
|
+
// out-of-scope per the Plan-Doc ("Stammfeld-Identität" lists them as
|
|
19
|
+
// separate concerns).
|
|
20
|
+
//
|
|
13
21
|
// Vocabulary bridge: custom-fields expose `enum` (Plan + `r.field.enum([...])`)
|
|
14
22
|
// where the framework's FieldDefinition calls the equivalent type `select`
|
|
15
|
-
// with an `options` array.
|
|
16
|
-
// else is already FieldDefinition-shaped (the serialized dehydrated builder).
|
|
23
|
+
// with an `options` array.
|
|
17
24
|
//
|
|
18
25
|
// Returns `null` when the serialized field is unparseable or names a type
|
|
19
|
-
//
|
|
20
|
-
|
|
26
|
+
// outside the custom-fields-supported set — callers then skip value-validation.
|
|
27
|
+
|
|
28
|
+
const SUPPORTED_TYPES_SET: ReadonlySet<string> = new Set(SUPPORTED_FIELD_TYPES);
|
|
29
|
+
const SUPPORTED_EMBEDDED_SUB_TYPES: ReadonlySet<string> = new Set([
|
|
30
|
+
"text",
|
|
31
|
+
"number",
|
|
32
|
+
"boolean",
|
|
33
|
+
"date",
|
|
34
|
+
]);
|
|
35
|
+
|
|
36
|
+
// Constraint-keys fieldToZod converts into Zod refinements. Stripped so the
|
|
37
|
+
// resulting schema validates the type-shape only, not the constraint.
|
|
38
|
+
const CONSTRAINT_KEYS = ["required", "maxLength", "format", "default"] as const;
|
|
39
|
+
|
|
21
40
|
export function buildCustomFieldValueSchema(parsedField: unknown): z.ZodTypeAny | null {
|
|
22
41
|
if (!parsedField || typeof parsedField !== "object") return null;
|
|
23
42
|
const obj = parsedField as Record<string, unknown>;
|
|
24
|
-
|
|
43
|
+
const rawType = obj["type"];
|
|
44
|
+
if (typeof rawType !== "string") return null;
|
|
45
|
+
if (!SUPPORTED_TYPES_SET.has(rawType)) return null;
|
|
25
46
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
47
|
+
// Embedded sub-fields: pre-check the sub-type set so we surface unknown
|
|
48
|
+
// sub-types as "skip validation" (return null) rather than letting
|
|
49
|
+
// fieldToZod's assertUnreachable throw and the catch swallow real bugs.
|
|
50
|
+
if (rawType === "embedded") {
|
|
51
|
+
const schema = obj["schema"];
|
|
52
|
+
if (!schema || typeof schema !== "object") return null;
|
|
53
|
+
for (const sub of Object.values(schema)) {
|
|
54
|
+
if (!sub || typeof sub !== "object") return null;
|
|
55
|
+
const subType = (sub as Record<string, unknown>)["type"];
|
|
56
|
+
if (typeof subType !== "string" || !SUPPORTED_EMBEDDED_SUB_TYPES.has(subType)) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const fieldDef: Record<string, unknown> = { ...obj };
|
|
63
|
+
for (const k of CONSTRAINT_KEYS) delete fieldDef[k];
|
|
64
|
+
if (rawType === "enum") {
|
|
65
|
+
fieldDef["type"] = "select";
|
|
66
|
+
fieldDef["options"] = obj["values"] ?? obj["options"] ?? [];
|
|
67
|
+
}
|
|
30
68
|
|
|
31
69
|
// fieldToZod's money case validates `currency` against the passed list, not
|
|
32
70
|
// a field-level key — so surface the field's own currency when it declares
|
|
@@ -34,12 +72,8 @@ export function buildCustomFieldValueSchema(parsedField: unknown): z.ZodTypeAny
|
|
|
34
72
|
const currencies =
|
|
35
73
|
typeof obj["currency"] === "string" ? [obj["currency"] as string] : DEFAULT_CURRENCIES;
|
|
36
74
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
return fieldToZod(fieldDef as unknown as FieldDefinition, currencies);
|
|
42
|
-
} catch {
|
|
43
|
-
return null;
|
|
44
|
-
}
|
|
75
|
+
// @cast-boundary serialized-field is the dehydrated r.field.X() output =
|
|
76
|
+
// a FieldDefinition; fieldToZod reads only its type-specific keys (the
|
|
77
|
+
// extra fieldAccess/sensitive/retention/label keys are ignored).
|
|
78
|
+
return fieldToZod(fieldDef as unknown as FieldDefinition, currencies);
|
|
45
79
|
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { describe, expect, mock, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
createStaticLocaleResolver,
|
|
4
|
+
LocaleProvider,
|
|
5
|
+
PrimitivesProvider,
|
|
6
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
7
|
+
import { defaultPrimitives } from "@cosmicdrift/kumiko-renderer-web";
|
|
8
|
+
import { fireEvent, render, screen } from "@testing-library/react";
|
|
9
|
+
import type { ReactNode } from "react";
|
|
10
|
+
import { CustomFieldsFormSection } from "../custom-fields-form-section";
|
|
11
|
+
|
|
12
|
+
type FieldRow = {
|
|
13
|
+
id: string;
|
|
14
|
+
entityName: string;
|
|
15
|
+
fieldKey: string;
|
|
16
|
+
type: string;
|
|
17
|
+
required: boolean;
|
|
18
|
+
displayOrder: number;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const dispatchSpy = mock(async () => ({ isSuccess: true, data: undefined }));
|
|
22
|
+
let mockedQueryRows: readonly FieldRow[] = [];
|
|
23
|
+
|
|
24
|
+
const actual_renderer = await import("@cosmicdrift/kumiko-renderer");
|
|
25
|
+
mock.module("@cosmicdrift/kumiko-renderer", () => ({
|
|
26
|
+
...actual_renderer,
|
|
27
|
+
useDispatcher: mock(() => ({
|
|
28
|
+
write: dispatchSpy,
|
|
29
|
+
query: mock(),
|
|
30
|
+
batch: mock(),
|
|
31
|
+
})),
|
|
32
|
+
useQuery: mock(() => ({
|
|
33
|
+
data: { rows: mockedQueryRows },
|
|
34
|
+
loading: false,
|
|
35
|
+
error: null,
|
|
36
|
+
refetch: mock(),
|
|
37
|
+
})),
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
|
|
41
|
+
return (
|
|
42
|
+
<LocaleProvider resolver={createStaticLocaleResolver()}>
|
|
43
|
+
<PrimitivesProvider value={defaultPrimitives}>{children}</PrimitivesProvider>
|
|
44
|
+
</LocaleProvider>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
describe("CustomFieldsFormSection", () => {
|
|
49
|
+
test("renders an input per matching fieldDefinition and dispatches set-custom-field on save", async () => {
|
|
50
|
+
mockedQueryRows = [
|
|
51
|
+
{
|
|
52
|
+
id: "f1",
|
|
53
|
+
entityName: "component",
|
|
54
|
+
fieldKey: "vendor",
|
|
55
|
+
type: "text",
|
|
56
|
+
required: false,
|
|
57
|
+
displayOrder: 1,
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
id: "f2",
|
|
61
|
+
entityName: "component",
|
|
62
|
+
fieldKey: "tier",
|
|
63
|
+
type: "number",
|
|
64
|
+
required: false,
|
|
65
|
+
displayOrder: 2,
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: "f3",
|
|
69
|
+
entityName: "incident",
|
|
70
|
+
fieldKey: "rootCause",
|
|
71
|
+
type: "text",
|
|
72
|
+
required: false,
|
|
73
|
+
displayOrder: 1,
|
|
74
|
+
},
|
|
75
|
+
];
|
|
76
|
+
dispatchSpy.mockClear();
|
|
77
|
+
|
|
78
|
+
render(
|
|
79
|
+
<Wrapper>
|
|
80
|
+
<CustomFieldsFormSection entityName="component" entityId="row-42" />
|
|
81
|
+
</Wrapper>,
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
// Only `component`-entity fields are rendered (incident's rootCause filtered out).
|
|
85
|
+
expect(screen.getByTestId("custom-fields-form-section")).toBeTruthy();
|
|
86
|
+
const vendorInput = document.getElementById("custom-field-vendor") as HTMLInputElement;
|
|
87
|
+
const tierInput = document.getElementById("custom-field-tier") as HTMLInputElement;
|
|
88
|
+
expect(vendorInput).toBeTruthy();
|
|
89
|
+
expect(tierInput).toBeTruthy();
|
|
90
|
+
expect(document.getElementById("custom-field-rootCause")).toBeNull();
|
|
91
|
+
|
|
92
|
+
// Type in vendor; tier left empty (should be skipped on save).
|
|
93
|
+
fireEvent.change(vendorInput, { target: { value: "Hetzner" } });
|
|
94
|
+
|
|
95
|
+
const saveBtn = screen.getByTestId("custom-fields-form-save");
|
|
96
|
+
fireEvent.click(saveBtn);
|
|
97
|
+
// Wait one microtask for the async handleSave loop.
|
|
98
|
+
await Promise.resolve();
|
|
99
|
+
await Promise.resolve();
|
|
100
|
+
|
|
101
|
+
expect(dispatchSpy).toHaveBeenCalledTimes(1);
|
|
102
|
+
expect(dispatchSpy).toHaveBeenCalledWith("custom-fields:write:set-custom-field", {
|
|
103
|
+
entityName: "component",
|
|
104
|
+
entityId: "row-42",
|
|
105
|
+
fieldKey: "vendor",
|
|
106
|
+
value: "Hetzner",
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test("shows create-mode banner when entityId is null", () => {
|
|
111
|
+
mockedQueryRows = [];
|
|
112
|
+
|
|
113
|
+
render(
|
|
114
|
+
<Wrapper>
|
|
115
|
+
<CustomFieldsFormSection entityName="component" entityId={null} />
|
|
116
|
+
</Wrapper>,
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
expect(screen.getByTestId("custom-fields-form-create-mode")).toBeTruthy();
|
|
120
|
+
expect(screen.queryByTestId("custom-fields-form-section")).toBeNull();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("shows empty banner when no fieldDefinitions match entityName", () => {
|
|
124
|
+
mockedQueryRows = [
|
|
125
|
+
{
|
|
126
|
+
id: "f3",
|
|
127
|
+
entityName: "incident",
|
|
128
|
+
fieldKey: "rootCause",
|
|
129
|
+
type: "text",
|
|
130
|
+
required: false,
|
|
131
|
+
displayOrder: 1,
|
|
132
|
+
},
|
|
133
|
+
];
|
|
134
|
+
|
|
135
|
+
render(
|
|
136
|
+
<Wrapper>
|
|
137
|
+
<CustomFieldsFormSection entityName="component" entityId="row-42" />
|
|
138
|
+
</Wrapper>,
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
expect(screen.getByTestId("custom-fields-form-empty")).toBeTruthy();
|
|
142
|
+
expect(screen.queryByTestId("custom-fields-form-section")).toBeNull();
|
|
143
|
+
});
|
|
144
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Client-Feature-Factory für custom-fields. Wird vom App-Code in
|
|
3
|
+
// createKumikoApp({ clientFeatures: [customFieldsClient()] }) eingehängt
|
|
4
|
+
// und registriert die CustomFieldsFormSection unter dem Namen
|
|
5
|
+
// CUSTOM_FIELDS_FORM_EXTENSION_NAME im ExtensionSectionsProvider. Apps
|
|
6
|
+
// referenzieren den Namen im Screen-Schema:
|
|
7
|
+
//
|
|
8
|
+
// layout: { sections: [
|
|
9
|
+
// { kind: "extension", title: "...", component: {
|
|
10
|
+
// react: { __component: CUSTOM_FIELDS_FORM_EXTENSION_NAME }
|
|
11
|
+
// } },
|
|
12
|
+
// ]}
|
|
13
|
+
|
|
14
|
+
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
|
|
15
|
+
import { CUSTOM_FIELDS_FEATURE_NAME, CUSTOM_FIELDS_FORM_EXTENSION_NAME } from "../constants";
|
|
16
|
+
import { CustomFieldsFormSection } from "./custom-fields-form-section";
|
|
17
|
+
|
|
18
|
+
export function customFieldsClient(): ClientFeatureDefinition {
|
|
19
|
+
return {
|
|
20
|
+
name: CUSTOM_FIELDS_FEATURE_NAME,
|
|
21
|
+
extensionSectionComponents: {
|
|
22
|
+
[CUSTOM_FIELDS_FORM_EXTENSION_NAME]: CustomFieldsFormSection,
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// CustomFieldsFormSection — extension-section component für entityEdit-
|
|
3
|
+
// Screens. Lädt die fieldDefinition-Liste des Tenants, filtert auf die
|
|
4
|
+
// host-Entity, rendert pro Definition einen typed Input, dispatched
|
|
5
|
+
// `custom-fields:write:set-custom-field` pro non-empty Value beim Save.
|
|
6
|
+
//
|
|
7
|
+
// Mount via createKumikoApp({ clientFeatures: [customFieldsClient()] })
|
|
8
|
+
// — der clientFeature-Factory registriert diese Component unter dem
|
|
9
|
+
// Namen `CustomFieldsFormSection`, den die App im Screen-Schema via
|
|
10
|
+
// `component: { react: { __component: CUSTOM_FIELDS_FORM_EXTENSION_NAME } }`
|
|
11
|
+
// referenziert.
|
|
12
|
+
|
|
13
|
+
import { useDispatcher, usePrimitives, useQuery } from "@cosmicdrift/kumiko-renderer";
|
|
14
|
+
import { type ReactNode, useState } from "react";
|
|
15
|
+
import { CustomFieldsHandlers, CustomFieldsQueries } from "../constants";
|
|
16
|
+
|
|
17
|
+
type FieldDefinitionRow = {
|
|
18
|
+
readonly id: string;
|
|
19
|
+
readonly entityName: string;
|
|
20
|
+
readonly fieldKey: string;
|
|
21
|
+
readonly type: string;
|
|
22
|
+
readonly required: boolean;
|
|
23
|
+
readonly displayOrder: number;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
type FieldDefinitionListResponse = {
|
|
27
|
+
readonly rows: readonly FieldDefinitionRow[];
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function CustomFieldsFormSection({
|
|
31
|
+
entityName,
|
|
32
|
+
entityId,
|
|
33
|
+
}: {
|
|
34
|
+
readonly entityName: string;
|
|
35
|
+
readonly entityId: string | null;
|
|
36
|
+
}): ReactNode {
|
|
37
|
+
const { Banner, Button, Field, Input, Text } = usePrimitives();
|
|
38
|
+
const dispatcher = useDispatcher();
|
|
39
|
+
const query = useQuery<FieldDefinitionListResponse>(CustomFieldsQueries.fieldDefinitionList, {});
|
|
40
|
+
const [pending, setPending] = useState<Readonly<Record<string, string>>>({});
|
|
41
|
+
const [saving, setSaving] = useState(false);
|
|
42
|
+
const [errorKey, setErrorKey] = useState<string | null>(null);
|
|
43
|
+
|
|
44
|
+
if (entityId === null) {
|
|
45
|
+
return (
|
|
46
|
+
<Banner variant="info" testId="custom-fields-form-create-mode">
|
|
47
|
+
<Text>Save the entity first to add custom field values.</Text>
|
|
48
|
+
</Banner>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
if (query.loading && query.data === null) {
|
|
52
|
+
return (
|
|
53
|
+
<Banner variant="loading" testId="custom-fields-form-loading">
|
|
54
|
+
<Text>Loading…</Text>
|
|
55
|
+
</Banner>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
if (query.error) {
|
|
59
|
+
return (
|
|
60
|
+
<Banner variant="error" testId="custom-fields-form-error">
|
|
61
|
+
<Text>{query.error.i18nKey}</Text>
|
|
62
|
+
</Banner>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const matchingFields = (query.data?.rows ?? [])
|
|
67
|
+
.filter((f) => f.entityName === entityName)
|
|
68
|
+
.slice()
|
|
69
|
+
.sort((a, b) => a.displayOrder - b.displayOrder);
|
|
70
|
+
|
|
71
|
+
if (matchingFields.length === 0) {
|
|
72
|
+
return (
|
|
73
|
+
<Banner variant="info" testId="custom-fields-form-empty">
|
|
74
|
+
<Text>No custom fields defined for "{entityName}".</Text>
|
|
75
|
+
</Banner>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const handleSave = async (): Promise<void> => {
|
|
80
|
+
setSaving(true);
|
|
81
|
+
setErrorKey(null);
|
|
82
|
+
try {
|
|
83
|
+
for (const field of matchingFields) {
|
|
84
|
+
const raw = pending[field.fieldKey];
|
|
85
|
+
if (raw === undefined || raw === "") continue;
|
|
86
|
+
const value = coerceValue(field.type, raw);
|
|
87
|
+
const result = await dispatcher.write(CustomFieldsHandlers.setCustomField, {
|
|
88
|
+
entityName,
|
|
89
|
+
entityId,
|
|
90
|
+
fieldKey: field.fieldKey,
|
|
91
|
+
value,
|
|
92
|
+
});
|
|
93
|
+
if (!result.isSuccess) {
|
|
94
|
+
setErrorKey(result.error?.i18nKey ?? "custom-fields:save-failed");
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
setPending({});
|
|
99
|
+
} finally {
|
|
100
|
+
setSaving(false);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const dirty = Object.values(pending).some((v) => v !== "");
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<div data-testid="custom-fields-form-section">
|
|
108
|
+
{matchingFields.map((field) => (
|
|
109
|
+
<Field
|
|
110
|
+
key={field.id}
|
|
111
|
+
id={`custom-field-${field.fieldKey}`}
|
|
112
|
+
label={field.fieldKey}
|
|
113
|
+
required={field.required}
|
|
114
|
+
>
|
|
115
|
+
{renderInputFor(field, pending[field.fieldKey] ?? "", (v) =>
|
|
116
|
+
setPending((p) => ({ ...p, [field.fieldKey]: v })),
|
|
117
|
+
)}
|
|
118
|
+
</Field>
|
|
119
|
+
))}
|
|
120
|
+
<Button
|
|
121
|
+
variant="primary"
|
|
122
|
+
onClick={() => void handleSave()}
|
|
123
|
+
disabled={saving || !dirty}
|
|
124
|
+
testId="custom-fields-form-save"
|
|
125
|
+
>
|
|
126
|
+
{saving ? "Saving…" : "Save custom fields"}
|
|
127
|
+
</Button>
|
|
128
|
+
{errorKey !== null && (
|
|
129
|
+
<Banner variant="error" testId="custom-fields-form-save-error">
|
|
130
|
+
<Text>{errorKey}</Text>
|
|
131
|
+
</Banner>
|
|
132
|
+
)}
|
|
133
|
+
</div>
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
function renderInputFor(
|
|
137
|
+
field: FieldDefinitionRow,
|
|
138
|
+
raw: string,
|
|
139
|
+
onChange: (v: string) => void,
|
|
140
|
+
): ReactNode {
|
|
141
|
+
const id = `custom-field-${field.fieldKey}`;
|
|
142
|
+
const name = field.fieldKey;
|
|
143
|
+
if (field.type === "number") {
|
|
144
|
+
return (
|
|
145
|
+
<Input
|
|
146
|
+
kind="number"
|
|
147
|
+
id={id}
|
|
148
|
+
name={name}
|
|
149
|
+
value={raw === "" ? "" : Number(raw)}
|
|
150
|
+
onChange={(v) => onChange(v === undefined ? "" : String(v))}
|
|
151
|
+
/>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
if (field.type === "boolean") {
|
|
155
|
+
return (
|
|
156
|
+
<Input
|
|
157
|
+
kind="boolean"
|
|
158
|
+
id={id}
|
|
159
|
+
name={name}
|
|
160
|
+
value={raw === "true"}
|
|
161
|
+
onChange={(v) => onChange(v ? "true" : "false")}
|
|
162
|
+
/>
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
if (field.type === "date") {
|
|
166
|
+
return (
|
|
167
|
+
<Input kind="date" id={id} name={name} value={raw} onChange={(v) => onChange(v ?? "")} />
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
return <Input kind="text" id={id} name={name} value={raw} onChange={onChange} />;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function coerceValue(type: string, raw: string): unknown {
|
|
175
|
+
if (type === "number") {
|
|
176
|
+
const n = Number(raw);
|
|
177
|
+
return Number.isNaN(n) ? raw : n;
|
|
178
|
+
}
|
|
179
|
+
if (type === "boolean") return raw === "true";
|
|
180
|
+
return raw;
|
|
181
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
export {
|
|
3
|
+
CUSTOM_FIELDS_FORM_EXTENSION_NAME,
|
|
4
|
+
CustomFieldsHandlers,
|
|
5
|
+
CustomFieldsQueries,
|
|
6
|
+
} from "../constants";
|
|
7
|
+
export { customFieldsClient } from "./client-plugin";
|
|
8
|
+
export { CustomFieldsFormSection } from "./custom-fields-form-section";
|
|
@@ -4,13 +4,11 @@
|
|
|
4
4
|
// 1. Feature-Definition Smoke (Boot-Validation passes)
|
|
5
5
|
// 2. Cross-Feature-Behavior: fileRef-Entity ist als Hook-Anker für
|
|
6
6
|
// Sprint-2-userData-Extension nutzbar
|
|
7
|
-
// 3. DDL-Konsistenz:
|
|
8
|
-
// dieselbe Postgres-Struktur (Drift-Guard)
|
|
9
|
-
// 4. Event-QN-Match: r.defineEvent + framework's fileUploadedEvent
|
|
10
|
-
// resolven zum selben QN
|
|
7
|
+
// 3. DDL-Konsistenz: fileRefsTable (buildEntityTable) + fileRefEntity
|
|
8
|
+
// zeigen auf dieselbe Postgres-Struktur (Drift-Guard)
|
|
11
9
|
|
|
12
10
|
import { defineFeature, EXT_USER_DATA } from "@cosmicdrift/kumiko-framework/engine";
|
|
13
|
-
import {
|
|
11
|
+
import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
|
|
14
12
|
import { setupTestStack, type TestStack } from "@cosmicdrift/kumiko-framework/stack";
|
|
15
13
|
|
|
16
14
|
// Native dialect exposes column metadata on the `columns` array (EntityTableMeta)
|
|
@@ -148,21 +146,3 @@ describe("files :: DDL-Konsistenz (M3, S1.7)", () => {
|
|
|
148
146
|
expect(pgColumns.has("size")).toBe(true);
|
|
149
147
|
});
|
|
150
148
|
});
|
|
151
|
-
|
|
152
|
-
describe("files :: event-QN-match (M4, S1.7)", () => {
|
|
153
|
-
test("framework's fileUploadedEvent.name === 'files:event:uploaded'", () => {
|
|
154
|
-
// Wenn das Framework den Event-Namen aendert, fliegt dieser Test
|
|
155
|
-
// sofort an — und der QN aus r.defineEvent("uploaded") im feature
|
|
156
|
-
// wuerde nicht mehr matchen. Drift-Guard.
|
|
157
|
-
expect(FILE_UPLOADED_EVENT_TYPE).toBe("files:event:uploaded");
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
test("Feature-Name 'files' + Event-Short 'uploaded' = QN 'files:event:uploaded'", () => {
|
|
161
|
-
// r.defineEvent("uploaded") in defineFeature("files", ...) resolved
|
|
162
|
-
// zu QN "files:event:uploaded" via Framework-Convention. Match
|
|
163
|
-
// garantiert dass framework's appendEvent + EventDef-Schema-
|
|
164
|
-
// Validation auf demselben QN landen.
|
|
165
|
-
const expected = `${feature.name}:event:uploaded`;
|
|
166
|
-
expect(expected).toBe(FILE_UPLOADED_EVENT_TYPE);
|
|
167
|
-
});
|
|
168
|
-
});
|
package/src/files/feature.ts
CHANGED
|
@@ -1,34 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
// bundled-feature, damit Cross-Feature-Hooks (userData, tenantData) sich
|
|
9
|
-
// an die "fileRef"-Entity hängen können.
|
|
10
|
-
//
|
|
11
|
-
// Sprint 1.5 (this commit):
|
|
12
|
-
// - r.entity("fileRef", fileRefEntity) — Schema-Surface
|
|
13
|
-
// - r.defineEvent("uploaded", schema) — Event-Marker
|
|
14
|
-
//
|
|
15
|
-
// Sprint 2 (kommt):
|
|
16
|
-
// - r.useExtension(EXT_USER_DATA, "fileRef", { export, delete })
|
|
17
|
-
//
|
|
18
|
-
// Sprint 5 (kommt):
|
|
19
|
-
// - r.useExtension(EXT_TENANT_DATA, "fileRef", { destroy })
|
|
20
|
-
//
|
|
21
|
-
// Routes bleiben framework-internal (multipart-Upload + binary-Streaming
|
|
22
|
-
// passen nicht in das Handler-Pattern; siehe schema/file-ref.ts für
|
|
23
|
-
// Architektur-Note).
|
|
24
|
-
//
|
|
25
|
-
// Sprint-1.5-Plan-Roadmap-Wille: "fileRefsTable bleibt in framework
|
|
26
|
-
// (kein Daten-Move), aber r.entity('fileRef') deklariert sie für das
|
|
27
|
-
// Feature." — diese Datei IST die Umsetzung.
|
|
28
|
-
export function createFilesFeature(): FeatureDefinition {
|
|
29
|
-
return defineFeature("files", (r) => {
|
|
30
|
-
r.entity("fileRef", fileRefEntity);
|
|
31
|
-
|
|
32
|
-
r.defineEvent("uploaded", fileUploadedPayloadSchema);
|
|
33
|
-
});
|
|
34
|
-
}
|
|
1
|
+
// files — full Event-Sourcing für File-Metadata. Die Implementierung (Entity,
|
|
2
|
+
// files:event:*-Events, Inline-Projektion) lebt seit dem ES-Umbau im
|
|
3
|
+
// Framework neben file-routes + fileRefsTable, weil file-routes hart davon
|
|
4
|
+
// abhängt (appendDomainEventCore verlangt registrierte Events + Projektion).
|
|
5
|
+
// Dieses Modul re-exportiert nur, damit der App-Import-Pfad
|
|
6
|
+
// `@cosmicdrift/kumiko-bundled-features/files` stabil bleibt.
|
|
7
|
+
export { createFilesFeature, fileRefEntity } from "@cosmicdrift/kumiko-framework/files";
|
|
@@ -110,6 +110,33 @@ describe("s3-provider (Minio)", () => {
|
|
|
110
110
|
const key = uniqueKey("never-existed.bin");
|
|
111
111
|
await expect(provider.read(key)).rejects.toThrow();
|
|
112
112
|
});
|
|
113
|
+
|
|
114
|
+
test("writeStream round-trip via multipart writer preserves bytes", async () => {
|
|
115
|
+
// Pinst die idiomatic Bun-S3-writer-Form (write + end, kein manual
|
|
116
|
+
// flush). Chunks summieren absichtlich auf > 5 MiB (partSize) UND auf
|
|
117
|
+
// einen krummen Rest, damit der multipart-finalizer auch dann greift,
|
|
118
|
+
// wenn die Source-Chunks nicht auf die Part-Boundary aufgehen.
|
|
119
|
+
const key = uniqueKey("stream-multipart.bin");
|
|
120
|
+
const partSize = 5 * 1024 * 1024;
|
|
121
|
+
const chunk = new Uint8Array(1024 * 1024);
|
|
122
|
+
for (let i = 0; i < chunk.length; i++) chunk[i] = i % 251;
|
|
123
|
+
const chunks: Uint8Array[] = [];
|
|
124
|
+
for (let i = 0; i < 7; i++) chunks.push(chunk);
|
|
125
|
+
|
|
126
|
+
if (!provider.writeStream) throw new Error("s3 provider should implement writeStream");
|
|
127
|
+
await provider.writeStream(
|
|
128
|
+
key,
|
|
129
|
+
(async function* () {
|
|
130
|
+
for (const c of chunks) yield c;
|
|
131
|
+
})(),
|
|
132
|
+
);
|
|
133
|
+
|
|
134
|
+
const readBack = await provider.read(key);
|
|
135
|
+
expect(readBack.byteLength).toBe(chunks.length * chunk.length);
|
|
136
|
+
expect(readBack.byteLength).toBeGreaterThan(partSize);
|
|
137
|
+
expect(readBack[0]).toBe(0);
|
|
138
|
+
expect(readBack[readBack.byteLength - 1]).toBe(chunk[chunk.length - 1]);
|
|
139
|
+
});
|
|
113
140
|
});
|
|
114
141
|
|
|
115
142
|
describe("createS3ProviderFromEnv", () => {
|
|
@@ -72,26 +72,21 @@ export function createS3Provider(config: S3ProviderConfig): FileStorageProvider
|
|
|
72
72
|
},
|
|
73
73
|
|
|
74
74
|
async writeStream(key, source, options): Promise<void> {
|
|
75
|
-
// Echtes multipart-streaming via Bun's S3-Writer —
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
75
|
+
// Echtes multipart-streaming via Bun's S3-Writer — partSize steuert die
|
|
76
|
+
// Part-Boundary intern (AWS/R2 verlangen non-final Parts >= 5 MiB,
|
|
77
|
+
// sonst EntityTooSmall beim CompleteMultipartUpload). Manuelles flush()
|
|
78
|
+
// hier wuerde genau diese Garantie brechen, sobald die Source-Chunks
|
|
79
|
+
// nicht auf partSize aufgehen.
|
|
80
80
|
const writer = client.file(key).writer({
|
|
81
81
|
...(options?.mimeType !== undefined && { type: options.mimeType }),
|
|
82
82
|
retry: 3,
|
|
83
83
|
queueSize: 4,
|
|
84
84
|
partSize: STREAM_PART_SIZE,
|
|
85
85
|
});
|
|
86
|
-
let buffered = 0;
|
|
87
86
|
for await (const chunk of source) {
|
|
88
|
-
//
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
if (buffered >= STREAM_PART_SIZE) {
|
|
92
|
-
await writer.flush();
|
|
93
|
-
buffered = 0;
|
|
94
|
-
}
|
|
87
|
+
// Await applies Backpressure und bounded die in-flight Queue auf
|
|
88
|
+
// queueSize, statt unbegrenzt zu puffern.
|
|
89
|
+
await writer.write(chunk);
|
|
95
90
|
}
|
|
96
91
|
await writer.end();
|
|
97
92
|
},
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
setupTestStack,
|
|
26
26
|
type TestStack,
|
|
27
27
|
unsafeCreateEntityTable,
|
|
28
|
+
unsafePushTables,
|
|
28
29
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
29
30
|
import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
|
|
30
31
|
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
@@ -145,21 +146,9 @@ beforeAll(async () => {
|
|
|
145
146
|
UNIQUE(user_id, tenant_id)
|
|
146
147
|
)
|
|
147
148
|
`);
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
tenant_id UUID NOT NULL,
|
|
152
|
-
storage_key TEXT NOT NULL,
|
|
153
|
-
file_name TEXT NOT NULL,
|
|
154
|
-
mime_type TEXT NOT NULL,
|
|
155
|
-
size INTEGER NOT NULL,
|
|
156
|
-
entity_type TEXT,
|
|
157
|
-
entity_id TEXT,
|
|
158
|
-
field_name TEXT,
|
|
159
|
-
inserted_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
|
160
|
-
inserted_by_id TEXT
|
|
161
|
-
)
|
|
162
|
-
`);
|
|
149
|
+
// fileRef ist buildEntityTable-getrieben (softDelete) — echte Entity-Tabelle
|
|
150
|
+
// pushen statt hand-CREATE, damit is_deleted/deleted_at/deleted_by_id da sind.
|
|
151
|
+
await unsafePushTables(stack.db, { fileRefsTable });
|
|
163
152
|
await asRawClient(stack.db).unsafe(`
|
|
164
153
|
CREATE TABLE IF NOT EXISTS test_notes (
|
|
165
154
|
id UUID PRIMARY KEY,
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// File-Retention Integration-Test.
|
|
2
|
+
//
|
|
3
|
+
// Beweist, dass die BESTEHENDE data-retention + Forget-Pipeline auch für
|
|
4
|
+
// `fileRef` greift — kein file-spezifischer Retention-Mechanismus. fileRef ist
|
|
5
|
+
// ein normales softDelete-ES-Entity; sein Forget-/Retention-Verhalten kommt
|
|
6
|
+
// aus genau derselben Kette wie bei jedem anderen Entity:
|
|
7
|
+
//
|
|
8
|
+
// runForgetCleanup → resolveRetentionPolicyForTenant(entityName="fileRef")
|
|
9
|
+
// → policyToStrategy → fileRef userData delete-Hook (delete | anonymize)
|
|
10
|
+
//
|
|
11
|
+
// Abgedeckt:
|
|
12
|
+
// 1. Default (keine Override-Policy) → Forget HARD-löscht die Datei (Art. 17).
|
|
13
|
+
// 2. Tenant-Retention-Override fileRef→anonymize → Forget anonymisiert
|
|
14
|
+
// (insertedById=null, Row bleibt) statt zu löschen.
|
|
15
|
+
// 3. Per-Tenant: derselbe User, anonymize in Tenant A, delete in Tenant B —
|
|
16
|
+
// die Policy entscheidet pro Tenant.
|
|
17
|
+
|
|
18
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
19
|
+
import { asRawClient, insertOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
20
|
+
import {
|
|
21
|
+
createEventStoreExecutor,
|
|
22
|
+
createTenantDb,
|
|
23
|
+
type DbConnection,
|
|
24
|
+
} from "@cosmicdrift/kumiko-framework/db";
|
|
25
|
+
import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
|
|
26
|
+
import {
|
|
27
|
+
setupTestStack,
|
|
28
|
+
type TestStack,
|
|
29
|
+
TestUsers,
|
|
30
|
+
unsafeCreateEntityTable,
|
|
31
|
+
unsafePushTables,
|
|
32
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
33
|
+
import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
|
|
34
|
+
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
35
|
+
import { createComplianceProfilesFeature } from "../../compliance-profiles";
|
|
36
|
+
import { createDataRetentionFeature, tenantRetentionOverrideEntity } from "../../data-retention";
|
|
37
|
+
import { tenantRetentionOverrideTable } from "../../data-retention/schema/tenant-retention-override";
|
|
38
|
+
import { createFilesFeature } from "../../files";
|
|
39
|
+
import { createSessionsFeature } from "../../sessions";
|
|
40
|
+
import { createUserFeature, USER_STATUS, userEntity, userTable } from "../../user";
|
|
41
|
+
import { createUserDataRightsDefaultsFeature } from "../../user-data-rights-defaults";
|
|
42
|
+
import { createUserDataRightsFeature } from "../feature";
|
|
43
|
+
import { runForgetCleanup } from "../run-forget-cleanup";
|
|
44
|
+
|
|
45
|
+
let stack: TestStack;
|
|
46
|
+
let db: DbConnection;
|
|
47
|
+
|
|
48
|
+
const TENANT_A = "00000000-0000-4000-8000-00000000000a";
|
|
49
|
+
const TENANT_B = "00000000-0000-4000-8000-00000000000b";
|
|
50
|
+
const TENANT_SYSTEM = "00000000-0000-4000-8000-000000000001";
|
|
51
|
+
|
|
52
|
+
function uuid(suffix: number): string {
|
|
53
|
+
return `aaaaaaaa-aaaa-4aaa-8aaa-${suffix.toString(16).padStart(12, "0")}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
type Instant = InstanceType<ReturnType<typeof getTemporal>["Instant"]>;
|
|
57
|
+
const NOW = (): Instant => getTemporal().Now.instant();
|
|
58
|
+
function pastInstant(): Instant {
|
|
59
|
+
return getTemporal().Instant.fromEpochMilliseconds(Date.now() - 60_000);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let overrideExecutor: ReturnType<typeof createEventStoreExecutor>;
|
|
63
|
+
|
|
64
|
+
beforeAll(async () => {
|
|
65
|
+
stack = await setupTestStack({
|
|
66
|
+
features: [
|
|
67
|
+
createUserFeature(),
|
|
68
|
+
createFilesFeature(),
|
|
69
|
+
createDataRetentionFeature(),
|
|
70
|
+
createComplianceProfilesFeature(),
|
|
71
|
+
createSessionsFeature(),
|
|
72
|
+
createUserDataRightsFeature(),
|
|
73
|
+
createUserDataRightsDefaultsFeature(),
|
|
74
|
+
],
|
|
75
|
+
});
|
|
76
|
+
db = stack.db;
|
|
77
|
+
|
|
78
|
+
await unsafeCreateEntityTable(db, userEntity);
|
|
79
|
+
await unsafeCreateEntityTable(db, tenantRetentionOverrideEntity);
|
|
80
|
+
await unsafePushTables(db, { fileRefsTable });
|
|
81
|
+
await asRawClient(db).unsafe(`
|
|
82
|
+
CREATE TABLE IF NOT EXISTS read_tenant_memberships (
|
|
83
|
+
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
|
84
|
+
tenant_id UUID NOT NULL,
|
|
85
|
+
user_id TEXT NOT NULL,
|
|
86
|
+
version INTEGER NOT NULL DEFAULT 0,
|
|
87
|
+
inserted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
88
|
+
modified_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
89
|
+
inserted_by_id TEXT,
|
|
90
|
+
modified_by_id TEXT,
|
|
91
|
+
is_deleted BOOLEAN NOT NULL DEFAULT false,
|
|
92
|
+
deleted_at TIMESTAMPTZ,
|
|
93
|
+
deleted_by_id TEXT,
|
|
94
|
+
roles TEXT NOT NULL DEFAULT '[]',
|
|
95
|
+
UNIQUE(user_id, tenant_id)
|
|
96
|
+
)
|
|
97
|
+
`);
|
|
98
|
+
|
|
99
|
+
overrideExecutor = createEventStoreExecutor(
|
|
100
|
+
tenantRetentionOverrideTable,
|
|
101
|
+
tenantRetentionOverrideEntity,
|
|
102
|
+
{ entityName: "tenant-retention-override" },
|
|
103
|
+
);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
afterAll(async () => {
|
|
107
|
+
await stack.cleanup();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
beforeEach(async () => {
|
|
111
|
+
await resetTestTables(db, [
|
|
112
|
+
userTable,
|
|
113
|
+
"read_tenant_memberships",
|
|
114
|
+
fileRefsTable,
|
|
115
|
+
tenantRetentionOverrideTable,
|
|
116
|
+
]);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
async function seedForgetUser(id: string): Promise<void> {
|
|
120
|
+
await insertOne(db, userTable, {
|
|
121
|
+
id,
|
|
122
|
+
tenantId: TENANT_SYSTEM,
|
|
123
|
+
email: `user-${id}@example.com`,
|
|
124
|
+
passwordHash: "hashed",
|
|
125
|
+
displayName: `User ${id}`,
|
|
126
|
+
locale: "de",
|
|
127
|
+
emailVerified: true,
|
|
128
|
+
roles: '["Member"]',
|
|
129
|
+
status: USER_STATUS.DeletionRequested,
|
|
130
|
+
gracePeriodEnd: pastInstant(),
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function seedMembership(userId: string, tenantId: string): Promise<void> {
|
|
135
|
+
await asRawClient(db).unsafe(
|
|
136
|
+
`INSERT INTO read_tenant_memberships (tenant_id, user_id, roles)
|
|
137
|
+
VALUES ($1, $2, '["Member"]') ON CONFLICT (user_id, tenant_id) DO NOTHING`,
|
|
138
|
+
[tenantId, userId],
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function seedFileRef(id: string, tenantId: string, insertedById: string): Promise<void> {
|
|
143
|
+
await asRawClient(db).unsafe(
|
|
144
|
+
`INSERT INTO file_refs (id, tenant_id, storage_key, file_name, mime_type, size, inserted_by_id)
|
|
145
|
+
VALUES ($1, $2, $3, $4, 'application/pdf', 1024, $5) ON CONFLICT (id) DO NOTHING`,
|
|
146
|
+
[id, tenantId, `storage/${id}`, `${id}.pdf`, insertedById],
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Setzt einen Tenant-Retention-Override über die GLEICHE API die der
|
|
151
|
+
// Forget-Resolver liest — kein Test-Sonderpfad.
|
|
152
|
+
async function seedFileRetentionOverride(
|
|
153
|
+
tenantId: string,
|
|
154
|
+
config: { keepFor: string; strategy: string; reference?: string },
|
|
155
|
+
): Promise<void> {
|
|
156
|
+
const by = { ...TestUsers.systemAdmin, tenantId };
|
|
157
|
+
const result = await overrideExecutor.create(
|
|
158
|
+
{ entityName: "fileRef", config: JSON.stringify(config), reason: "test", tenantId },
|
|
159
|
+
by,
|
|
160
|
+
createTenantDb(db, tenantId, "system"),
|
|
161
|
+
);
|
|
162
|
+
if (!result.isSuccess)
|
|
163
|
+
throw new Error(`seedFileRetentionOverride failed: ${JSON.stringify(result)}`);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function fetchFileRow(
|
|
167
|
+
id: string,
|
|
168
|
+
): Promise<{ id: string; inserted_by_id: string | null; is_deleted: boolean } | null> {
|
|
169
|
+
const result = await asRawClient(db).unsafe(
|
|
170
|
+
`SELECT id, inserted_by_id, is_deleted FROM file_refs WHERE id = $1`,
|
|
171
|
+
[id],
|
|
172
|
+
);
|
|
173
|
+
// biome-ignore lint/suspicious/noExplicitAny: drizzle execute typing
|
|
174
|
+
const rows = ((result as any).rows ?? result) as Array<{
|
|
175
|
+
id: string;
|
|
176
|
+
inserted_by_id: string | null;
|
|
177
|
+
is_deleted: boolean;
|
|
178
|
+
}>;
|
|
179
|
+
return rows[0] ?? null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
describe("file-retention :: Forget-Pipeline greift für fileRef", () => {
|
|
183
|
+
test("Default (keine Override-Policy) → Datei wird hart gelöscht (Art. 17)", async () => {
|
|
184
|
+
const userId = uuid(1);
|
|
185
|
+
await seedForgetUser(userId);
|
|
186
|
+
await seedMembership(userId, TENANT_B);
|
|
187
|
+
await seedFileRef(uuid(101), TENANT_B, userId);
|
|
188
|
+
|
|
189
|
+
const result = await runForgetCleanup({ db, registry: stack.registry, now: NOW() });
|
|
190
|
+
|
|
191
|
+
expect(result.processedUserIds).toContain(userId);
|
|
192
|
+
// Hard-Delete: Row weg.
|
|
193
|
+
expect(await fetchFileRow(uuid(101))).toBeNull();
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("Retention-Override fileRef→anonymize → Datei wird anonymisiert, Row bleibt", async () => {
|
|
197
|
+
const userId = uuid(2);
|
|
198
|
+
await seedForgetUser(userId);
|
|
199
|
+
await seedMembership(userId, TENANT_A);
|
|
200
|
+
await seedFileRef(uuid(201), TENANT_A, userId);
|
|
201
|
+
await seedFileRetentionOverride(TENANT_A, { keepFor: "30d", strategy: "anonymize" });
|
|
202
|
+
|
|
203
|
+
const result = await runForgetCleanup({ db, registry: stack.registry, now: NOW() });
|
|
204
|
+
|
|
205
|
+
expect(result.processedUserIds).toContain(userId);
|
|
206
|
+
const row = await fetchFileRow(uuid(201));
|
|
207
|
+
// Anonymize: Row existiert weiter, aber ohne Personenbezug (insertedById null).
|
|
208
|
+
expect(row).not.toBeNull();
|
|
209
|
+
expect(row?.inserted_by_id).toBeNull();
|
|
210
|
+
expect(row?.is_deleted).toBe(false);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("Per-Tenant: derselbe User → anonymize in A, hard-delete in B", async () => {
|
|
214
|
+
const userId = uuid(3);
|
|
215
|
+
await seedForgetUser(userId);
|
|
216
|
+
await seedMembership(userId, TENANT_A);
|
|
217
|
+
await seedMembership(userId, TENANT_B);
|
|
218
|
+
await seedFileRef(uuid(301), TENANT_A, userId);
|
|
219
|
+
await seedFileRef(uuid(302), TENANT_B, userId);
|
|
220
|
+
await seedFileRetentionOverride(TENANT_A, { keepFor: "30d", strategy: "anonymize" });
|
|
221
|
+
// TENANT_B: kein Override → Default-Delete.
|
|
222
|
+
|
|
223
|
+
await runForgetCleanup({ db, registry: stack.registry, now: NOW() });
|
|
224
|
+
|
|
225
|
+
const aRow = await fetchFileRow(uuid(301));
|
|
226
|
+
expect(aRow).not.toBeNull();
|
|
227
|
+
expect(aRow?.inserted_by_id).toBeNull();
|
|
228
|
+
|
|
229
|
+
expect(await fetchFileRow(uuid(302))).toBeNull();
|
|
230
|
+
});
|
|
231
|
+
});
|
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
setupTestStack,
|
|
23
23
|
type TestStack,
|
|
24
24
|
unsafeCreateEntityTable,
|
|
25
|
+
unsafePushTables,
|
|
25
26
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
26
27
|
import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
|
|
27
28
|
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
@@ -92,21 +93,9 @@ beforeAll(async () => {
|
|
|
92
93
|
UNIQUE(user_id, tenant_id)
|
|
93
94
|
)
|
|
94
95
|
`);
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
tenant_id UUID NOT NULL,
|
|
99
|
-
storage_key TEXT NOT NULL,
|
|
100
|
-
file_name TEXT NOT NULL,
|
|
101
|
-
mime_type TEXT NOT NULL,
|
|
102
|
-
size INTEGER NOT NULL,
|
|
103
|
-
entity_type TEXT,
|
|
104
|
-
entity_id TEXT,
|
|
105
|
-
field_name TEXT,
|
|
106
|
-
inserted_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
|
107
|
-
inserted_by_id TEXT
|
|
108
|
-
)
|
|
109
|
-
`);
|
|
96
|
+
// fileRef ist buildEntityTable-getrieben (softDelete) — echte Entity-Tabelle
|
|
97
|
+
// pushen statt hand-CREATE, damit is_deleted/deleted_at/deleted_by_id da sind.
|
|
98
|
+
await unsafePushTables(stack.db, { fileRefsTable });
|
|
110
99
|
});
|
|
111
100
|
|
|
112
101
|
afterAll(async () => {
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
setupTestStack,
|
|
22
22
|
type TestStack,
|
|
23
23
|
unsafeCreateEntityTable,
|
|
24
|
+
unsafePushTables,
|
|
24
25
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
25
26
|
import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
|
|
26
27
|
import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
|
|
@@ -79,21 +80,9 @@ beforeAll(async () => {
|
|
|
79
80
|
UNIQUE(user_id, tenant_id)
|
|
80
81
|
)
|
|
81
82
|
`);
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
tenant_id UUID NOT NULL,
|
|
86
|
-
storage_key TEXT NOT NULL,
|
|
87
|
-
file_name TEXT NOT NULL,
|
|
88
|
-
mime_type TEXT NOT NULL,
|
|
89
|
-
size INTEGER NOT NULL,
|
|
90
|
-
entity_type TEXT,
|
|
91
|
-
entity_id TEXT,
|
|
92
|
-
field_name TEXT,
|
|
93
|
-
inserted_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
|
94
|
-
inserted_by_id TEXT
|
|
95
|
-
)
|
|
96
|
-
`);
|
|
83
|
+
// fileRef ist buildEntityTable-getrieben (softDelete) — echte Entity-Tabelle
|
|
84
|
+
// pushen statt hand-CREATE, damit is_deleted/deleted_at/deleted_by_id da sind.
|
|
85
|
+
await unsafePushTables(stack.db, { fileRefsTable });
|
|
97
86
|
});
|
|
98
87
|
|
|
99
88
|
afterAll(async () => {
|
package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.test.ts
CHANGED
|
@@ -12,10 +12,12 @@
|
|
|
12
12
|
|
|
13
13
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
14
14
|
import { asRawClient, insertOne } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
15
|
+
import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
|
|
15
16
|
import {
|
|
16
17
|
setupTestStack,
|
|
17
18
|
type TestStack,
|
|
18
19
|
unsafeCreateEntityTable,
|
|
20
|
+
unsafePushTables,
|
|
19
21
|
} from "@cosmicdrift/kumiko-framework/stack";
|
|
20
22
|
import { createComplianceProfilesFeature } from "../../compliance-profiles";
|
|
21
23
|
import { createDataRetentionFeature } from "../../data-retention";
|
|
@@ -53,24 +55,10 @@ beforeAll(async () => {
|
|
|
53
55
|
// Drizzle-Generated-Queries kollidieren).
|
|
54
56
|
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
55
57
|
|
|
56
|
-
// file_refs ist
|
|
57
|
-
//
|
|
58
|
-
// CREATE
|
|
59
|
-
await
|
|
60
|
-
CREATE TABLE IF NOT EXISTS file_refs (
|
|
61
|
-
id UUID PRIMARY KEY,
|
|
62
|
-
tenant_id UUID NOT NULL,
|
|
63
|
-
storage_key TEXT NOT NULL,
|
|
64
|
-
file_name TEXT NOT NULL,
|
|
65
|
-
mime_type TEXT NOT NULL,
|
|
66
|
-
size INTEGER NOT NULL,
|
|
67
|
-
entity_type TEXT,
|
|
68
|
-
entity_id TEXT,
|
|
69
|
-
field_name TEXT,
|
|
70
|
-
inserted_at TIMESTAMPTZ DEFAULT now() NOT NULL,
|
|
71
|
-
inserted_by_id TEXT
|
|
72
|
-
)
|
|
73
|
-
`);
|
|
58
|
+
// file_refs ist jetzt das buildEntityTable-getriebene fileRef-Entity
|
|
59
|
+
// (softDelete → is_deleted/deleted_at/deleted_by_id). Echte Entity-Tabelle
|
|
60
|
+
// pushen statt hand-CREATE, damit der is_deleted-Filter der Hooks greift.
|
|
61
|
+
await unsafePushTables(stack.db, { fileRefsTable });
|
|
74
62
|
});
|
|
75
63
|
|
|
76
64
|
afterAll(async () => {
|
|
@@ -30,9 +30,12 @@ import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
|
|
|
30
30
|
// Cleanup als TODO und faellen das in S2.U5 nochmal an.
|
|
31
31
|
|
|
32
32
|
export const fileRefExportHook: UserDataExportHook = async (ctx) => {
|
|
33
|
+
// isDeleted:false — soft-deleted (trashed) Files gehören nicht ins
|
|
34
|
+
// Auskunfts-Bundle. Forget (delete-Hook unten) erfasst sie trotzdem.
|
|
33
35
|
const rawRows = await selectMany(ctx.db, fileRefsTable, {
|
|
34
36
|
tenantId: ctx.tenantId,
|
|
35
37
|
insertedById: ctx.userId,
|
|
38
|
+
isDeleted: false,
|
|
36
39
|
});
|
|
37
40
|
|
|
38
41
|
// @cast-boundary db-row: drizzle liefert insertedAt als Instant
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
createEntity,
|
|
3
|
-
createNumberField,
|
|
4
|
-
createTextField,
|
|
5
|
-
createTimestampField,
|
|
6
|
-
} from "@cosmicdrift/kumiko-framework/engine";
|
|
7
|
-
|
|
8
|
-
// fileRef — Schema-Sicht der File-Metadata-Tabelle aus dem Framework.
|
|
9
|
-
//
|
|
10
|
-
// Architektur-Entscheidung (Sprint 1.5):
|
|
11
|
-
//
|
|
12
|
-
// Die DB-Tabelle `file_refs` lebt weiterhin in
|
|
13
|
-
// `framework/src/files/file-ref-table.ts` als drizzle pgTable, weil
|
|
14
|
-
// die Hono-Upload-/Download-Routes (`createFileRoutes` in
|
|
15
|
-
// `framework/src/api/server.ts`) sie direkt nutzen. Multipart-Upload
|
|
16
|
-
// und Binary-Streaming passen nicht in das Write/Query-Handler-Pattern
|
|
17
|
-
// — Routes bleiben framework-internal.
|
|
18
|
-
//
|
|
19
|
-
// Was hier passiert: dieselbe DB-Tabelle wird zusätzlich als
|
|
20
|
-
// `r.entity("fileRef")` in einem bundled-feature deklariert. Das
|
|
21
|
-
// ermoeglicht:
|
|
22
|
-
// 1. r.useExtension(EXT_USER_DATA, "fileRef", { export, delete })
|
|
23
|
-
// in Sprint 2 — Forget-Flow + Daten-Export erkennen die Entity.
|
|
24
|
-
// 2. r.useExtension(EXT_TENANT_DATA, "fileRef", { destroy })
|
|
25
|
-
// in Sprint 5 — Tenant-Lifecycle löscht alle FileRefs.
|
|
26
|
-
// 3. Boot-Validation für PII-Annotations greift (fileName, originalName).
|
|
27
|
-
//
|
|
28
|
-
// Kein buildEntityTable hier — die Mapping-Tabelle existiert schon im
|
|
29
|
-
// Framework. Drizzle-Reads in den Sprint-2+-Hooks gehen direkt über
|
|
30
|
-
// `fileRefsTable` aus `@cosmicdrift/kumiko-framework/files`.
|
|
31
|
-
//
|
|
32
|
-
// PII-Annotations (Sprint 0.1+0.7+1.7):
|
|
33
|
-
// - fileName → pii: true (Originalname enthält oft Personen-Bezug:
|
|
34
|
-
// "Marc-Lebenslauf.pdf", "Krankheitsattest-Mai.pdf")
|
|
35
|
-
//
|
|
36
|
-
// Andere Felder brauchen KEINE Annotation:
|
|
37
|
-
// - storageKey, mimeType, size, entityType, entityId, fieldName,
|
|
38
|
-
// insertedById → keine PII-typischen Field-Namen, PII-Heuristik
|
|
39
|
-
// greift nicht (siehe boot-validator.ts PII_DIRECT_NAME_HINTS).
|
|
40
|
-
// Ein allowPlaintext-Marker wäre Über-Annotation ohne Effekt.
|
|
41
|
-
// - insertedAt → Audit-Timestamp, framework-managed.
|
|
42
|
-
//
|
|
43
|
-
// Tabellenname matched die Framework-pgTable damit r.entity-Reads über
|
|
44
|
-
// dieselbe Postgres-Tabelle laufen.
|
|
45
|
-
export const fileRefEntity = createEntity({
|
|
46
|
-
table: "file_refs",
|
|
47
|
-
fields: {
|
|
48
|
-
storageKey: createTextField({ required: true }),
|
|
49
|
-
fileName: createTextField({ required: true, pii: true }),
|
|
50
|
-
mimeType: createTextField({ required: true }),
|
|
51
|
-
size: createNumberField({ required: true }),
|
|
52
|
-
entityType: createTextField(),
|
|
53
|
-
entityId: createTextField(),
|
|
54
|
-
fieldName: createTextField(),
|
|
55
|
-
insertedAt: createTimestampField({ sortable: true, filterable: true }),
|
|
56
|
-
insertedById: createTextField(),
|
|
57
|
-
},
|
|
58
|
-
});
|