@cosmicdrift/kumiko-framework 0.159.1 → 0.161.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.
Files changed (90) hide show
  1. package/package.json +3 -3
  2. package/src/api/__tests__/api.test.ts +65 -0
  3. package/src/api/__tests__/auth-routes-cookie.test.ts +1 -0
  4. package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +237 -0
  5. package/src/api/__tests__/auth-routes-mfa-verify.test.ts +1 -0
  6. package/src/api/__tests__/dispatcher-live.integration.test.ts +74 -0
  7. package/src/api/__tests__/login-rate-limiter-sweep.test.ts +41 -0
  8. package/src/api/__tests__/server-boot-guards.test.ts +71 -0
  9. package/src/api/api-constants.ts +1 -0
  10. package/src/api/auth-middleware.ts +17 -44
  11. package/src/api/auth-routes.ts +6 -2
  12. package/src/api/index.ts +1 -0
  13. package/src/api/routes.ts +57 -0
  14. package/src/api/server.ts +5 -4
  15. package/src/bun-db/query.ts +12 -25
  16. package/src/crypto/kms-adapter.ts +2 -118
  17. package/src/db/__tests__/build-filter-where.test.ts +34 -0
  18. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +91 -0
  19. package/src/db/cursor.ts +1 -18
  20. package/src/db/dialect.ts +8 -19
  21. package/src/db/entity-table-meta-types.ts +2 -92
  22. package/src/db/event-store-executor.ts +4 -96
  23. package/src/db/table-builder.ts +2 -19
  24. package/src/db/tenant-db.ts +6 -55
  25. package/src/engine/__tests__/boot-validator.test.ts +46 -0
  26. package/src/engine/__tests__/codemod-pipeline.test.ts +139 -10
  27. package/src/engine/__tests__/engine.test.ts +28 -0
  28. package/src/engine/__tests__/registry-facade-sweep.test.ts +80 -0
  29. package/src/engine/__tests__/registry.test.ts +40 -0
  30. package/src/engine/__tests__/tier-resolver-extension.test.ts +19 -1
  31. package/src/engine/boot-validator/entity-handler.ts +10 -1
  32. package/src/engine/define-feature.ts +1 -0
  33. package/src/engine/define-handler.ts +1 -0
  34. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +11 -1
  35. package/src/engine/feature-ast/__tests__/parse.test.ts +983 -3
  36. package/src/engine/feature-ast/__tests__/patch.test.ts +168 -0
  37. package/src/engine/feature-ast/__tests__/patcher.test.ts +7 -0
  38. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +9 -0
  39. package/src/engine/feature-ast/extractors/handlers.ts +19 -2
  40. package/src/engine/feature-ast/extractors/index.ts +1 -0
  41. package/src/engine/feature-ast/index.ts +2 -0
  42. package/src/engine/feature-ast/parse.ts +3 -0
  43. package/src/engine/feature-ast/patch.ts +2 -0
  44. package/src/engine/feature-ast/patcher.ts +21 -0
  45. package/src/engine/feature-ast/patterns.ts +16 -0
  46. package/src/engine/feature-ast/render.ts +15 -0
  47. package/src/engine/feature-builder-state.ts +3 -0
  48. package/src/engine/feature-entity-handlers.ts +35 -1
  49. package/src/engine/index.ts +3 -0
  50. package/src/engine/pattern-library/__tests__/library.test.ts +9 -0
  51. package/src/engine/pattern-library/library.ts +2 -0
  52. package/src/engine/pattern-library/mixed-schemas.ts +37 -0
  53. package/src/engine/registry-facade.ts +9 -0
  54. package/src/engine/registry-ingest.ts +10 -0
  55. package/src/engine/registry-state.ts +3 -0
  56. package/src/engine/types/config.ts +2 -497
  57. package/src/engine/types/define-handler.ts +2 -94
  58. package/src/engine/types/entity-handlers.ts +2 -30
  59. package/src/engine/types/feature.ts +2 -1021
  60. package/src/engine/types/fields.ts +2 -685
  61. package/src/engine/types/handlers.ts +2 -820
  62. package/src/engine/types/hooks.ts +2 -170
  63. package/src/engine/types/index.ts +44 -36
  64. package/src/engine/types/nav.ts +2 -67
  65. package/src/engine/types/ownership.ts +2 -83
  66. package/src/engine/types/projection.ts +2 -165
  67. package/src/engine/types/screen.ts +2 -747
  68. package/src/engine/types/step.ts +2 -334
  69. package/src/engine/types/workspace.ts +2 -42
  70. package/src/errors/write-error-info.ts +6 -22
  71. package/src/event-store/errors.ts +2 -35
  72. package/src/event-store/event-store.ts +2 -21
  73. package/src/event-store/snapshot.ts +11 -35
  74. package/src/event-store/types.ts +2 -22
  75. package/src/files/provider-resolver.ts +3 -5
  76. package/src/files/types.ts +5 -54
  77. package/src/jobs/__tests__/jobs.integration.test.ts +102 -1
  78. package/src/pipeline/__tests__/dispatcher.test.ts +96 -0
  79. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +208 -0
  80. package/src/pipeline/dispatch-shared.ts +39 -1
  81. package/src/pipeline/dispatch-stream.ts +74 -0
  82. package/src/pipeline/dispatcher-utils.ts +1 -1
  83. package/src/pipeline/dispatcher.ts +7 -0
  84. package/src/pipeline/multi-stream-apply-context.ts +4 -42
  85. package/src/rate-limit/resolver.ts +10 -30
  86. package/src/secrets/envelope-cipher.ts +4 -6
  87. package/src/secrets/types.ts +2 -177
  88. package/src/stack/request-helper.ts +19 -2
  89. package/src/stack/test-stack.ts +33 -14
  90. package/src/time/tz-context.ts +9 -56
@@ -1,685 +1,2 @@
1
- // --- Field Types ---
2
-
3
- // OwnershipMap is declared in ./ownership.ts — field-access maps to
4
- // per-role ownership rules. A legacy `readonly string[]` form is still
5
- // accepted at the type layer during migration: features that pass an
6
- // array are auto-normalized to { [role]: "all" } at registry build.
7
- // Long-term: string[] disappears.
8
- import type { OwnershipMap } from "./ownership";
9
-
10
- export type FieldAccess = {
11
- readonly read?: OwnershipMap | readonly string[];
12
- readonly write?: OwnershipMap | readonly string[];
13
- };
14
-
15
- // `sensitive: true` — the field's value is excluded from caller-facing event
16
- // echoes in write responses (#820) and MUST be ciphertext-at-rest: boot
17
- // validation requires a subject annotation (pii / userOwned / tenantOwned)
18
- // or `encrypted: true` (#967). Event payloads carry the table ciphertext,
19
- // so replay reproduces the row byte-identically; plaintext never lands in
20
- // permanent history. Use for password hashes, API tokens, bank details,
21
- // tax IDs. See docs/plans/architecture/projections.md.
22
-
23
- // --- PII / Subject-Key Annotations (DSGVO Art. 17 — Crypto-Shredding) ---
24
- //
25
- // Felder die PII enthalten werden in Sprint 3 (crypto-shredding) mit einem
26
- // Subject-Schluessel encrypted gespeichert. Subject = die natuerliche Person
27
- // oder der Tenant der die Daten "besitzt". Loeschung erfolgt durch Vernichten
28
- // des Subject-Keys ("Crypto-Shredding") — der Datensatz bleibt physisch
29
- // (Audit-Trail bewahrt), ist aber nicht mehr entschluesselbar. Sprint 0
30
- // fuegt nur die Schema-Marker + Boot-Validation ein; Encrypt/Decrypt-Mechanik
31
- // kommt in Sprint 3.
32
- //
33
- // Drei orthogonale Markierungen:
34
- // - `pii: true` — Subject = die Entity selbst.
35
- // Beispiel: user.email gehoert User Marc.
36
- // - `userOwned: { ownerField }` — Subject = der User der im genannten
37
- // Field referenziert ist.
38
- // Beispiel: comment.body gehoert
39
- // comment.authorId.
40
- // - `tenantOwned: true` — Subject = der aktuelle Tenant
41
- // (ctx.tenantId zur Schreibzeit).
42
- // Beispiel: tenantBranding.brandColor.
43
- //
44
- // `anonymize` ist die Pro-Feld-Funktion die der retention-Cleanup-Job
45
- // (Sprint 2) aufruft wenn die Entity-Strategy "anonymize" lautet oder die
46
- // `blockDelete`-Frist abgelaufen ist. Beispiel: `() => "[ANONYMIZED]"` oder
47
- // `() => null`.
48
- //
49
- // `allowPlaintext` unterdrueckt PII-Heuristik-Boot-Warnings fuer Felder die
50
- // zwar PII-Naming haben (email, name, body) aber bewusst Klartext bleiben
51
- // sollen — z.B. ticket.title als Geschaeftsdaten. Wert ist eine Begruendung
52
- // wie "is-business-data".
53
- //
54
- // `anonymize` darf sync oder async sein — der Cleanup-Job (Sprint 2)
55
- // awaited den Return. Async-Funktionen sind sinnvoll wenn die Anonymisierung
56
- // einen Lookup braucht (z.B. konsistente Pseudonyme aus separater Tabelle).
57
- //
58
- // Siehe docs/plans/datenschutz/crypto-shredding.md und docs/plans/datenschutz/roadmap.md.
59
- export type PiiAnnotations = {
60
- readonly pii?: boolean;
61
- readonly userOwned?: { readonly ownerField: string };
62
- readonly tenantOwned?: boolean;
63
- readonly anonymize?: () => unknown | Promise<unknown>;
64
- readonly allowPlaintext?: string;
65
- /** Equality-Lookups (fetchOne/filter eq) bleiben trotz Verschluesselung
66
- * moeglich: generierte `<snake>_bidx`-Spalte traegt einen HMAC-Blind-
67
- * Index, Query-Compiler matchen `(col = $1 OR col_bidx = $2)`. Nur auf
68
- * text-Feldern MIT Subject-Annotation erlaubt (Boot-Validator).
69
- * Siehe docs/plans/datenschutz/blind-index.md (kumiko-framework#818). */
70
- readonly lookupable?: true;
71
- };
72
-
73
- // --- Retention (DSGVO Art. 5(1)(e) + HGB/AO Aufbewahrungspflichten) ---
74
- //
75
- // Pro Entity definiert der Author eine Default-Retention-Policy. Tenant-
76
- // Admin uebersteuert sie via Compliance-Profile + Tenant-Override (Sprint 2).
77
- // Vier Strategien:
78
- //
79
- // - "hardDelete" — Row physisch weg nach `keepFor`. Logs, Sessions.
80
- // - "softDelete" — `deletedAt = now()`. Erlaubt spaetere Restore.
81
- // - "anonymize" — Felder mit `anonymize`-Funktion ueberschrieben,
82
- // Row bleibt. Order/Invoice mit gemischter PII +
83
- // Geschaeftsdaten.
84
- // - "blockDelete" — Cleanup-Job ignoriert; User-Forget loest stattdessen
85
- // `anonymize` aus. Buchhaltung, Mandate, Patientenakten.
86
- //
87
- // `keepFor` ist eine Duration-String wie "30d", "10y", "6m". Parser
88
- // kommt im Cleanup-Job (Sprint 2). `reference` ist das Field das den
89
- // Lebenszeit-Anker liefert (Default: `createdAt`). Sessions z.B. nutzen
90
- // `lastSeenAt` damit aktive Sessions nicht weggemueht werden.
91
- //
92
- // Siehe docs/plans/features/core-data-retention.md und Sprint 2 in roadmap.md.
93
- export type RetentionDef = {
94
- readonly keepFor: string;
95
- readonly strategy: "hardDelete" | "softDelete" | "anonymize" | "blockDelete";
96
- readonly reference?: string;
97
- };
98
-
99
- export type TextFieldDef = {
100
- readonly type: "text";
101
- readonly maxLength?: number;
102
- readonly required?: boolean;
103
- readonly searchable?: boolean;
104
- readonly sortable?: boolean;
105
- /** Author erlaubt Screen-Filter auf diesem Feld (Tier 2.7c).
106
- * Boot-Validator weist Filter mit `filterable !== true` zurück.
107
- * Default: false — analog zu `sortable`, opt-in. */
108
- readonly filterable?: boolean;
109
- readonly encrypted?: boolean;
110
- /** User/admin may legitimately see the value (their own IBAN, passport
111
- * number) — unlike `r.secret()`, which only server code reads. A
112
- * declarative alias over the subject-KMS (pii/userOwned/tenantOwned):
113
- * only allowed on `type: "text"`, can't combine with `searchable`/
114
- * `sortable` (boot validator, kumiko-platform#231/#456). */
115
- readonly piiEncrypted?: boolean;
116
- readonly sensitive?: boolean;
117
- readonly format?: "email" | "url" | "phone";
118
- readonly default?: string;
119
- readonly access?: FieldAccess;
120
- /** Mehrzeiliger Text — DefaultInput rendert dann ein <textarea> statt
121
- * <input type="text">. `true` = Default 4 Zeilen, `{ rows: N }` =
122
- * explizite Höhe. Search/sort/encrypt verhalten sich unverändert
123
- * identisch zu single-line — nur die Render-Surface wechselt. */
124
- readonly multiline?: boolean | { readonly rows?: number };
125
- } & PiiAnnotations;
126
-
127
- /**
128
- * Long-form text content — source-code, markdown, blog-posts, email-
129
- * templates, anything that can be megabytes large. Bewusst MINIMALE
130
- * Surface gegen `text`:
131
- *
132
- * - **Kein `sortable`**: ORDER BY auf 100 KB-Strings kostet I/O ohne
133
- * sinnvolles UX-Outcome (lex-Sortierung von Code ist Nonsense).
134
- * - **Kein `searchable`**: ILIKE/Substring-Suche auf langen Texten
135
- * skaliert nicht. Wer wirklich Volltextsuche will, nimmt den
136
- * SearchAdapter (Meilisearch) — der hat eine eigene Pipeline mit
137
- * Tokenizer + Index, NICHT diesen field-flag.
138
- * - **Kein `filterable`**: WHERE auf langen Strings same Story wie
139
- * sortable.
140
- * - **Kein `format`**: email/url/phone sind kurz definierte Inputs,
141
- * longText ist per Definition unstrukturiert.
142
- *
143
- * Type-level enforcement statt convention: wer sortable/searchable
144
- * braucht, nimmt `text` (mit den entsprechenden Skalierungs-Trade-offs).
145
- * DB-mapping ist identisch zu text (Postgres `text` ist unbounded).
146
- */
147
- export type LongTextFieldDef = {
148
- readonly type: "longText";
149
- /** Optionale soft-Cap. Default unbounded (= Postgres-text-limit, 1 GB).
150
- * Nützlich für defensive Caps wie 1 MB damit ein verirrter Browser-
151
- * Paste nicht die DB sprengt. */
152
- readonly maxLength?: number;
153
- readonly required?: boolean;
154
- readonly encrypted?: boolean;
155
- readonly sensitive?: boolean;
156
- readonly default?: string;
157
- readonly access?: FieldAccess;
158
- readonly multiline?: boolean | { readonly rows?: number };
159
- } & PiiAnnotations;
160
-
161
- export type BooleanFieldDef = {
162
- readonly type: "boolean";
163
- readonly required?: boolean;
164
- readonly sortable?: boolean;
165
- readonly filterable?: boolean;
166
- readonly sensitive?: boolean;
167
- readonly default?: boolean;
168
- readonly access?: FieldAccess;
169
- };
170
-
171
- export type SelectFieldDef<TOptions extends readonly string[] = readonly string[]> = {
172
- readonly type: "select";
173
- readonly options: TOptions;
174
- readonly required?: boolean;
175
- readonly sortable?: boolean;
176
- readonly filterable?: boolean;
177
- readonly sensitive?: boolean;
178
- readonly default?: TOptions[number];
179
- readonly access?: FieldAccess;
180
- } & PiiAnnotations;
181
-
182
- // Mehrere Werte aus einer festen Options-Liste — UI rendert als
183
- // Checkbox-/Multi-Select-Kontrolle. Storage: jsonb-Array<string>;
184
- // jeder Eintrag muss in `options` enthalten sein.
185
- //
186
- // Wann statt `select`: wenn der User mehr als einen Wert gleichzeitig
187
- // auswählen darf (Führerscheinklassen, Tags, Sprachen, Skills).
188
- // Wann statt `embedded` mit Booleans: wenn die Option-Liste nicht
189
- // hardcoded sein soll oder bei mehr als ~5 Optionen — sonst explodiert
190
- // das embedded-Schema.
191
- //
192
- // Ordering: das Array bewahrt die Caller-Reihenfolge (jsonb-array, nicht
193
- // set). Das Framework dedupliziert beim Schreiben nicht — Validator
194
- // rejected Duplikate erst wenn Bedarf da ist.
195
- export type MultiSelectFieldDef<TOptions extends readonly string[] = readonly string[]> = {
196
- readonly type: "multiSelect";
197
- readonly options: TOptions;
198
- readonly required?: boolean;
199
- readonly filterable?: boolean;
200
- readonly sensitive?: boolean;
201
- /** Default-Auswahl. Jeder Eintrag muss in `options` sein (Boot-Validator). */
202
- readonly default?: readonly TOptions[number][];
203
- readonly access?: FieldAccess;
204
- } & PiiAnnotations;
205
-
206
- /**
207
- * Storage: `integer` flag decides the Postgres column type, not just Zod
208
- * validation. `integer: true` → `integer` column (32-bit, ~±2.1 billion),
209
- * write-boundary enforces `.int()`. Omitted/`false` (the default) →
210
- * `double precision` column — fractional values are accepted end to end.
211
- * Need exact decimal storage instead of a binary float (money-adjacent
212
- * math, no representation error)? Use `createDecimalField` (`numeric`).
213
- */
214
- export type NumberFieldDef = {
215
- readonly type: "number";
216
- readonly required?: boolean;
217
- readonly sortable?: boolean;
218
- readonly filterable?: boolean;
219
- readonly sensitive?: boolean;
220
- readonly default?: number;
221
- readonly access?: FieldAccess;
222
- readonly min?: number;
223
- /** `true` → `integer` column + `.int()` Zod validation. Omitted/`false` →
224
- * `double precision` column, fractional values allowed. */
225
- readonly integer?: boolean;
226
- } & PiiAnnotations;
227
-
228
- /**
229
- * 64-bit-Integer-Spalte fuer Audit-Counter, Byte-Sizes, Event-IDs und
230
- * andere Werte die >2^31 (~2.1 Mrd) wandern koennen. Storage als
231
- * Postgres `bigint`, JS-Round-trip als `number` (mode:"number" — sicher
232
- * bis 2^53 ≈ 9 PB, JSON-serialisierbar). Wer >2^53 braucht (rare),
233
- * nutzt einen `text`-Field mit eigenem Codec.
234
- *
235
- * Vorrang vor `NumberFieldDef`-(integer 32-bit-Cap, ~2.1 GB) immer dann
236
- * wenn der Wert physisch ueber dieses Limit klettern kann: Bytes,
237
- * Events, Counters in High-Throughput-Apps, Cumulative-Sums. Money
238
- * hat dafuer den eigenen `MoneyFieldDef` (mit Currency-Spalte).
239
- */
240
- export type BigIntFieldDef = {
241
- readonly type: "bigInt";
242
- readonly required?: boolean;
243
- readonly sortable?: boolean;
244
- readonly filterable?: boolean;
245
- readonly sensitive?: boolean;
246
- readonly default?: number;
247
- readonly access?: FieldAccess;
248
- } & PiiAnnotations;
249
-
250
- /**
251
- * Exact decimal — Postgres `numeric(precision, scale)`. For values that need
252
- * fractional precision the integer `number` field (32-bit int) and `money`
253
- * field (BIGINT minor units + currency) can't hold: interest rates,
254
- * percentages, ratios, measurements.
255
- *
256
- * `precision` = total significant digits, `scale` = digits after the decimal
257
- * point (both required — no silent default that could truncate). pg returns
258
- * `numeric` as a string to preserve precision; the read-codec surfaces it as
259
- * a JS `number` (safe ≤ 2^53, same trade-off as `bigInt` mode:"number" — a
260
- * value past that boundary loses precision, so keep `precision - scale` ≤ 15).
261
- */
262
- export type DecimalFieldDef = {
263
- readonly type: "decimal";
264
- readonly precision: number;
265
- readonly scale: number;
266
- readonly required?: boolean;
267
- readonly sortable?: boolean;
268
- readonly filterable?: boolean;
269
- readonly sensitive?: boolean;
270
- readonly default?: number;
271
- readonly access?: FieldAccess;
272
- } & PiiAnnotations;
273
-
274
- export type MoneyFieldDef = {
275
- readonly type: "money";
276
- readonly required?: boolean;
277
- readonly sortable?: boolean;
278
- readonly filterable?: boolean;
279
- readonly sensitive?: boolean;
280
- readonly access?: FieldAccess;
281
- };
282
-
283
- // Reference-Field (Tier 2.7e-3) — FK-Style Verweis auf eine andere
284
- // Entity. Gespeichert als UUID-Spalte (uuid type), Read-Side liefert
285
- // optional die referenced Row mit (Tier 2.7e-4 eagerload).
286
- //
287
- // `entity` akzeptiert zwei Formen:
288
- // - kurz ("customer") — same-feature reference, Default-Pfad.
289
- // - qualifiziert ("users:user") — cross-feature, Format
290
- // "<featureName>:<entityName>". Renderer baut die Lookup-Query-QN
291
- // gegen das angegebene Feature (`users:query:user:list`).
292
- //
293
- // `labelField` (optional) — welches Feld der referenced Entity wird
294
- // im Select-Dropdown als Label gezeigt. Default: "id". Best practice
295
- // ist ein menschlich-lesbares Feld wie "name", "title", "email".
296
- export type ReferenceFieldDef = {
297
- readonly type: "reference";
298
- readonly entity: string;
299
- readonly required?: boolean;
300
- readonly filterable?: boolean;
301
- readonly sensitive?: boolean;
302
- readonly access?: FieldAccess;
303
- /** Welches Feld der referenced Entity als Display-Label im
304
- * Select-Dropdown erscheint. Default: "id". Boot-Validator pinst
305
- * dass das Feld auf der referenced Entity existiert. */
306
- readonly labelField?: string;
307
- /** Multi-Reference (Tier 2.7e-Multi): Wert ist ein Array von UUIDs
308
- * statt single UUID. Storage als jsonb-Array<uuid>. UI rendert
309
- * Multi-Select-Combobox mit Tag-Anzeige der gewählten Items. */
310
- readonly multiple?: boolean;
311
- };
312
-
313
- // --- Currency ---
314
-
315
- // Keep in sync with DEFAULT_CURRENCIES in ../field-helpers.ts.
316
- export type DefaultCurrency =
317
- | "EUR"
318
- | "USD"
319
- | "GBP"
320
- | "CHF"
321
- | "JPY"
322
- | "SEK"
323
- | "NOK"
324
- | "DKK"
325
- | "PLN"
326
- | "CZK"
327
- | "CAD"
328
- | "AUD"
329
- | "NZD"
330
- | "CNY"
331
- | "INR";
332
-
333
- // --- Embedded Object ---
334
-
335
- export type EmbeddedSubFieldDef = {
336
- readonly type: "text" | "number" | "boolean" | "date";
337
- readonly required?: boolean;
338
- readonly searchable?: boolean;
339
- readonly access?: FieldAccess;
340
- };
341
-
342
- export type EmbeddedFieldDef = {
343
- readonly type: "embedded";
344
- readonly required?: boolean;
345
- readonly sensitive?: boolean;
346
- readonly schema: Readonly<Record<string, EmbeddedSubFieldDef>>;
347
- readonly access?: FieldAccess;
348
- } & PiiAnnotations;
349
-
350
- // Free-form jsonb — keys/shape NOT validated at write-time. Use for:
351
- // - Tenant-defined extension data (custom-fields-bundle uses this for
352
- // `customFields` on host-entities — keys are dynamic per fieldDefinition)
353
- // - Configuration-blobs with shape that evolves outside Stammfeld-schema
354
- // - AI-inferred metadata where shape is provider-dependent
355
- //
356
- // Vs. embedded: embedded enforces a typed sub-schema; jsonb accepts any
357
- // JSON-shaped object. Read-side both map to Postgres `jsonb`. Default `{}`
358
- // + NOT NULL, identisch zu embedded.
359
- export type JsonbFieldDef = {
360
- readonly type: "jsonb";
361
- readonly sensitive?: boolean;
362
- readonly access?: FieldAccess;
363
- } & PiiAnnotations;
364
-
365
- // Legacy "date" — JS-Date-Object, semantisch unklar (Wall-Clock vs Instant).
366
- // Für neue Felder bevorzuge:
367
- // - `timestamp` für UTC-Instant ("wann ist das passiert")
368
- // - `createLocatedTimestampField()` für Termine die an einem Ort
369
- // stattfinden ("Pickup um 10:00 in Lissabon")
370
- // - (kommt) `plainDate` für Kalender-Daten ohne Uhrzeit (z.B. Geburtstag)
371
- // Siehe docs/plans/architecture/timezones.md
372
- export type DateFieldDef = {
373
- readonly type: "date";
374
- readonly required?: boolean;
375
- readonly sortable?: boolean;
376
- readonly filterable?: boolean;
377
- readonly sensitive?: boolean;
378
- readonly access?: FieldAccess;
379
- /** Erlaubte Datumsgrenzen als ISO `yyyy-mm-dd` (z.B. Geburtsdatum nicht
380
- * in der Zukunft: `max` = heute). Begrenzt den Picker und wird vom
381
- * Zod-Schema beim Write durchgesetzt. */
382
- readonly min?: string;
383
- readonly max?: string;
384
- /** Format/Locale-Override für Anzeige und Eingabe-Parsing (z.B.
385
- * "de-DE"). Default = App-Locale. */
386
- readonly locale?: string;
387
- } & PiiAnnotations;
388
-
389
- // UTC-Instant (Temporal.Instant). Für Ereignisse die zu einem bestimmten
390
- // Augenblick passieren, ohne Location-Bezug: createdAt, loginAt, actualPickupAt.
391
- // JSON-Form: ISO-UTC-String "2026-04-18T10:00:00Z" via .toJSON().
392
- //
393
- // Mit `locatedBy: "<name>Tz"` markiert: bildet ein Wall-Clock+TZ-Pair mit dem
394
- // referenzierten tz-Feld. JSON-Form wird dann zwei Felder ({ at, tz }), DB
395
- // speichert Wall-Clock+tz und konvertiert transparent (siehe DB-Wrapper,
396
- // kommt in einer späteren Iteration).
397
- //
398
- // Für neue Felder bevorzuge `createLocatedTimestampField()` — EIN atomares
399
- // Feld statt eines lose verdrahteten Pairs (siehe LocatedTimestampFieldDef).
400
- export type TimestampFieldDef = {
401
- readonly type: "timestamp";
402
- readonly required?: boolean;
403
- readonly sortable?: boolean;
404
- readonly filterable?: boolean;
405
- readonly sensitive?: boolean;
406
- readonly access?: FieldAccess;
407
- /**
408
- * Marker: dieses Timestamp-Feld ist Wall-Clock-Zeit an einem Ort.
409
- * Wert ist der Name des begleitenden tz-Felds (IANA-Zone).
410
- *
411
- * Beispiel: manuelles Pair
412
- * { pickupAt: { type: "timestamp", locatedBy: "pickupTz" }, pickupTz: { type: "tz" } }
413
- * — bevorzuge stattdessen `createLocatedTimestampField()`.
414
- */
415
- readonly locatedBy?: string;
416
- /** Erlaubte Grenzen als ISO-Datetime. Begrenzt den Picker auf
417
- * Tages-Granularität; die exakte Uhrzeit-Grenze setzt das Zod-Schema
418
- * beim Write durch. */
419
- readonly min?: string;
420
- readonly max?: string;
421
- /** Format/Locale-Override für Anzeige und Eingabe-Parsing. Default =
422
- * App-Locale. */
423
- readonly locale?: string;
424
- } & PiiAnnotations;
425
-
426
- // IANA-Zonenname (z.B. "Europe/Berlin", "America/Los_Angeles").
427
- // Wird via `Intl.supportedValuesOf("timeZone")` validiert (kommt im
428
- // Zod-Validator-Schritt). Eigener Field-Typ damit Type-Safety + Storage
429
- // (TEXT-Spalte) korrekt sind und der `locatedBy`-Marker eindeutig auflöst.
430
- export type TzFieldDef = {
431
- readonly type: "tz";
432
- readonly required?: boolean;
433
- readonly sensitive?: boolean;
434
- readonly access?: FieldAccess;
435
- } & PiiAnnotations;
436
-
437
- // Wall-Clock-Termin an einem Ort als ATOMARES Konzept.
438
- // EIN Feld in der Schema-Definition, ZWEI Spalten in der DB
439
- // (`<name>_utc TIMESTAMPTZ` + `<name>_tz TEXT`), DREI Felder im API-Object
440
- // ({ at, tz, utc }). Drizzle-Wrapper macht die Konvertierung transparent —
441
- // Feature-Code sieht das 3-Felder-Object beim Read und schreibt
442
- // { at, tz } beim Insert (utc wird berechnet).
443
- //
444
- // API-Form:
445
- // Write: { at: "2026-04-15T10:00:00", tz: "Europe/Lisbon" }
446
- // Read: { at: "2026-04-15T10:00:00", tz: "Europe/Lisbon", utc: "2026-04-15T09:00:00Z" }
447
- //
448
- // Default-Sicht für `at`: Wall-Clock am Ort (`tz`). Wer User-lokale Sicht
449
- // will, projeziert `utc` separat per ctx.tz.fromInstantInZone(utc, userTz).
450
- //
451
- // Ersetzt das alte `locatedTimestamp(name)` Helper-Pattern (zwei separate
452
- // Pair-Felder). Sauberer Single-Field-Typ + Auto-Convert-Logik.
453
- //
454
- // Siehe docs/plans/architecture/timezones.md.
455
- export type LocatedTimestampFieldDef = {
456
- readonly type: "locatedTimestamp";
457
- readonly required?: boolean;
458
- readonly sortable?: boolean;
459
- readonly filterable?: boolean;
460
- readonly sensitive?: boolean;
461
- readonly access?: FieldAccess;
462
- /** Erlaubte Grenzen als ISO-Datetime (Wall-Clock). Begrenzt den Picker
463
- * auf Tages-Granularität; die exakte Uhrzeit-Grenze setzt das Zod-Schema
464
- * beim Write durch. */
465
- readonly min?: string;
466
- readonly max?: string;
467
- /** Format/Locale-Override für Anzeige und Eingabe-Parsing. Default =
468
- * App-Locale. */
469
- readonly locale?: string;
470
- } & PiiAnnotations;
471
-
472
- export type FileFieldDef = {
473
- readonly type: "file";
474
- readonly required?: boolean;
475
- readonly maxSize?: string;
476
- readonly accept?: readonly string[];
477
- readonly access?: FieldAccess;
478
- };
479
-
480
- export type ImageFieldDef = {
481
- readonly type: "image";
482
- readonly required?: boolean;
483
- readonly maxSize?: string;
484
- readonly accept?: readonly string[];
485
- readonly thumbnails?: boolean;
486
- readonly access?: FieldAccess;
487
- };
488
-
489
- export type FilesFieldDef = {
490
- readonly type: "files";
491
- readonly maxSize?: string;
492
- readonly accept?: readonly string[];
493
- readonly maxCount?: number;
494
- readonly access?: FieldAccess;
495
- };
496
-
497
- export type ImagesFieldDef = {
498
- readonly type: "images";
499
- readonly maxSize?: string;
500
- readonly accept?: readonly string[];
501
- readonly maxCount?: number;
502
- readonly thumbnails?: boolean;
503
- readonly access?: FieldAccess;
504
- };
505
-
506
- export type FieldDefinition =
507
- | TextFieldDef
508
- | LongTextFieldDef
509
- | BooleanFieldDef
510
- | SelectFieldDef
511
- | MultiSelectFieldDef
512
- | NumberFieldDef
513
- | BigIntFieldDef
514
- | DecimalFieldDef
515
- | MoneyFieldDef
516
- | ReferenceFieldDef
517
- | EmbeddedFieldDef
518
- | JsonbFieldDef
519
- | DateFieldDef
520
- | TimestampFieldDef
521
- | TzFieldDef
522
- | LocatedTimestampFieldDef
523
- | FileFieldDef
524
- | ImageFieldDef
525
- | FilesFieldDef
526
- | ImagesFieldDef;
527
-
528
- // Union of all field variants that represent uploaded files. They share
529
- // `maxSize` and `accept`, which is what upload validation cares about.
530
- export type AnyFileFieldDef = FileFieldDef | ImageFieldDef | FilesFieldDef | ImagesFieldDef;
531
-
532
- // --- Derived (computed) fields ---
533
- //
534
- // A derived field is read-time only: its value is computed from the stored row
535
- // (and the clock) when an entityList query runs (676/2 — NOT detail; only the
536
- // "list" case in entity-handlers.ts calls augmentDerivedFields), never
537
- // persisted. It lives in
538
- // `EntityDefinition.derivedFields` — deliberately NOT in `fields`, so it
539
- // produces no DB column, never enters a write schema, and can't be the target
540
- // of an entityEdit. A declarative `entityList` can name it like any column and
541
- // the view-model renders the appended value.
542
- //
543
- // LIMIT: derived columns are DISPLAY ONLY. A declarative `entityList` loads its
544
- // rows server-side and a column-header sort round-trips to the server, where
545
- // `executor.list` sorts/filters/searches over real SQL columns — so a derived
546
- // field (no column) silently no-ops. There is no client-side sort path. Need a
547
- // derived value sortable/searchable? Materialize it as a stored field; it then
548
- // rides the existing `searchable`/`sortable` machinery. Time-dependent values
549
- // (as-of-today) can't be materialized without a daily re-index anyway.
550
-
551
- /** Display type a derived value formats as — drives the column's renderer
552
- * choice in the view-model, parallel to FieldDefinition["type"]. Single-column
553
- * types only: `money` is excluded because it needs a `<name>Currency`
554
- * companion column a derived field has no place to put — use `number`/
555
- * `decimal` plus a `{ format: "currency" }` column renderer instead. */
556
- export type DerivedValueType = "text" | "number" | "decimal" | "boolean" | "date" | "timestamp";
557
-
558
- /** Clock injected into `derive` — never read `Temporal.Now`/`Date` inside a
559
- * derive body (no-date-api guard + testability). The list-query handler passes
560
- * the read-time instant; unit tests pass a fixed one. */
561
- export type DeriveContext = {
562
- readonly asOf: Temporal.Instant;
563
- };
564
-
565
- export type DerivedFieldDef = {
566
- readonly valueType: DerivedValueType;
567
- /** Pure function of the stored row + clock. Returns the JSON-safe display
568
- * value (e.g. integer minor units for a currency column, ISO string for a
569
- * `date`). */
570
- readonly derive: (row: Readonly<Record<string, unknown>>, ctx: DeriveContext) => unknown;
571
- };
572
-
573
- export type DerivedFieldsMap = Readonly<Record<string, DerivedFieldDef>>;
574
-
575
- /** Client-facing projection of DerivedFieldDef — `derive` is server-only and
576
- * not JSON-safe (would trip the output-walk guard), so the browser schema
577
- * only ever carries `valueType`. A real `Pick`, not a same-shape cast: TS
578
- * itself proves `derive` isn't there instead of a `{ valueType } as
579
- * DerivedFieldDef` cast lying about a field that's actually missing. */
580
- export type ClientDerivedFieldDef = Pick<DerivedFieldDef, "valueType">;
581
-
582
- // --- Entity ---
583
-
584
- // --- State Transitions ---
585
-
586
- export type TransitionMap = Readonly<Record<string, readonly string[]>>;
587
-
588
- /** Composite-Index auf einer Entity. Spalten werden via field-Name
589
- * referenziert (camelCase). buildEntityTable mapped sie auf snake_case-
590
- * Spaltennamen und benennt den Index nach Convention:
591
- *
592
- * <table>_<col1>_<col2>_idx (non-unique)
593
- * <table>_<col1>_<col2>_unique (unique)
594
- *
595
- * Eine `name`-Override ist erlaubt — Convention-Bruch in Bestandscode
596
- * vermeidet Migration-Churn beim Refactor.
597
- *
598
- * Single-column indices über `tenantId` sind redundant (buildEntityTable
599
- * legt die immer automatisch an); die Boot-Validation warnt (außer
600
- * `{ unique: true }` — semantische 1:1-Constraint, kein Performance-Hint). */
601
- export type EntityIndexDef = {
602
- readonly columns: readonly [string, ...string[]];
603
- readonly unique?: boolean;
604
- readonly name?: string;
605
- /**
606
- * Optional SQL-Fragment fuer Partial-Index — `CREATE [UNIQUE] INDEX
607
- * ... WHERE <condition>`. Postgres-Pattern fuer "Index nur unter
608
- * bestimmten Bedingungen", typisches Beispiel: ExportJob-Idempotency
609
- * `UNIQUE(userId) WHERE status IN ('pending', 'running')`.
610
- *
611
- * Caller baut das Fragment via drizzle-orm `sql\`...\``-Tagged-
612
- * Template. table-builder.ts emittiert `.where(def.where)` auf den
613
- * Drizzle-IndexBuilder — wirkt sowohl fuer unique- als auch fuer
614
- * non-unique-Indexes (PG erlaubt beides; non-unique partial nutzt
615
- * man z.B. fuer scharfe BTREE-Indexes nur auf einer Status-Teilmenge
616
- * statt voller Tabelle).
617
- */
618
- readonly where?: unknown;
619
- };
620
-
621
- export type FieldsMap = Readonly<Record<string, FieldDefinition>>;
622
-
623
- export type EntityDefinition<F extends FieldsMap = FieldsMap> = {
624
- readonly table?: string;
625
- readonly fields: F;
626
- readonly softDelete?: boolean;
627
- /** This aggregate's event stream lives on SYSTEM_TENANT_ID rather than the
628
- * creator's tenant. Opt-in per entity (NOT inherited from r.systemScope()):
629
- * only for genuinely tenant-independent aggregates like `user`. The first
630
- * event (create) is what's routed; updates resolve the stream tenant upstream. */
631
- readonly systemStream?: boolean;
632
- readonly searchWeight?: number;
633
- readonly defaultCurrency?: string;
634
- /** Allowed state transitions per field. Boot validates against select options. */
635
- readonly transitions?: Readonly<Record<string, TransitionMap>>;
636
- /** Composite-Indices über mehrere Felder. Single-column FK-Indices und
637
- * der tenant_id-Index werden weiterhin automatisch von buildEntityTable
638
- * angelegt — diese Liste ist nur für Custom-Indices die der Author
639
- * explizit deklariert (z.B. `{ unique: true, columns: ["key", "tenantId", "userId"] }`). */
640
- readonly indexes?: readonly EntityIndexDef[];
641
- /**
642
- * PK-Typ der Entity.
643
- * - `"serial"` (default): bigserial integer — schneller, kompakter, perfekt für klassische CRUD-Entities.
644
- * - `"uuid"`: uuid mit `gen_random_uuid()` default — verpflichtend für Entities deren `id` als
645
- * Foreign-Key-Wert in multi-tenant Kontexten reist (z.B. `tenant.id` IS der `tenantId`). Auch für
646
- * ES-Aggregate (Phase 2+) notwendig, da Events per UUID aggregiert werden.
647
- */
648
- readonly idType?: "serial" | "uuid";
649
- /**
650
- * Row-level ownership rules (H.2). read runs as WHERE-predicate on list/
651
- * detail/queryProjection, scoping which rows the caller sees. write runs
652
- * pre-save on create/update/delete, scoping which rows the caller may
653
- * modify (Straddle-safe, multi-role atomic — see engine/ownership.ts).
654
- *
655
- * Keys are role names; rules use the `from()` helper or `{ where }`
656
- * escape hatch. Entity-level ownership is AND-ed with tenant isolation —
657
- * a user's tenant filter still applies first.
658
- */
659
- readonly access?: {
660
- readonly read?: OwnershipMap;
661
- readonly write?: OwnershipMap;
662
- };
663
- /**
664
- * Default-Retention-Policy fuer diese Entity. Tenant-Admin kann via
665
- * Compliance-Profile + Tenant-Override (Sprint 2) uebersteuern.
666
- * Cleanup-Job (Sprint 2) verarbeitet die Strategy:
667
- *
668
- * - "hardDelete" → Row physisch weg nach keepFor
669
- * - "softDelete" → deletedAt = now() (mit core-soft-delete-Feature)
670
- * - "anonymize" → Felder mit `anonymize`-Funktion ueberschrieben,
671
- * Row bleibt
672
- * - "blockDelete" → Cleanup-Job ignoriert; User-Forget loest
673
- * stattdessen anonymize aus. Buchhaltung, Mandate,
674
- * Patientenakten.
675
- *
676
- * Siehe docs/plans/features/core-data-retention.md.
677
- */
678
- readonly retention?: RetentionDef;
679
- /**
680
- * Read-time computed fields, keyed by name. Not stored, not a DB column,
681
- * not writable — appended to each row by the list/detail query handler and
682
- * nameable as a column in a declarative `entityList`. See DerivedFieldDef.
683
- */
684
- readonly derivedFields?: DerivedFieldsMap;
685
- };
1
+ // Legacy path re-exported for callers still importing this module directly.
2
+ export * from "@cosmicdrift/kumiko-types/fields";