@cosmicdrift/kumiko-bundled-features 0.21.0 → 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.
@@ -1,34 +1,7 @@
1
- import { defineFeature, type FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine";
2
- import { fileUploadedPayloadSchema } from "@cosmicdrift/kumiko-framework/files";
3
- import { fileRefEntity } from "./schema/file-ref";
4
-
5
- export { fileRefEntity } from "./schema/file-ref";
6
-
7
- // files Schema-Sicht der framework-internen file_refs-Tabelle als
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", () => {
@@ -1,29 +1,15 @@
1
- import { Readable } from "node:stream";
2
- import {
3
- DeleteObjectCommand,
4
- GetObjectCommand,
5
- HeadObjectCommand,
6
- PutObjectCommand,
7
- S3Client,
8
- } from "@aws-sdk/client-s3";
9
- import { Upload } from "@aws-sdk/lib-storage";
10
- import { getSignedUrl as presign } from "@aws-sdk/s3-request-presigner";
11
1
  import type { FileStorageProvider, SignedUrlOptions } from "@cosmicdrift/kumiko-framework/files";
12
2
 
13
3
  // =============================================================================
14
4
  // Operator-Pflicht-Setup (Multipart-Upload-Cleanup)
15
5
  // =============================================================================
16
6
  //
17
- // `writeStream` nutzt @aws-sdk/lib-storage's Upload-class fuer echtes
18
- // multipart-streaming. S3 created dabei eine Multipart-Upload-Session mit
19
- // einer Upload-ID; bei normaler Completion wird sie via Complete-
20
- // MultipartUpload geschlossen.
21
- //
22
- // **Edge-Case bei Worker-Abort:** wenn der Export-Worker mid-write gecancelt
23
- // wird (Pod-Restart, K8s-OOM-Kill, Process-Signal), bleibt die Multipart-
24
- // Upload-Session in S3 OFFEN. S3 behaelt die bereits hochgeladenen Parts
25
- // und berechnet Storage-Kosten dafuer — bis sie manuell oder via Lifecycle-
26
- // Rule abgebrochen werden.
7
+ // `writeStream` nutzt Bun's S3-Writer fuer echtes multipart-streaming. S3
8
+ // created dabei eine Multipart-Upload-Session mit einer Upload-ID; bei
9
+ // normaler Completion wird sie geschlossen. Wird der Export-Worker mid-write
10
+ // gecancelt (Pod-Restart, K8s-OOM-Kill, Process-Signal), bleibt die Session
11
+ // in S3 OFFEN und berechnet Storage-Kosten fuer die bereits hochgeladenen
12
+ // Parts bis sie via Lifecycle-Rule abgebrochen werden.
27
13
  //
28
14
  // **Pflicht-Operator-Setup auf jedem Bucket:**
29
15
  //
@@ -39,16 +25,9 @@ import type { FileStorageProvider, SignedUrlOptions } from "@cosmicdrift/kumiko-
39
25
  // AWS-CLI: `aws s3api put-bucket-lifecycle-configuration --bucket <name>
40
26
  // --lifecycle-configuration file://lifecycle.json`. Hetzner Object Storage
41
27
  // + R2 + Minio supporten dieselbe Syntax.
42
- //
43
- // **Code-side abort()** fuer graceful Worker-Shutdown ist follow-up. Das
44
- // braucht Worker-Cancel-Semantik (AbortSignal-Propagation durch r.job),
45
- // die im framework noch nicht existiert. Bis dahin ist die Lifecycle-
46
- // Rule die einzige Garantie gegen Storage-Leakage.
47
28
 
48
- // Minimal config surface everything the SDK needs, nothing framework-
49
- // specific. Apps wire this into `buildServer({ files: { storageProvider } })`
50
- // the same way they'd pass createLocalProvider in dev.
51
- //
29
+ const STREAM_PART_SIZE = 5 * 1024 * 1024;
30
+
52
31
  // `endpoint` + `forcePathStyle` are the R2/Minio knobs: AWS-S3 uses
53
32
  // virtual-host-style URLs (bucket.s3.region.amazonaws.com), Minio and many
54
33
  // S3-compat providers need path-style (endpoint/bucket/key). Default
@@ -67,8 +46,7 @@ export type S3ProviderConfig = {
67
46
 
68
47
  // Exported for unit testing — the branch logic (explicit override vs.
69
48
  // auto-detect from endpoint) is small but load-bearing: Minio/R2 break
70
- // silently if the virtual-host-style is picked. Keeping it testable
71
- // without constructing an S3Client means the rule stays honest.
49
+ // silently if the virtual-host-style is picked.
72
50
  export function resolveForcePathStyle(config: S3ProviderConfig): boolean {
73
51
  // Explicit override wins; otherwise: custom endpoint → path-style
74
52
  // (that's the shape every non-AWS S3-compatible provider expects),
@@ -77,85 +55,53 @@ export function resolveForcePathStyle(config: S3ProviderConfig): boolean {
77
55
  }
78
56
 
79
57
  export function createS3Provider(config: S3ProviderConfig): FileStorageProvider {
80
- const client = new S3Client({
58
+ const client = new Bun.S3Client({
81
59
  region: config.region,
82
- credentials: {
83
- accessKeyId: config.accessKeyId,
84
- secretAccessKey: config.secretAccessKey,
85
- },
60
+ accessKeyId: config.accessKeyId,
61
+ secretAccessKey: config.secretAccessKey,
62
+ bucket: config.bucket,
86
63
  ...(config.endpoint !== undefined && { endpoint: config.endpoint }),
87
- forcePathStyle: resolveForcePathStyle(config),
64
+ // Bun's virtualHostedStyle is the inverse of the AWS-SDK forcePathStyle
65
+ // knob this config exposes: path-style ⇔ virtualHostedStyle=false.
66
+ virtualHostedStyle: !resolveForcePathStyle(config),
88
67
  });
89
68
 
90
69
  return {
91
70
  async write(key, data, mimeType): Promise<void> {
92
- await client.send(
93
- new PutObjectCommand({
94
- Bucket: config.bucket,
95
- Key: key,
96
- Body: data,
97
- ...(mimeType !== undefined && { ContentType: mimeType }),
98
- }),
99
- );
71
+ await client.write(key, data, mimeType !== undefined ? { type: mimeType } : undefined);
100
72
  },
101
73
 
102
74
  async writeStream(key, source, options): Promise<void> {
103
- // Echtes multipart-streaming via @aws-sdk/lib-storage.Upload
104
- // der Source-AsyncIterable wird chunk-weise zu S3 hochgeladen,
105
- // niemals alles im Memory aggregiert. lib-storage handled
106
- // automatisch chunking (5MB-Parts default), parallel-uploads
107
- // (4 concurrent default), und retry bei Part-Failures.
108
- //
109
- // Memory-Footprint: ~5MB pro in-flight-part × 4 concurrent =
110
- // ~20MB Heap-Bound, unabhaengig von der Total-Bundle-Size. Macht
111
- // 1GB+ Bundles moeglich ohne OOM.
112
- //
113
- // Readable.from(source) adapiert AsyncIterable → node:Readable —
114
- // lib-storage's Body-Type akzeptiert Web-ReadableStream + node-
115
- // Readable, nicht direkt AsyncIterable. Adapter ist zero-copy.
116
- const body = Readable.from(source);
117
- const upload = new Upload({
118
- client,
119
- params: {
120
- Bucket: config.bucket,
121
- Key: key,
122
- Body: body,
123
- ...(options?.mimeType !== undefined && { ContentType: options.mimeType }),
124
- },
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
+ const writer = client.file(key).writer({
81
+ ...(options?.mimeType !== undefined && { type: options.mimeType }),
82
+ retry: 3,
83
+ queueSize: 4,
84
+ partSize: STREAM_PART_SIZE,
125
85
  });
126
- await upload.done();
86
+ for await (const chunk of source) {
87
+ // Await applies Backpressure und bounded die in-flight Queue auf
88
+ // queueSize, statt unbegrenzt zu puffern.
89
+ await writer.write(chunk);
90
+ }
91
+ await writer.end();
127
92
  },
128
93
 
129
94
  async read(key): Promise<Uint8Array> {
130
- const response = await client.send(new GetObjectCommand({ Bucket: config.bucket, Key: key }));
131
- if (!response.Body) {
132
- throw new Error(`s3_read_empty_body: ${key}`);
133
- }
134
- // transformToByteArray is the stream-to-bytes helper the v3 SDK ships
135
- // with — avoids us reinventing a ReadableStream reader. Returns a
136
- // Uint8Array, which is what FileStorageProvider.read() promises.
137
- return response.Body.transformToByteArray();
95
+ return new Uint8Array(await client.file(key).arrayBuffer());
138
96
  },
139
97
 
140
98
  readStream(key): AsyncIterable<Uint8Array> {
141
- // S3 GetObject.Body ist ein StreamingBlobPayloadOutputTypes auf
142
- // node ist das ein Readable-Stream der bereits AsyncIterable<Buffer>
143
- // ist. Wir wrappen lazy: erst beim ersten chunk-pull wird der
144
- // GetObject-Request abgesetzt. Wenn der Key nicht existiert, faellt
145
- // der Error genau dort (nicht beim readStream-Aufruf) — gleiches
146
- // Lazy-Verhalten wie inmemory + local.
99
+ // Lazy: erst beim ersten chunk-pull wird der GET-Request abgesetzt.
100
+ // Existiert der Key nicht, faellt der Error genau dort (nicht beim
101
+ // readStream-Aufruf) gleiches Lazy-Verhalten wie inmemory + local.
147
102
  return {
148
103
  async *[Symbol.asyncIterator]() {
149
- const response = await client.send(
150
- new GetObjectCommand({ Bucket: config.bucket, Key: key }),
151
- );
152
- if (!response.Body) {
153
- throw new Error(`s3_read_empty_body: ${key}`);
154
- }
155
- // SdkStream is AsyncIterable<Buffer> on node. Buffer extends
156
- // Uint8Array; cast sichert die Surface ohne neue runtime-deps.
157
- const body = response.Body as AsyncIterable<Uint8Array>; // @cast-boundary engine-bridge
158
- for await (const chunk of body) {
104
+ for await (const chunk of client.file(key).stream()) {
159
105
  yield chunk;
160
106
  }
161
107
  },
@@ -163,23 +109,11 @@ export function createS3Provider(config: S3ProviderConfig): FileStorageProvider
163
109
  },
164
110
 
165
111
  async delete(key): Promise<void> {
166
- await client.send(new DeleteObjectCommand({ Bucket: config.bucket, Key: key }));
112
+ await client.delete(key);
167
113
  },
168
114
 
169
115
  async exists(key): Promise<boolean> {
170
- try {
171
- await client.send(new HeadObjectCommand({ Bucket: config.bucket, Key: key }));
172
- return true;
173
- } catch (error) {
174
- // S3 SDK throws either NotFound or a generic 404. Check both the
175
- // `.name` property (newer SDKs) and the `$metadata.httpStatusCode`
176
- // (what the SDK guarantees on every error).
177
- const err = error as { name?: string; $metadata?: { httpStatusCode?: number } }; // @cast-boundary error-details
178
- if (err.name === "NotFound" || err.$metadata?.httpStatusCode === 404) {
179
- return false;
180
- }
181
- throw error;
182
- }
116
+ return client.exists(key);
183
117
  },
184
118
 
185
119
  async getSignedUrl(
@@ -187,17 +121,16 @@ export function createS3Provider(config: S3ProviderConfig): FileStorageProvider
187
121
  expiresInSeconds: number,
188
122
  options?: SignedUrlOptions,
189
123
  ): Promise<string> {
190
- // ResponseContentDisposition is the S3 mechanism for overriding the
191
- // Content-Disposition header on the presigned GET — the browser sees
192
- // the original filename instead of the UUID storage key.
193
- const command = new GetObjectCommand({
194
- Bucket: config.bucket,
195
- Key: key,
124
+ // contentDisposition wird von Bun als response-content-disposition
125
+ // Query-Param signiert (Response-Override fuer den GET-Download)
126
+ // der Browser sieht den Original-Dateinamen statt des UUID-Keys.
127
+ return client.presign(key, {
128
+ expiresIn: expiresInSeconds,
129
+ method: "GET",
196
130
  ...(options?.contentDisposition !== undefined && {
197
- ResponseContentDisposition: options.contentDisposition,
131
+ contentDisposition: options.contentDisposition,
198
132
  }),
199
133
  });
200
- return presign(client, command, { expiresIn: expiresInSeconds });
201
134
  },
202
135
  };
203
136
  }
@@ -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
- await asRawClient(stack.db).unsafe(`
149
- CREATE TABLE IF NOT EXISTS file_refs (
150
- id UUID PRIMARY KEY,
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
- await asRawClient(stack.db).unsafe(`
96
- CREATE TABLE IF NOT EXISTS file_refs (
97
- id UUID PRIMARY KEY,
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
- await asRawClient(stack.db).unsafe(`
83
- CREATE TABLE IF NOT EXISTS file_refs (
84
- id UUID PRIMARY KEY,
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 () => {