@cosmicdrift/kumiko-framework 0.2.2 → 0.2.3

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.
Files changed (42) hide show
  1. package/CHANGELOG.md +2 -0
  2. package/package.json +3 -2
  3. package/src/auth/__tests__/roles.test.ts +24 -0
  4. package/src/auth/index.ts +7 -0
  5. package/src/auth/roles.ts +42 -0
  6. package/src/compliance/__tests__/duration-spec.test.ts +72 -0
  7. package/src/compliance/__tests__/profiles.test.ts +308 -0
  8. package/src/compliance/__tests__/sub-processors.test.ts +139 -0
  9. package/src/compliance/duration-spec.ts +44 -0
  10. package/src/compliance/index.ts +31 -0
  11. package/src/compliance/override-schema.ts +136 -0
  12. package/src/compliance/profiles.ts +427 -0
  13. package/src/compliance/sub-processors.ts +152 -0
  14. package/src/db/__tests__/big-int-field.test.ts +131 -0
  15. package/src/db/table-builder.ts +18 -1
  16. package/src/engine/__tests__/boot-validator-api-exposure.test.ts +142 -0
  17. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +570 -0
  18. package/src/engine/__tests__/boot-validator-s0-integration.test.ts +160 -0
  19. package/src/engine/boot-validator.ts +276 -0
  20. package/src/engine/define-feature.ts +39 -0
  21. package/src/engine/extension-names.ts +105 -0
  22. package/src/engine/extensions/user-data.ts +106 -0
  23. package/src/engine/factories.ts +15 -5
  24. package/src/engine/feature-ast/extractors.ts +40 -0
  25. package/src/engine/feature-ast/parse.ts +6 -0
  26. package/src/engine/feature-ast/patterns.ts +22 -0
  27. package/src/engine/feature-ast/render.ts +14 -0
  28. package/src/engine/index.ts +21 -0
  29. package/src/engine/pattern-library/__tests__/library.test.ts +5 -0
  30. package/src/engine/pattern-library/library.ts +36 -0
  31. package/src/engine/schema-builder.ts +8 -0
  32. package/src/engine/types/feature.ts +51 -0
  33. package/src/engine/types/fields.ts +134 -10
  34. package/src/engine/types/index.ts +3 -0
  35. package/src/files/__tests__/read-stream.test.ts +105 -0
  36. package/src/files/__tests__/write-stream.test.ts +233 -0
  37. package/src/files/__tests__/zip-stream.test.ts +357 -0
  38. package/src/files/in-memory-provider.ts +38 -0
  39. package/src/files/index.ts +3 -0
  40. package/src/files/local-provider.ts +58 -1
  41. package/src/files/types.ts +34 -6
  42. package/src/files/zip-stream.ts +251 -0
@@ -5,6 +5,7 @@
5
5
  // accepted at the type layer during migration: features that pass an
6
6
  // array are auto-normalized to { [role]: "all" } at registry build.
7
7
  // Long-term: string[] disappears.
8
+ import type { SQL } from "drizzle-orm";
8
9
  import type { OwnershipMap } from "../ownership";
9
10
 
10
11
  export type FieldAccess = {
@@ -20,6 +21,76 @@ export type FieldAccess = {
20
21
  // custom projections cannot read sensitive field values. See
21
22
  // docs/plans/architecture/projections.md.
22
23
 
24
+ // --- PII / Subject-Key Annotations (DSGVO Art. 17 — Crypto-Shredding) ---
25
+ //
26
+ // Felder die PII enthalten werden in Sprint 3 (crypto-shredding) mit einem
27
+ // Subject-Schluessel encrypted gespeichert. Subject = die natuerliche Person
28
+ // oder der Tenant der die Daten "besitzt". Loeschung erfolgt durch Vernichten
29
+ // des Subject-Keys ("Crypto-Shredding") — der Datensatz bleibt physisch
30
+ // (Audit-Trail bewahrt), ist aber nicht mehr entschluesselbar. Sprint 0
31
+ // fuegt nur die Schema-Marker + Boot-Validation ein; Encrypt/Decrypt-Mechanik
32
+ // kommt in Sprint 3.
33
+ //
34
+ // Drei orthogonale Markierungen:
35
+ // - `pii: true` — Subject = die Entity selbst.
36
+ // Beispiel: user.email gehoert User Marc.
37
+ // - `userOwned: { ownerField }` — Subject = der User der im genannten
38
+ // Field referenziert ist.
39
+ // Beispiel: comment.body gehoert
40
+ // comment.authorId.
41
+ // - `tenantOwned: true` — Subject = der aktuelle Tenant
42
+ // (ctx.tenantId zur Schreibzeit).
43
+ // Beispiel: tenantBranding.brandColor.
44
+ //
45
+ // `anonymize` ist die Pro-Feld-Funktion die der retention-Cleanup-Job
46
+ // (Sprint 2) aufruft wenn die Entity-Strategy "anonymize" lautet oder die
47
+ // `blockDelete`-Frist abgelaufen ist. Beispiel: `() => "[ANONYMIZED]"` oder
48
+ // `() => null`.
49
+ //
50
+ // `allowPlaintext` unterdrueckt PII-Heuristik-Boot-Warnings fuer Felder die
51
+ // zwar PII-Naming haben (email, name, body) aber bewusst Klartext bleiben
52
+ // sollen — z.B. ticket.title als Geschaeftsdaten. Wert ist eine Begruendung
53
+ // wie "is-business-data".
54
+ //
55
+ // `anonymize` darf sync oder async sein — der Cleanup-Job (Sprint 2)
56
+ // awaited den Return. Async-Funktionen sind sinnvoll wenn die Anonymisierung
57
+ // einen Lookup braucht (z.B. konsistente Pseudonyme aus separater Tabelle).
58
+ //
59
+ // Siehe docs/plans/datenschutz/crypto-shredding.md und docs/plans/datenschutz/roadmap.md.
60
+ export type PiiAnnotations = {
61
+ readonly pii?: boolean;
62
+ readonly userOwned?: { readonly ownerField: string };
63
+ readonly tenantOwned?: boolean;
64
+ readonly anonymize?: () => unknown | Promise<unknown>;
65
+ readonly allowPlaintext?: string;
66
+ };
67
+
68
+ // --- Retention (DSGVO Art. 5(1)(e) + HGB/AO Aufbewahrungspflichten) ---
69
+ //
70
+ // Pro Entity definiert der Author eine Default-Retention-Policy. Tenant-
71
+ // Admin uebersteuert sie via Compliance-Profile + Tenant-Override (Sprint 2).
72
+ // Vier Strategien:
73
+ //
74
+ // - "hardDelete" — Row physisch weg nach `keepFor`. Logs, Sessions.
75
+ // - "softDelete" — `deletedAt = now()`. Erlaubt spaetere Restore.
76
+ // - "anonymize" — Felder mit `anonymize`-Funktion ueberschrieben,
77
+ // Row bleibt. Order/Invoice mit gemischter PII +
78
+ // Geschaeftsdaten.
79
+ // - "blockDelete" — Cleanup-Job ignoriert; User-Forget loest stattdessen
80
+ // `anonymize` aus. Buchhaltung, Mandate, Patientenakten.
81
+ //
82
+ // `keepFor` ist eine Duration-String wie "30d", "10y", "6m". Parser
83
+ // kommt im Cleanup-Job (Sprint 2). `reference` ist das Field das den
84
+ // Lebenszeit-Anker liefert (Default: `createdAt`). Sessions z.B. nutzen
85
+ // `lastSeenAt` damit aktive Sessions nicht weggemueht werden.
86
+ //
87
+ // Siehe docs/plans/features/core-data-retention.md und Sprint 2 in roadmap.md.
88
+ export type RetentionDef = {
89
+ readonly keepFor: string;
90
+ readonly strategy: "hardDelete" | "softDelete" | "anonymize" | "blockDelete";
91
+ readonly reference?: string;
92
+ };
93
+
23
94
  export type TextFieldDef = {
24
95
  readonly type: "text";
25
96
  readonly maxLength?: number;
@@ -40,7 +111,7 @@ export type TextFieldDef = {
40
111
  * explizite Höhe. Search/sort/encrypt verhalten sich unverändert
41
112
  * identisch zu single-line — nur die Render-Surface wechselt. */
42
113
  readonly multiline?: boolean | { readonly rows?: number };
43
- };
114
+ } & PiiAnnotations;
44
115
 
45
116
  /**
46
117
  * Long-form text content — source-code, markdown, blog-posts, email-
@@ -74,7 +145,7 @@ export type LongTextFieldDef = {
74
145
  readonly default?: string;
75
146
  readonly access?: FieldAccess;
76
147
  readonly multiline?: boolean | { readonly rows?: number };
77
- };
148
+ } & PiiAnnotations;
78
149
 
79
150
  export type BooleanFieldDef = {
80
151
  readonly type: "boolean";
@@ -95,7 +166,7 @@ export type SelectFieldDef<TOptions extends readonly string[] = readonly string[
95
166
  readonly sensitive?: boolean;
96
167
  readonly default?: TOptions[number];
97
168
  readonly access?: FieldAccess;
98
- };
169
+ } & PiiAnnotations;
99
170
 
100
171
  // Mehrere Werte aus einer festen Options-Liste — UI rendert als
101
172
  // Checkbox-/Multi-Select-Kontrolle. Storage: jsonb-Array<string>;
@@ -119,7 +190,7 @@ export type MultiSelectFieldDef<TOptions extends readonly string[] = readonly st
119
190
  /** Default-Auswahl. Jeder Eintrag muss in `options` sein (Boot-Validator). */
120
191
  readonly default?: readonly TOptions[number][];
121
192
  readonly access?: FieldAccess;
122
- };
193
+ } & PiiAnnotations;
123
194
 
124
195
  export type NumberFieldDef = {
125
196
  readonly type: "number";
@@ -129,7 +200,29 @@ export type NumberFieldDef = {
129
200
  readonly sensitive?: boolean;
130
201
  readonly default?: number;
131
202
  readonly access?: FieldAccess;
132
- };
203
+ } & PiiAnnotations;
204
+
205
+ /**
206
+ * 64-bit-Integer-Spalte fuer Audit-Counter, Byte-Sizes, Event-IDs und
207
+ * andere Werte die >2^31 (~2.1 Mrd) wandern koennen. Storage als
208
+ * Postgres `bigint`, JS-Round-trip als `number` (mode:"number" — sicher
209
+ * bis 2^53 ≈ 9 PB, JSON-serialisierbar). Wer >2^53 braucht (rare),
210
+ * nutzt einen `text`-Field mit eigenem Codec.
211
+ *
212
+ * Vorrang vor `NumberFieldDef`-(integer 32-bit-Cap, ~2.1 GB) immer dann
213
+ * wenn der Wert physisch ueber dieses Limit klettern kann: Bytes,
214
+ * Events, Counters in High-Throughput-Apps, Cumulative-Sums. Money
215
+ * hat dafuer den eigenen `MoneyFieldDef` (mit Currency-Spalte).
216
+ */
217
+ export type BigIntFieldDef = {
218
+ readonly type: "bigInt";
219
+ readonly required?: boolean;
220
+ readonly sortable?: boolean;
221
+ readonly filterable?: boolean;
222
+ readonly sensitive?: boolean;
223
+ readonly default?: number;
224
+ readonly access?: FieldAccess;
225
+ } & PiiAnnotations;
133
226
 
134
227
  export type MoneyFieldDef = {
135
228
  readonly type: "money";
@@ -207,7 +300,7 @@ export type EmbeddedFieldDef = {
207
300
  readonly sensitive?: boolean;
208
301
  readonly schema: Readonly<Record<string, EmbeddedSubFieldDef>>;
209
302
  readonly access?: FieldAccess;
210
- };
303
+ } & PiiAnnotations;
211
304
 
212
305
  // Legacy "date" — JS-Date-Object, semantisch unklar (Wall-Clock vs Instant).
213
306
  // Für neue Felder bevorzuge:
@@ -223,7 +316,7 @@ export type DateFieldDef = {
223
316
  readonly filterable?: boolean;
224
317
  readonly sensitive?: boolean;
225
318
  readonly access?: FieldAccess;
226
- };
319
+ } & PiiAnnotations;
227
320
 
228
321
  // UTC-Instant (Temporal.Instant). Für Ereignisse die zu einem bestimmten
229
322
  // Augenblick passieren, ohne Location-Bezug: createdAt, loginAt, actualPickupAt.
@@ -251,7 +344,7 @@ export type TimestampFieldDef = {
251
344
  * { pickupAt: { type: "timestamp", locatedBy: "pickupTz" }, pickupTz: { type: "tz" } }
252
345
  */
253
346
  readonly locatedBy?: string;
254
- };
347
+ } & PiiAnnotations;
255
348
 
256
349
  // IANA-Zonenname (z.B. "Europe/Berlin", "America/Los_Angeles").
257
350
  // Wird via `Intl.supportedValuesOf("timeZone")` validiert (kommt im
@@ -262,7 +355,7 @@ export type TzFieldDef = {
262
355
  readonly required?: boolean;
263
356
  readonly sensitive?: boolean;
264
357
  readonly access?: FieldAccess;
265
- };
358
+ } & PiiAnnotations;
266
359
 
267
360
  // Wall-Clock-Termin an einem Ort als ATOMARES Konzept.
268
361
  // EIN Feld in der Schema-Definition, ZWEI Spalten in der DB
@@ -289,7 +382,7 @@ export type LocatedTimestampFieldDef = {
289
382
  readonly filterable?: boolean;
290
383
  readonly sensitive?: boolean;
291
384
  readonly access?: FieldAccess;
292
- };
385
+ } & PiiAnnotations;
293
386
 
294
387
  export type FileFieldDef = {
295
388
  readonly type: "file";
@@ -332,6 +425,7 @@ export type FieldDefinition =
332
425
  | SelectFieldDef
333
426
  | MultiSelectFieldDef
334
427
  | NumberFieldDef
428
+ | BigIntFieldDef
335
429
  | MoneyFieldDef
336
430
  | ReferenceFieldDef
337
431
  | EmbeddedFieldDef
@@ -380,6 +474,20 @@ export type EntityIndexDef = {
380
474
  readonly columns: readonly [string, ...string[]];
381
475
  readonly unique?: boolean;
382
476
  readonly name?: string;
477
+ /**
478
+ * Optional SQL-Fragment fuer Partial-Index — `CREATE [UNIQUE] INDEX
479
+ * ... WHERE <condition>`. Postgres-Pattern fuer "Index nur unter
480
+ * bestimmten Bedingungen", typisches Beispiel: ExportJob-Idempotency
481
+ * `UNIQUE(userId) WHERE status IN ('pending', 'running')`.
482
+ *
483
+ * Caller baut das Fragment via drizzle-orm `sql\`...\``-Tagged-
484
+ * Template. table-builder.ts emittiert `.where(def.where)` auf den
485
+ * Drizzle-IndexBuilder — wirkt sowohl fuer unique- als auch fuer
486
+ * non-unique-Indexes (PG erlaubt beides; non-unique partial nutzt
487
+ * man z.B. fuer scharfe BTREE-Indexes nur auf einer Status-Teilmenge
488
+ * statt voller Tabelle).
489
+ */
490
+ readonly where?: SQL;
383
491
  };
384
492
 
385
493
  export type FieldsMap = Readonly<Record<string, FieldDefinition>>;
@@ -419,4 +527,20 @@ export type EntityDefinition<F extends FieldsMap = FieldsMap> = {
419
527
  readonly read?: OwnershipMap;
420
528
  readonly write?: OwnershipMap;
421
529
  };
530
+ /**
531
+ * Default-Retention-Policy fuer diese Entity. Tenant-Admin kann via
532
+ * Compliance-Profile + Tenant-Override (Sprint 2) uebersteuern.
533
+ * Cleanup-Job (Sprint 2) verarbeitet die Strategy:
534
+ *
535
+ * - "hardDelete" → Row physisch weg nach keepFor
536
+ * - "softDelete" → deletedAt = now() (mit core-soft-delete-Feature)
537
+ * - "anonymize" → Felder mit `anonymize`-Funktion ueberschrieben,
538
+ * Row bleibt
539
+ * - "blockDelete" → Cleanup-Job ignoriert; User-Forget loest
540
+ * stattdessen anonymize aus. Buchhaltung, Mandate,
541
+ * Patientenakten.
542
+ *
543
+ * Siehe docs/plans/features/core-data-retention.md.
544
+ */
545
+ readonly retention?: RetentionDef;
422
546
  };
@@ -66,6 +66,7 @@ export type {
66
66
  } from "./feature";
67
67
  export type {
68
68
  AnyFileFieldDef,
69
+ BigIntFieldDef,
69
70
  BooleanFieldDef,
70
71
  DateFieldDef,
71
72
  DefaultCurrency,
@@ -85,7 +86,9 @@ export type {
85
86
  MoneyFieldDef,
86
87
  MultiSelectFieldDef,
87
88
  NumberFieldDef,
89
+ PiiAnnotations,
88
90
  ReferenceFieldDef,
91
+ RetentionDef,
89
92
  SelectFieldDef,
90
93
  TextFieldDef,
91
94
  TimestampFieldDef,
@@ -0,0 +1,105 @@
1
+ // Storage-Provider readStream-API (S2.U3 Atom 3c.fix).
2
+ //
3
+ // Pinst:
4
+ // - In-memory-Provider: readStream yieldet die Bytes als single-chunk,
5
+ // Roundtrip identisch zu write+read.
6
+ // - Local-Provider: readStream nutzt fs.createReadStream → mehrere
7
+ // chunks fuer >hwm-Files. Lazy-Fail: ENOENT trifft erst beim ersten
8
+ // chunk-pull, nicht beim readStream-Aufruf.
9
+ // - Beide Provider: missing-key throw't beim Konsum, nicht bei
10
+ // readStream() — gleiches Lazy-Pattern wie S3.
11
+ //
12
+ // readStream + writeStream sind ab Atom 3c.fix REQUIRED in der Provider-
13
+ // Surface (kein optional). Der Type-Compiler erzwingt Implementierung,
14
+ // kein silent runtime-throw mehr.
15
+
16
+ import { mkdtemp, rm } from "node:fs/promises";
17
+ import { tmpdir } from "node:os";
18
+ import { join } from "node:path";
19
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
20
+ import { createInMemoryFileProvider } from "../in-memory-provider";
21
+ import { createLocalProvider } from "../local-provider";
22
+
23
+ async function collect(stream: AsyncIterable<Uint8Array>): Promise<Uint8Array> {
24
+ const chunks: Uint8Array[] = [];
25
+ let total = 0;
26
+ for await (const chunk of stream) {
27
+ chunks.push(chunk);
28
+ total += chunk.byteLength;
29
+ }
30
+ const out = new Uint8Array(total);
31
+ let offset = 0;
32
+ for (const c of chunks) {
33
+ out.set(c, offset);
34
+ offset += c.byteLength;
35
+ }
36
+ return out;
37
+ }
38
+
39
+ describe("FileStorageProvider.readStream — in-memory", () => {
40
+ test("readStream returnt geschriebene Bytes identisch", async () => {
41
+ const provider = createInMemoryFileProvider();
42
+ const data = new Uint8Array([1, 2, 3, 4, 5]);
43
+ await provider.write("test/foo.bin", data);
44
+
45
+ const result = await collect(provider.readStream("test/foo.bin"));
46
+ expect(Array.from(result)).toEqual([1, 2, 3, 4, 5]);
47
+ });
48
+
49
+ test("missing-key throw't beim ersten chunk-pull (lazy-Pattern)", async () => {
50
+ const provider = createInMemoryFileProvider();
51
+ // readStream() selbst wirft NICHT — das Iterator-Object existiert.
52
+ const stream = provider.readStream("does/not/exist.bin");
53
+ expect(stream).toBeDefined();
54
+ // Erst beim Iterieren faellt der Fehler.
55
+ await expect(collect(stream)).rejects.toThrow(/in-memory file not found/);
56
+ });
57
+ });
58
+
59
+ describe("FileStorageProvider.readStream — local", () => {
60
+ let basePath: string;
61
+
62
+ beforeEach(async () => {
63
+ basePath = await mkdtemp(join(tmpdir(), "kumiko-readstream-test-"));
64
+ });
65
+
66
+ afterEach(async () => {
67
+ await rm(basePath, { recursive: true, force: true });
68
+ });
69
+
70
+ test("readStream returnt geschriebene Bytes identisch", async () => {
71
+ const provider = createLocalProvider(basePath);
72
+ const data = new Uint8Array([10, 20, 30, 40, 50]);
73
+ await provider.write("foo.bin", data);
74
+
75
+ const result = await collect(provider.readStream("foo.bin"));
76
+ expect(Array.from(result)).toEqual([10, 20, 30, 40, 50]);
77
+ });
78
+
79
+ test("readStream chunkt grosse Files (>64KB highWaterMark)", async () => {
80
+ // 200KB > default highWaterMark=64KB → erwartet mind. 2 chunks.
81
+ const provider = createLocalProvider(basePath);
82
+ const big = new Uint8Array(200 * 1024);
83
+ for (let i = 0; i < big.length; i++) big[i] = i & 0xff;
84
+ await provider.write("big.bin", big);
85
+
86
+ let chunkCount = 0;
87
+ let totalBytes = 0;
88
+ for await (const chunk of provider.readStream("big.bin")) {
89
+ chunkCount++;
90
+ totalBytes += chunk.byteLength;
91
+ }
92
+ expect(chunkCount).toBeGreaterThan(1);
93
+ expect(totalBytes).toBe(200 * 1024);
94
+ });
95
+
96
+ test("ENOENT throw't beim ersten chunk-pull (lazy-Pattern)", async () => {
97
+ // Pinst dass readStream() selbst kein Filesystem-Lookup macht;
98
+ // node:fs createReadStream emittiert error event erst beim Read-
99
+ // Versuch. Test-Coverage matched die Inmemory-Variante.
100
+ const provider = createLocalProvider(basePath);
101
+ const stream = provider.readStream("does/not/exist.bin");
102
+ expect(stream).toBeDefined();
103
+ await expect(collect(stream)).rejects.toThrow(/ENOENT|no such file/);
104
+ });
105
+ });
@@ -0,0 +1,233 @@
1
+ // Storage-Provider writeStream-API (S2.U3 Atom 1d).
2
+ //
3
+ // Pinst:
4
+ // - In-memory-Provider: writeStream collected chunks + macht
5
+ // read-Roundtrip identisch zu write(uint8array).
6
+ // - Local-Provider: writeStream schreibt atomar via tmp + rename,
7
+ // halb-fertige Stream-Bricht hinterlassen keine Garbage am Final-
8
+ // Pfad.
9
+ // - Beide Provider liefern die Bytes via read() identisch zurueck —
10
+ // kein chunk-Loss, kein chunk-Order-Verlust.
11
+ //
12
+ // AsyncIterable-source pinst die Streaming-Semantik (Caller streamt
13
+ // chunk-fuer-chunk, Provider niemals alles im Memory).
14
+
15
+ import { mkdtemp, readdir, rm, stat } from "node:fs/promises";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
19
+ import { createInMemoryFileProvider } from "../in-memory-provider";
20
+ import { createLocalProvider } from "../local-provider";
21
+
22
+ async function* fromChunks(chunks: Uint8Array[]): AsyncIterable<Uint8Array> {
23
+ for (const c of chunks) {
24
+ yield c;
25
+ }
26
+ }
27
+
28
+ async function* slowChunks(chunks: Uint8Array[], delayMs: number): AsyncIterable<Uint8Array> {
29
+ for (const c of chunks) {
30
+ await new Promise((r) => setTimeout(r, delayMs));
31
+ yield c;
32
+ }
33
+ }
34
+
35
+ describe("FileStorageProvider.writeStream — in-memory", () => {
36
+ test("schreibt 3 chunks + read liefert konkateniert zurueck", async () => {
37
+ const provider = createInMemoryFileProvider();
38
+ const chunks = [
39
+ new Uint8Array([1, 2, 3]),
40
+ new Uint8Array([4, 5, 6]),
41
+ new Uint8Array([7, 8, 9]),
42
+ ];
43
+ await provider.writeStream("test/file.bin", fromChunks(chunks));
44
+
45
+ const data = await provider.read("test/file.bin");
46
+ expect(Array.from(data)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9]);
47
+ });
48
+
49
+ test("leerer Stream → leere Datei", async () => {
50
+ const provider = createInMemoryFileProvider();
51
+ await provider.writeStream("empty.bin", fromChunks([]));
52
+
53
+ const data = await provider.read("empty.bin");
54
+ expect(data.byteLength).toBe(0);
55
+ });
56
+
57
+ test("mimeType-Option wird in der gespeicherten Entry erhalten", async () => {
58
+ // Kein direkter Read-Pfad fuer mimeType — wir checken via existing-
59
+ // Marker (write-Pfad teilt den storage-shape). Symmetrie-Test:
60
+ // ein write() + ein writeStream() mit gleichem key + gleichem
61
+ // mimeType muessen byte-identische Reads liefern.
62
+ const a = createInMemoryFileProvider();
63
+ const b = createInMemoryFileProvider();
64
+ const bytes = new Uint8Array([42, 43, 44]);
65
+
66
+ await a.write("k", bytes, "application/zip");
67
+ if (!b.writeStream) throw new Error("writeStream missing");
68
+ await b.writeStream("k", fromChunks([bytes]), { mimeType: "application/zip" });
69
+
70
+ expect(Array.from(await a.read("k"))).toEqual(Array.from(await b.read("k")));
71
+ });
72
+ });
73
+
74
+ describe("FileStorageProvider.writeStream — local-filesystem", () => {
75
+ let basePath: string;
76
+
77
+ beforeEach(async () => {
78
+ basePath = await mkdtemp(join(tmpdir(), "kumiko-write-stream-"));
79
+ });
80
+
81
+ afterEach(async () => {
82
+ await rm(basePath, { recursive: true, force: true });
83
+ });
84
+
85
+ test("schreibt + read roundtrip mit chunked source", async () => {
86
+ const provider = createLocalProvider(basePath);
87
+ const chunks = [new Uint8Array([10, 20, 30]), new Uint8Array([40, 50, 60])];
88
+ await provider.writeStream("dir/foo.bin", fromChunks(chunks));
89
+
90
+ const data = await provider.read("dir/foo.bin");
91
+ expect(Array.from(data)).toEqual([10, 20, 30, 40, 50, 60]);
92
+ });
93
+
94
+ test("legt parent-Verzeichnisse rekursiv an", async () => {
95
+ const provider = createLocalProvider(basePath);
96
+ await provider.writeStream("deeply/nested/path/file.bin", fromChunks([new Uint8Array([1])]));
97
+
98
+ const stats = await stat(join(basePath, "deeply/nested/path/file.bin"));
99
+ expect(stats.isFile()).toBe(true);
100
+ });
101
+
102
+ test("atomar: bei wirfendem Stream entsteht KEIN final-Pfad-File", async () => {
103
+ const provider = createLocalProvider(basePath);
104
+
105
+ async function* failingSource(): AsyncIterable<Uint8Array> {
106
+ yield new Uint8Array([1, 2, 3]);
107
+ throw new Error("synthetic mid-stream failure");
108
+ }
109
+
110
+ await expect(provider.writeStream("dir/half.bin", failingSource())).rejects.toThrow(
111
+ /synthetic mid-stream failure/,
112
+ );
113
+
114
+ expect(await provider.exists("dir/half.bin")).toBe(false);
115
+ });
116
+
117
+ test("atomar: nach Failure ist KEIN final-Pfad-File da (.tmp-Leak ist best-effort)", async () => {
118
+ // Echte Atomicity-Garantie: der final-Pfad ist niemals halb-fertig
119
+ // sichtbar. tmp-Files koennen je nach OS-Race im destroy-Pfad
120
+ // kurz liegen bleiben — kein Korrektheitsproblem (kein Reader
121
+ // sucht nach `*.tmp`-Patterns), nur Operations-Hygiene.
122
+ const provider = createLocalProvider(basePath);
123
+
124
+ async function* failing(): AsyncIterable<Uint8Array> {
125
+ yield new Uint8Array([99]);
126
+ throw new Error("fail");
127
+ }
128
+
129
+ await expect(provider.writeStream("subdir/leak-check.bin", failing())).rejects.toThrow();
130
+
131
+ // Final-Pfad ist NICHT da — das ist die harte Garantie.
132
+ expect(await provider.exists("subdir/leak-check.bin")).toBe(false);
133
+
134
+ // tmp-Files duerfen nicht den final-Namen haben (sonst kein Atomicity).
135
+ const entries = await readdir(join(basePath, "subdir"));
136
+ for (const entry of entries) {
137
+ expect(entry).not.toBe("leak-check.bin");
138
+ }
139
+ });
140
+
141
+ test("ueberschreibt existing file atomar (rename ueber bestehenden Pfad)", async () => {
142
+ const provider = createLocalProvider(basePath);
143
+
144
+ await provider.write("k", new Uint8Array([1, 1, 1]));
145
+ await provider.writeStream("k", fromChunks([new Uint8Array([2, 2, 2])]));
146
+
147
+ const data = await provider.read("k");
148
+ expect(Array.from(data)).toEqual([2, 2, 2]);
149
+ });
150
+ });
151
+
152
+ describe("FileStorageProvider.writeStream — Streaming-Property", () => {
153
+ // Pinst dass writeStream tatsaechlich AsyncIterable konsumiert ohne
154
+ // alle chunks in eine Promise.resolve(...) Array zu converten.
155
+ // Wenn ein Provider intern das chunks-Array sammelt, ist das fuer
156
+ // unsere semantischen Garantien OK — wir testen nur das Resultat.
157
+ // Dieser Test sichert dass der Caller ein REIN async-iterable
158
+ // uebergeben kann (z.B. ZIP-Stream der chunks lazy generiert).
159
+
160
+ test("Source mit Promise-delays wird korrekt verarbeitet", async () => {
161
+ const provider = createInMemoryFileProvider();
162
+
163
+ async function* lazySource(): AsyncIterable<Uint8Array> {
164
+ await new Promise((r) => setTimeout(r, 5));
165
+ yield new Uint8Array([1]);
166
+ await new Promise((r) => setTimeout(r, 5));
167
+ yield new Uint8Array([2]);
168
+ await new Promise((r) => setTimeout(r, 5));
169
+ yield new Uint8Array([3]);
170
+ }
171
+
172
+ await provider.writeStream("lazy.bin", lazySource());
173
+
174
+ const data = await provider.read("lazy.bin");
175
+ expect(Array.from(data)).toEqual([1, 2, 3]);
176
+ });
177
+
178
+ test("local-Provider streamt WAEHREND der Source yieldet (tmp-File existiert pre-completion)", async () => {
179
+ // Echter Streaming-Property-Test: bei langsamen Source-yields muss
180
+ // der local-Provider die tmp-File anfangen zu schreiben WAEHREND
181
+ // wir noch chunks zur Verfuegung stellen — nicht erst alle chunks
182
+ // collecten.
183
+ //
184
+ // Pattern: 5 chunks mit je 30ms delay zwischen yields. Wir starten
185
+ // writeStream + pollen alle 10ms (max 200ms) bis tmp-File auftaucht.
186
+ // Bei collect-then-write taucht NIE eine tmp-File auf — Test failed
187
+ // dann via Timeout. Poll-basiert statt time-hardcoded gibt CI-
188
+ // Geschwindigkeits-Toleranz; flake-frei solange total-source-time
189
+ // (5 × 30ms = 150ms) > poll-Granularitaet (10ms).
190
+ const basePath = await mkdtemp(join(tmpdir(), "kumiko-stream-prop-"));
191
+ try {
192
+ const provider = createLocalProvider(basePath);
193
+ const chunks = [
194
+ new Uint8Array([1]),
195
+ new Uint8Array([2]),
196
+ new Uint8Array([3]),
197
+ new Uint8Array([4]),
198
+ new Uint8Array([5]),
199
+ ];
200
+ const writePromise = provider.writeStream("streamed.bin", slowChunks(chunks, 30));
201
+
202
+ // Poll bis tmp existiert ODER final schon fertig (zu schneller CI).
203
+ let hasTmp = false;
204
+ let alreadyDone = false;
205
+ for (let i = 0; i < 20 && !hasTmp && !alreadyDone; i++) {
206
+ await new Promise((r) => setTimeout(r, 10));
207
+ const dirContents = await readdir(basePath);
208
+ hasTmp = dirContents.some((f) => f.endsWith(".tmp"));
209
+ alreadyDone = dirContents.includes("streamed.bin");
210
+ }
211
+ if (alreadyDone && !hasTmp) {
212
+ throw new Error(
213
+ "slowChunks-delay zu kurz fuer CI: writeStream war fertig bevor poll start. " +
214
+ "delayMs erhoehen oder chunks reduzieren.",
215
+ );
216
+ }
217
+ expect(hasTmp).toBe(true); // tmp existiert WAEHREND yields
218
+
219
+ // Warten auf completion
220
+ await writePromise;
221
+
222
+ // Nach completion: tmp ist weg, final-File ist da
223
+ const finalContents = await readdir(basePath);
224
+ expect(finalContents).toContain("streamed.bin");
225
+ expect(finalContents.filter((f) => f.endsWith(".tmp"))).toEqual([]);
226
+
227
+ const data = await provider.read("streamed.bin");
228
+ expect(Array.from(data)).toEqual([1, 2, 3, 4, 5]);
229
+ } finally {
230
+ await rm(basePath, { recursive: true, force: true });
231
+ }
232
+ });
233
+ });