@cosmicdrift/kumiko-types 0.326.1 → 0.328.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 (173) hide show
  1. package/dist/action-icon.d.ts +3 -0
  2. package/dist/action-icon.js +51 -0
  3. package/{src/agent-exposure.ts → dist/agent-exposure.d.ts} +8 -15
  4. package/dist/agent-exposure.js +16 -0
  5. package/dist/concurrency-mode.d.ts +1 -0
  6. package/dist/config-scope.d.ts +1 -0
  7. package/dist/config-scope.js +1 -0
  8. package/dist/config.d.ts +234 -0
  9. package/dist/config.js +1 -0
  10. package/dist/cursor-types.d.ts +16 -0
  11. package/dist/cursor-types.js +1 -0
  12. package/dist/db-connection.d.ts +14 -0
  13. package/dist/db-connection.js +1 -0
  14. package/dist/define-handler.d.ts +58 -0
  15. package/dist/define-handler.js +1 -0
  16. package/dist/derivatives-types.d.ts +53 -0
  17. package/dist/derivatives-types.js +3 -0
  18. package/dist/entity-cache.d.ts +16 -0
  19. package/dist/entity-cache.js +1 -0
  20. package/dist/entity-handlers.d.ts +54 -0
  21. package/dist/entity-handlers.js +1 -0
  22. package/dist/entity-table-meta-types.d.ts +44 -0
  23. package/dist/entity-table-meta-types.js +6 -0
  24. package/dist/envelope-cipher-types.d.ts +5 -0
  25. package/dist/envelope-cipher-types.js +1 -0
  26. package/dist/event-store-executor-types.d.ts +86 -0
  27. package/dist/event-store-executor-types.js +1 -0
  28. package/dist/event-store-types.d.ts +37 -0
  29. package/dist/event-store-types.js +7 -0
  30. package/dist/event-type-map.d.ts +8 -0
  31. package/{src/event-type-map.ts → dist/event-type-map.js} +1 -16
  32. package/dist/executor-brand.d.ts +8 -0
  33. package/dist/executor-brand.js +1 -0
  34. package/dist/extension-options-map.d.ts +9 -0
  35. package/dist/extension-options-map.js +4 -0
  36. package/dist/feature.d.ts +518 -0
  37. package/dist/feature.js +1 -0
  38. package/dist/field-icon.d.ts +1 -0
  39. package/dist/field-icon.js +1 -0
  40. package/dist/fields.d.ts +729 -0
  41. package/dist/fields.js +50 -0
  42. package/dist/file-handle-types.d.ts +12 -0
  43. package/dist/file-handle-types.js +1 -0
  44. package/dist/file-provider-resolver-types.d.ts +3 -0
  45. package/dist/file-provider-resolver-types.js +1 -0
  46. package/dist/file-storage-provider-types.d.ts +17 -0
  47. package/dist/file-storage-provider-types.js +1 -0
  48. package/dist/geo-tz.d.ts +17 -0
  49. package/{src/geo-tz.ts → dist/geo-tz.js} +1 -20
  50. package/dist/handlers.d.ts +493 -0
  51. package/dist/handlers.js +49 -0
  52. package/dist/hook-phase.d.ts +1 -0
  53. package/dist/hook-phase.js +1 -0
  54. package/dist/hooks.d.ts +75 -0
  55. package/dist/hooks.js +1 -0
  56. package/dist/http-route.d.ts +52 -0
  57. package/dist/http-route.js +13 -0
  58. package/dist/identifiers.d.ts +8 -0
  59. package/{src/identifiers.ts → dist/identifiers.js} +7 -24
  60. package/dist/index.d.ts +1 -0
  61. package/dist/index.js +1 -0
  62. package/dist/kms-adapter-types.d.ts +68 -0
  63. package/dist/kms-adapter-types.js +49 -0
  64. package/dist/logger.d.ts +7 -0
  65. package/dist/logger.js +1 -0
  66. package/dist/multi-stream-apply-context-types.d.ts +18 -0
  67. package/dist/multi-stream-apply-context-types.js +1 -0
  68. package/dist/nav-icon.d.ts +3 -0
  69. package/dist/nav-icon.js +100 -0
  70. package/dist/nav.d.ts +39 -0
  71. package/dist/nav.js +1 -0
  72. package/dist/observability/index.d.ts +3 -0
  73. package/dist/observability/index.js +2 -0
  74. package/dist/observability/metric.d.ts +34 -0
  75. package/dist/observability/metric.js +3 -0
  76. package/dist/observability/provider.d.ts +24 -0
  77. package/dist/observability/provider.js +4 -0
  78. package/dist/observability/span.d.ts +32 -0
  79. package/dist/observability/span.js +4 -0
  80. package/dist/ownership.d.ts +33 -0
  81. package/dist/ownership.js +5 -0
  82. package/dist/projection.d.ts +38 -0
  83. package/dist/projection.js +1 -0
  84. package/dist/rate-limit-types.d.ts +19 -0
  85. package/dist/rate-limit-types.js +1 -0
  86. package/dist/relations.d.ts +25 -0
  87. package/dist/relations.js +1 -0
  88. package/dist/schema-table-types.d.ts +15 -0
  89. package/{src/schema-table-types.ts → dist/schema-table-types.js} +0 -20
  90. package/dist/screen.d.ts +1265 -0
  91. package/dist/screen.js +3 -0
  92. package/dist/search-adapter.d.ts +32 -0
  93. package/dist/search-adapter.js +1 -0
  94. package/dist/secrets-types.d.ts +52 -0
  95. package/dist/secrets-types.js +30 -0
  96. package/dist/snapshot-types.d.ts +13 -0
  97. package/dist/snapshot-types.js +1 -0
  98. package/dist/step.d.ts +304 -0
  99. package/dist/step.js +11 -0
  100. package/dist/target-ref.d.ts +5 -0
  101. package/{src/target-ref.ts → dist/target-ref.js} +1 -6
  102. package/dist/tenancy-brand.d.ts +6 -0
  103. package/dist/tenancy-brand.js +1 -0
  104. package/dist/tenant-db-types.d.ts +67 -0
  105. package/dist/tenant-db-types.js +5 -0
  106. package/dist/tree-node.d.ts +26 -0
  107. package/dist/tree-node.js +22 -0
  108. package/dist/tz-context.d.ts +48 -0
  109. package/dist/tz-context.js +6 -0
  110. package/dist/websocket-route.d.ts +46 -0
  111. package/dist/websocket-route.js +10 -0
  112. package/dist/where-clause-types.d.ts +20 -0
  113. package/dist/where-clause-types.js +1 -0
  114. package/dist/workspace.d.ts +10 -0
  115. package/dist/workspace.js +1 -0
  116. package/dist/write-error-info-types.d.ts +12 -0
  117. package/dist/write-error-info-types.js +1 -0
  118. package/package.json +362 -119
  119. package/src/changes.json +12 -0
  120. package/src/__tests__/handlers.test.ts +0 -40
  121. package/src/__tests__/plural-forms.test.ts +0 -18
  122. package/src/action-icon.ts +0 -57
  123. package/src/concurrency-mode.ts +0 -5
  124. package/src/config-scope.ts +0 -5
  125. package/src/config.ts +0 -563
  126. package/src/cursor-types.ts +0 -18
  127. package/src/db-connection.ts +0 -25
  128. package/src/define-handler.ts +0 -138
  129. package/src/derivatives-types.ts +0 -106
  130. package/src/entity-cache.ts +0 -35
  131. package/src/entity-handlers.ts +0 -68
  132. package/src/entity-table-meta-types.ts +0 -101
  133. package/src/envelope-cipher-types.ts +0 -6
  134. package/src/event-store-executor-types.ts +0 -163
  135. package/src/event-store-types.ts +0 -85
  136. package/src/executor-brand.ts +0 -19
  137. package/src/extension-options-map.ts +0 -21
  138. package/src/feature.ts +0 -1132
  139. package/src/field-icon.ts +0 -23
  140. package/src/fields.ts +0 -1114
  141. package/src/file-handle-types.ts +0 -20
  142. package/src/file-provider-resolver-types.ts +0 -7
  143. package/src/file-storage-provider-types.ts +0 -54
  144. package/src/handlers.ts +0 -1246
  145. package/src/hook-phase.ts +0 -2
  146. package/src/hooks.ts +0 -170
  147. package/src/http-route.ts +0 -78
  148. package/src/kms-adapter-types.ts +0 -128
  149. package/src/logger.ts +0 -7
  150. package/src/multi-stream-apply-context-types.ts +0 -52
  151. package/src/nav-icon.ts +0 -105
  152. package/src/nav.ts +0 -139
  153. package/src/observability/index.ts +0 -28
  154. package/src/observability/metric.ts +0 -56
  155. package/src/observability/provider.ts +0 -32
  156. package/src/observability/span.ts +0 -58
  157. package/src/ownership.ts +0 -83
  158. package/src/projection.ts +0 -170
  159. package/src/rate-limit-types.ts +0 -32
  160. package/src/relations.ts +0 -52
  161. package/src/screen.ts +0 -1593
  162. package/src/search-adapter.ts +0 -45
  163. package/src/secrets-types.ts +0 -182
  164. package/src/snapshot-types.ts +0 -36
  165. package/src/step.ts +0 -390
  166. package/src/tenancy-brand.ts +0 -9
  167. package/src/tenant-db-types.ts +0 -116
  168. package/src/tree-node.ts +0 -134
  169. package/src/tz-context.ts +0 -63
  170. package/src/where-clause-types.ts +0 -31
  171. package/src/workspace.ts +0 -42
  172. package/src/write-error-info-types.ts +0 -20
  173. /package/{src/index.ts → dist/concurrency-mode.js} +0 -0
package/src/fields.ts DELETED
@@ -1,1114 +0,0 @@
1
- // --- Field Types ---
2
-
3
- import type { VariantSpec } from "./derivatives-types";
4
-
5
- // OwnershipMap is declared in ./ownership.ts — field-access maps to
6
- // per-role ownership rules. A legacy `readonly string[]` form is still
7
- // accepted at the type layer during migration: features that pass an
8
- // array are auto-normalized to { [role]: "all" } at registry build.
9
- // Long-term: string[] disappears.
10
- import type { OwnershipMap } from "./ownership";
11
- import type { EntityTenancy } from "./tenancy-brand";
12
-
13
- export type FieldAccess = {
14
- readonly read?: OwnershipMap | readonly string[];
15
- readonly write?: OwnershipMap | readonly string[];
16
- };
17
-
18
- // `sensitive: true` — the field's value is excluded from caller-facing event
19
- // echoes in write responses (#820) and MUST be ciphertext-at-rest: boot
20
- // validation requires a subject annotation (pii / userOwned / tenantOwned)
21
- // or `encrypted: true` (#967). Event payloads carry the table ciphertext,
22
- // so replay reproduces the row byte-identically; plaintext never lands in
23
- // permanent history. Use for password hashes, API tokens, bank details,
24
- // tax IDs. See docs/plans/architecture/projections.md.
25
-
26
- // --- PII / Subject-Key Annotations (GDPR Art. 17) ---
27
- //
28
- // Four independent mechanisms, NOT interchangeable. Picking the wrong
29
- // `personal`/`find` combination is the recurring mistake this table exists
30
- // to prevent: `encrypted: true` looks like the strongest option and is the
31
- // only one with NO erasure guarantee.
32
- //
33
- // resolved flag | at rest | searchable | Art. 17 erasure
34
- // --------------------------------------------|------------|------------|---------------
35
- // (none) | plaintext | yes | no
36
- // allowPlaintext + anonymize | plaintext | yes | read side only
37
- // pii / userOwned / tenantOwned / recordOwned | ciphertext | yes * | yes, key erase
38
- // encrypted: true | ciphertext | no | NO
39
- //
40
- // * Subject-annotated + `searchable: true` (#1610): search consumer
41
- // decrypts into Meilisearch. Events/projection stay ciphertext.
42
- // eraseKey paths purge docs via purgeSearchDocumentsForSubject.
43
- // `lookupable: true` still covers equality (blind index). `sortable`
44
- // stays forbidden — sorting reads the ciphertext column.
45
- //
46
- // Encrypting a field is NOT a reason to make it unfindable. A user table you
47
- // cannot search by name is a user table with no working search. The line that
48
- // actually forbids search is `sensitive: true` — nobody may read those values
49
- // back, and the event-store executor already strips them before the search
50
- // consumer ever sees a payload. Password hashes, API tokens, bank details:
51
- // never indexed. Email, username, display name: encrypted at rest, findable.
52
- //
53
- // Why `encrypted: true` erases nothing: it uses the app-wide master key,
54
- // which is never destroyed per subject. Encryption-at-rest, nothing more.
55
- // Only a subject annotation binds the field to a per-subject key that
56
- // `crypto-shredding:write:forget-subject` (or the tenant-destroy hook's
57
- // eraseSubjectKeys) can destroy, which makes every event and every
58
- // projection copy of that subject unreadable at once.
59
- //
60
- // Why `anonymize` only covers the read side: it overwrites projection rows.
61
- // `kumiko_events` is append-only and keeps the original payload forever
62
- // (archiveStream stops REPLAY, it does not delete the row). A plaintext
63
- // field stays permanently readable in the event log for anyone with database
64
- // access — an access + retention question, not a crypto one. Choose it
65
- // deliberately, not by forgetting to annotate.
66
- //
67
- // Which subject owns the field — expressed via `personal` on the field
68
- // factory, resolved to these flags:
69
- // - `personal: "self"` → `pii: true`. the entity itself, user.email.
70
- // - `personal: { of: "<f>" }` → `userOwned: { ownerField: "<f>" }`. the
71
- // user referenced in that field.
72
- // comment.body → comment.authorId.
73
- // - `personal: "tenant"` → `tenantOwned: true`. the current tenant
74
- // (ctx.tenantId at write time).
75
- // tenantBranding.brandColor.
76
- // - `personal: "ref"` → `subjectRef: true`. FK into `user` with no
77
- // annotated content of its own (authorId,
78
- // assigneeId).
79
- // - `personal: { of: "id" }` → `recordOwned: true`. the row itself is the
80
- // subject — free-text content with no user
81
- // reference of its own (a support note keyed
82
- // only by its own row id).
83
- // - `personal: false, reason` → `allowPlaintext: "<reason>"`. deliberately
84
- // plaintext; `reason` is mandatory snake_case.
85
- //
86
- // How findable it must stay — `find`, mandatory whenever `personal` names a
87
- // subject ("self" / { of } / "tenant"):
88
- // - `find: "exact"` → `lookupable: true`. equality only, via blind index.
89
- // - `find: "fuzzy"` → `lookupable: true, searchable: true`. substring/
90
- // full-text via the Meilisearch consumer (#1610).
91
- // - `find: "none"` → neither.
92
- // - `find: "secret"` → `sensitive: true`. longText fields only allow
93
- // "none" | "secret" — no lookup/search machinery there.
94
- //
95
- // Worked examples, all live in this repo:
96
- //
97
- // user.email (user/schema/user.ts) — login identifier, must stay findable.
98
- // `personal: "self", find: "exact"`. Ciphertext everywhere, exact lookup
99
- // via the blind index, gone when the user's subject key dies.
100
- //
101
- // user.displayName (same file) — real name; substring search via Meili.
102
- // `personal: "self", find: "fuzzy"` (#1610). Ciphertext in
103
- // events/projection; plaintext only in the derived index; purged on
104
- // forget.
105
- //
106
- // ledger.description (ledger/entity.ts) — "Miete Januar" is accounting
107
- // data that happens to read like PII, and full-text search over it is the
108
- // point. `personal: false, reason: "is_business_data"`, cleaned up via
109
- // retention, event log keeps the original. Accepted deliberately.
110
- //
111
- // subscription.providerCustomerId (billing-foundation/entities.ts) — a
112
- // Mollie/Stripe id, never searched. `personal: "tenant", find: "none"`,
113
- // explicitly NOT `encrypted: true`: tenant-destroy erases the tenant
114
- // subject key, and only the subject path shreds along with it (#800).
115
- // Budget maxLength for ciphertext
116
- // (`kumiko-pii:v1:<subject>:<blob>`, roughly plaintext × 2.3).
117
- //
118
- // `sensitive: true` is orthogonal to all of this — see its own note above.
119
- //
120
- // `anonymize` is the per-field function the retention-cleanup job calls when
121
- // the entity strategy is "anonymize" or a `blockDelete` deadline expired.
122
- // Sync or async (async when the pseudonym needs a lookup); the job awaits it.
123
- // Example: `() => "[ANONYMIZED]"` or `() => null`.
124
- //
125
- // Background specs (both shipped, kumiko-platform/docs/archive/plans/):
126
- // datenschutz/crypto-shredding.md, datenschutz/blind-index.md.
127
- // Internal, expanded flag shape a field definition carries after a factory
128
- // (createTextField, createTimestampField, ...) resolves the author-facing
129
- // `personal`/`find` annotations below. No field author sets this directly.
130
- export type ResolvedPiiFlags = {
131
- readonly pii?: boolean;
132
- readonly userOwned?: { readonly ownerField: string };
133
- readonly tenantOwned?: boolean;
134
- readonly recordOwned?: true;
135
- readonly anonymize?: () => unknown | Promise<unknown>;
136
- readonly allowPlaintext?: string;
137
- /** Equality-Lookups (fetchOne/filter eq) bleiben trotz Verschluesselung
138
- * moeglich: generierte `<snake>_bidx`-Spalte traegt einen HMAC-Blind-
139
- * Index, Query-Compiler matchen `(col = $1 OR col_bidx = $2)`. Nur auf
140
- * text-Feldern MIT Subject-Annotation erlaubt (Boot-Validator).
141
- * Siehe docs/plans/datenschutz/blind-index.md (kumiko-framework#818). */
142
- readonly lookupable?: true;
143
- /** Foreign key into the `user` entity (authorId, assigneeId, ...) with no
144
- * annotated content of its own. Triggers no encryption — feeds only the
145
- * GDPR-hook-coverage boot guard, which otherwise can't see subject data
146
- * that lives purely in an FK column. */
147
- readonly subjectRef?: true;
148
- };
149
-
150
- // --- Author-facing PII API (kumiko-framework#2250) ---
151
- //
152
- // Two questions instead of the twelve raw flags above: whose data is this
153
- // (`personal`, resolved to a subject key) and how findable must it stay
154
- // (`find`, resolved to the search/lookup flags). Field factories expand
155
- // these into `ResolvedPiiFlags` — see packages/framework/src/engine/factories.ts.
156
- export type PersonalSubject = "self" | { readonly of: string } | "tenant";
157
- export type Findability = "exact" | "fuzzy" | "none" | "secret";
158
-
159
- // Marks "no personal annotation given" — plain `Record<string, never>` would
160
- // force every OTHER intersected property (e.g. a factory's `required?: R`)
161
- // to `never` too, since its index signature applies to all string keys.
162
- // Naming the three annotation keys explicitly avoids that trap.
163
- //
164
- // kumiko-framework#2921: no longer part of PersonalAnnotations /
165
- // PersonalAnnotationsLongText — text and longText fields must declare an
166
- // explicit stance. It still applies to PersonalAnnotationsNoFind, whose
167
- // factories keep `personal` optional.
168
- type NoPersonalAnnotation = {
169
- readonly personal?: never;
170
- readonly find?: never;
171
- readonly reason?: never;
172
- readonly anonymize?: never;
173
- };
174
-
175
- export type PersonalAnnotations =
176
- | {
177
- readonly personal: PersonalSubject;
178
- readonly find: Findability;
179
- readonly anonymize?: () => unknown | Promise<unknown>;
180
- }
181
- | { readonly personal: "ref" }
182
- | {
183
- readonly personal: false;
184
- readonly reason: string;
185
- readonly anonymize?: () => unknown | Promise<unknown>;
186
- };
187
-
188
- // longText has no lookupable/searchable machinery ("Kein searchable" — see
189
- // LongTextFieldDef doc) — only "does this need to stay a secret" applies.
190
- // Kept mandatory like `find` on PersonalAnnotations: no silent default.
191
- export type LongTextFindability = "none" | "secret";
192
-
193
- export type PersonalAnnotationsLongText =
194
- | {
195
- readonly personal: PersonalSubject;
196
- readonly find: LongTextFindability;
197
- readonly anonymize?: () => unknown | Promise<unknown>;
198
- }
199
- | { readonly personal: "ref" }
200
- | {
201
- readonly personal: false;
202
- readonly reason: string;
203
- readonly anonymize?: () => unknown | Promise<unknown>;
204
- };
205
-
206
- // Same as PersonalAnnotations but without `find` — for field types with no
207
- // full-text/blind-index machinery (everything except text and longText).
208
- export type PersonalAnnotationsNoFind =
209
- | {
210
- readonly personal: PersonalSubject;
211
- readonly anonymize?: () => unknown | Promise<unknown>;
212
- }
213
- | { readonly personal: "ref" }
214
- | {
215
- readonly personal: false;
216
- readonly reason: string;
217
- readonly anonymize?: () => unknown | Promise<unknown>;
218
- }
219
- | NoPersonalAnnotation;
220
-
221
- // --- Retention (DSGVO Art. 5(1)(e) + HGB/AO Aufbewahrungspflichten) ---
222
- //
223
- // Pro Entity definiert der Author eine Default-Retention-Policy. Tenant-
224
- // Admin uebersteuert sie via Compliance-Profile + Tenant-Override (Sprint 2).
225
- // Vier Strategien:
226
- //
227
- // - "hardDelete" — Row physisch weg nach `keepFor`. Logs, Sessions.
228
- // - "softDelete" — `deletedAt = now()`. Erlaubt spaetere Restore.
229
- // - "anonymize" — Felder mit `anonymize`-Funktion ueberschrieben,
230
- // Row bleibt. Order/Invoice mit gemischter PII +
231
- // Geschaeftsdaten.
232
- // - "blockDelete" — Cleanup-Job ignoriert; User-Forget loest stattdessen
233
- // `anonymize` aus. Buchhaltung, Mandate, Patientenakten.
234
- //
235
- // `keepFor` ist eine Duration-String wie "30d", "10y", "6m". Parser
236
- // kommt im Cleanup-Job (Sprint 2). `reference` ist das Field das den
237
- // Lebenszeit-Anker liefert (Default: `createdAt`). Sessions z.B. nutzen
238
- // `lastSeenAt` damit aktive Sessions nicht weggemueht werden.
239
- //
240
- // Siehe docs/plans/features/core-data-retention.md und Sprint 2 in roadmap.md.
241
- export type RetentionDef = {
242
- readonly keepFor: string;
243
- readonly strategy: "hardDelete" | "softDelete" | "anonymize" | "blockDelete";
244
- readonly reference?: string;
245
- };
246
-
247
- export type TextFieldDef = {
248
- readonly type: "text";
249
- /** Optional free-text semantics for the AI agent manifest — never rendered;
250
- * the i18n label is what users see. */
251
- readonly description?: string;
252
- readonly maxLength?: number;
253
- readonly required?: boolean;
254
- readonly searchable?: boolean;
255
- readonly sortable?: boolean;
256
- /** Author erlaubt Screen-Filter auf diesem Feld (Tier 2.7c).
257
- * Boot-Validator weist Filter mit `filterable !== true` zurück.
258
- * Default: false — analog zu `sortable`, opt-in. */
259
- readonly filterable?: boolean;
260
- readonly encrypted?: boolean;
261
- readonly sensitive?: boolean;
262
- /** A later run reads this field's value as an instruction — prompt, rule,
263
- * template. Every create/update whose payload writes it requires agent.risk
264
- * "high" on the directly-dispatched entry handler; a revert doesn't undo a
265
- * run that already read the compromised value. See irreversible-operation-gate. */
266
- readonly readAsInstruction?: boolean;
267
- /** "password" is a pure render hint (masked input) — no storage semantics. */
268
- readonly format?: "email" | "url" | "phone" | "password";
269
- readonly default?: string;
270
- readonly access?: FieldAccess;
271
- /** Mehrzeiliger Text — DefaultInput rendert dann ein <textarea> statt
272
- * <input type="text">. `true` = Default 4 Zeilen, `{ rows: N }` =
273
- * explizite Höhe. Search/sort/encrypt verhalten sich unverändert
274
- * identisch zu single-line — nur die Render-Surface wechselt. */
275
- readonly multiline?: boolean | { readonly rows?: number };
276
- } & ResolvedPiiFlags;
277
-
278
- /**
279
- * Long-form text content — source-code, markdown, blog-posts, email-
280
- * templates, anything that can be megabytes large. Bewusst MINIMALE
281
- * Surface gegen `text`:
282
- *
283
- * - **Kein `sortable`**: ORDER BY auf 100 KB-Strings kostet I/O ohne
284
- * sinnvolles UX-Outcome (lex-Sortierung von Code ist Nonsense).
285
- * - **Kein `searchable`**: ILIKE/Substring-Suche auf langen Texten
286
- * skaliert nicht. Wer wirklich Volltextsuche will, nimmt den
287
- * SearchAdapter (Meilisearch) — der hat eine eigene Pipeline mit
288
- * Tokenizer + Index, NICHT diesen field-flag.
289
- * - **Kein `filterable`**: WHERE auf langen Strings same Story wie
290
- * sortable.
291
- * - **Kein `format`**: email/url/phone sind kurz definierte Inputs,
292
- * longText ist per Definition unstrukturiert.
293
- *
294
- * Type-level enforcement statt convention: wer sortable/searchable
295
- * braucht, nimmt `text` (mit den entsprechenden Skalierungs-Trade-offs).
296
- * DB-mapping ist identisch zu text (Postgres `text` ist unbounded).
297
- */
298
- export type LongTextFieldDef = {
299
- readonly type: "longText";
300
- readonly description?: string;
301
- /** Optionale soft-Cap. Default unbounded (= Postgres-text-limit, 1 GB).
302
- * Nützlich für defensive Caps wie 1 MB damit ein verirrter Browser-
303
- * Paste nicht die DB sprengt. */
304
- readonly maxLength?: number;
305
- readonly required?: boolean;
306
- readonly encrypted?: boolean;
307
- readonly sensitive?: boolean;
308
- /** A later run reads this field's value as an instruction — prompt, rule,
309
- * template. Every create/update whose payload writes it requires agent.risk
310
- * "high" on the directly-dispatched entry handler; a revert doesn't undo a
311
- * run that already read the compromised value. See irreversible-operation-gate. */
312
- readonly readAsInstruction?: boolean;
313
- readonly default?: string;
314
- readonly access?: FieldAccess;
315
- readonly multiline?: boolean | { readonly rows?: number };
316
- } & ResolvedPiiFlags;
317
-
318
- export type BooleanFieldDef = {
319
- readonly type: "boolean";
320
- readonly description?: string;
321
- readonly required?: boolean;
322
- readonly sortable?: boolean;
323
- readonly filterable?: boolean;
324
- readonly sensitive?: boolean;
325
- readonly default?: boolean;
326
- readonly access?: FieldAccess;
327
- };
328
-
329
- export type SelectFieldDef<TOptions extends readonly string[] = readonly string[]> = {
330
- readonly type: "select";
331
- readonly description?: string;
332
- readonly options: TOptions;
333
- readonly required?: boolean;
334
- readonly sortable?: boolean;
335
- readonly filterable?: boolean;
336
- readonly sensitive?: boolean;
337
- readonly default?: TOptions[number];
338
- readonly access?: FieldAccess;
339
- /** Renderer metadata: `"radio"` always renders the option set as a visible
340
- * radio group, `"dropdown"` always renders the combobox. Omitted = the
341
- * renderer's own heuristic picks (radio group for a few short labels,
342
- * dropdown otherwise). A `"radio"` group with many or long labels wraps —
343
- * the author owns that trade-off, the renderer does not override it. */
344
- readonly display?: "radio" | "dropdown";
345
- } & ResolvedPiiFlags;
346
-
347
- // Mehrere Werte aus einer festen Options-Liste — UI rendert als
348
- // Checkbox-/Multi-Select-Kontrolle. Storage: jsonb-Array<string>;
349
- // jeder Eintrag muss in `options` enthalten sein.
350
- //
351
- // Wann statt `select`: wenn der User mehr als einen Wert gleichzeitig
352
- // auswählen darf (Führerscheinklassen, Tags, Sprachen, Skills).
353
- // Wann statt `embedded` mit Booleans: wenn die Option-Liste nicht
354
- // hardcoded sein soll oder bei mehr als ~5 Optionen — sonst explodiert
355
- // das embedded-Schema.
356
- //
357
- // Ordering: das Array bewahrt die Caller-Reihenfolge (jsonb-array, nicht
358
- // set). Das Framework dedupliziert beim Schreiben nicht — Validator
359
- // rejected Duplikate erst wenn Bedarf da ist.
360
- export type MultiSelectFieldDef<TOptions extends readonly string[] = readonly string[]> = {
361
- readonly type: "multiSelect";
362
- readonly description?: string;
363
- readonly options: TOptions;
364
- readonly required?: boolean;
365
- readonly filterable?: boolean;
366
- readonly sensitive?: boolean;
367
- /** Default-Auswahl. Jeder Eintrag muss in `options` sein (Boot-Validator). */
368
- readonly default?: readonly TOptions[number][];
369
- readonly access?: FieldAccess;
370
- /** Renderer metadata: `"checkboxes"` renders every option as a visible
371
- * checkbox with a select-all toggle. Default (omitted) stays the combobox
372
- * dropdown. */
373
- readonly display?: "checkboxes" | "dropdown";
374
- /** Renderer metadata: column count for the `"checkboxes"` grid at the widest
375
- * breakpoint; narrow viewports always collapse to one column. Omitted =
376
- * the renderer picks a sensible count. Ignored unless
377
- * `display: "checkboxes"`. */
378
- readonly columns?: 1 | 2 | 3 | 4;
379
- /** Renderer metadata: how many grid rows stay visible before the
380
- * `"checkboxes"` grid becomes scrollable. Omitted = the grid grows with its
381
- * content and never scrolls. Ignored unless `display: "checkboxes"`. */
382
- readonly maxRows?: number;
383
- } & ResolvedPiiFlags;
384
-
385
- /**
386
- * Storage: `integer` flag decides the Postgres column type, not just Zod
387
- * validation. `integer: true` → `integer` column (32-bit, ~±2.1 billion),
388
- * write-boundary enforces `.int()`. Omitted/`false` (the default) →
389
- * `double precision` column — fractional values are accepted end to end.
390
- * Need exact decimal storage instead of a binary float (money-adjacent
391
- * math, no representation error)? Use `createDecimalField` (`numeric`).
392
- */
393
- export type NumberFieldDef = {
394
- readonly type: "number";
395
- readonly description?: string;
396
- readonly required?: boolean;
397
- readonly sortable?: boolean;
398
- readonly filterable?: boolean;
399
- readonly sensitive?: boolean;
400
- readonly default?: number;
401
- readonly access?: FieldAccess;
402
- readonly min?: number;
403
- /** Upper bound at the write boundary (Zod `.max`). */
404
- readonly max?: number;
405
- /** `true` → `integer` column + `.int()` Zod validation. Omitted/`false` →
406
- * `double precision` column, fractional values allowed. */
407
- readonly integer?: boolean;
408
- /** Display-only suffix: a static string, or a sibling field name whose
409
- * value supplies it. Never converts the stored number. */
410
- readonly unit?: string | { readonly field: string };
411
- /** Thousands-grouping in the rendered display (Intl `useGrouping`).
412
- * Default `true`. Set `false` for values that read wrong grouped (e.g. a
413
- * model year: "2021", not "2.021"). Never affects the stored value. */
414
- readonly grouping?: boolean;
415
- } & ResolvedPiiFlags;
416
-
417
- /**
418
- * 64-bit-Integer-Spalte fuer Audit-Counter, Byte-Sizes, Event-IDs und
419
- * andere Werte die >2^31 (~2.1 Mrd) wandern koennen. Storage als
420
- * Postgres `bigint`, JS-Round-trip als `number` (mode:"number" — sicher
421
- * bis 2^53 ≈ 9 PB, JSON-serialisierbar). Wer >2^53 braucht (rare),
422
- * nutzt einen `text`-Field mit eigenem Codec.
423
- *
424
- * Vorrang vor `NumberFieldDef`-(integer 32-bit-Cap, ~2.1 GB) immer dann
425
- * wenn der Wert physisch ueber dieses Limit klettern kann: Bytes,
426
- * Events, Counters in High-Throughput-Apps, Cumulative-Sums. Money
427
- * hat dafuer den eigenen `MoneyFieldDef` (mit Currency-Spalte).
428
- */
429
- export type BigIntFieldDef = {
430
- readonly type: "bigInt";
431
- readonly description?: string;
432
- readonly required?: boolean;
433
- readonly sortable?: boolean;
434
- readonly filterable?: boolean;
435
- readonly sensitive?: boolean;
436
- readonly default?: number;
437
- readonly access?: FieldAccess;
438
- } & ResolvedPiiFlags;
439
-
440
- /**
441
- * Exact decimal — Postgres `numeric(precision, scale)`. For values that need
442
- * fractional precision the integer `number` field (32-bit int) and `money`
443
- * field (BIGINT minor units + currency) can't hold: interest rates,
444
- * percentages, ratios, measurements.
445
- *
446
- * `precision` = total significant digits, `scale` = digits after the decimal
447
- * point (both required — no silent default that could truncate). pg returns
448
- * `numeric` as a string to preserve precision; the read-codec surfaces it as
449
- * a JS `number` (safe ≤ 2^53, same trade-off as `bigInt` mode:"number" — a
450
- * value past that boundary loses precision, so keep `precision - scale` ≤ 15).
451
- */
452
- export type DecimalFieldDef = {
453
- readonly type: "decimal";
454
- readonly description?: string;
455
- readonly precision: number;
456
- readonly scale: number;
457
- readonly required?: boolean;
458
- readonly sortable?: boolean;
459
- readonly filterable?: boolean;
460
- readonly sensitive?: boolean;
461
- readonly default?: number;
462
- readonly access?: FieldAccess;
463
- } & ResolvedPiiFlags;
464
-
465
- /** Where a money field's currency comes from. `"tenant"` is the
466
- * tenant-settings bundle's per-tenant currency (`tenant-settings:config:
467
- * currency`, kumiko-framework#2933); `"literal"` names a fixed ISO code and
468
- * is checked against the app's `currencies` list at boot
469
- * (kumiko-framework#2839). Discriminated union so a `{ kind: "field", of }`
470
- * variant (amount and currency-picker side by side) can still be added
471
- * without a breaking change, once a multi-currency form actually exists. */
472
- export type MoneyCurrencySource =
473
- | { readonly kind: "tenant" }
474
- | { readonly kind: "literal"; readonly code: string };
475
-
476
- /** Qualified config key a `{ kind: "tenant" }` MoneyCurrencySource resolves
477
- * against — the tenant-settings bundle's per-tenant currency. Lives here
478
- * (client-safe) rather than framework/engine so renderer code can read it
479
- * without a runtime-only import (kumiko-framework#2937). */
480
- export const TENANT_CURRENCY_CONFIG_KEY = "tenant-settings:config:currency";
481
-
482
- export type MoneyFieldDef = {
483
- readonly type: "money";
484
- readonly description?: string;
485
- readonly required?: boolean;
486
- readonly sortable?: boolean;
487
- readonly filterable?: boolean;
488
- readonly sensitive?: boolean;
489
- readonly access?: FieldAccess;
490
- /** Declares where this field's currency comes from for a currently-empty
491
- * value in an entityEdit form. Undefined = unchanged existing behaviour
492
- * (`entity.defaultCurrency ?? "EUR"`); a stored value always keeps its
493
- * own currency regardless of this declaration. */
494
- readonly currency?: MoneyCurrencySource;
495
- };
496
-
497
- // Reference-Field (Tier 2.7e-3) — FK-Style Verweis auf eine andere
498
- // Entity. Gespeichert als UUID-Spalte (uuid type), Read-Side liefert
499
- // optional die referenced Row mit (Tier 2.7e-4 eagerload).
500
- //
501
- // `entity` akzeptiert zwei Formen:
502
- // - kurz ("customer") — same-feature reference, Default-Pfad.
503
- // - qualifiziert ("users:user") — cross-feature, Format
504
- // "<featureName>:<entityName>". Renderer baut die Lookup-Query-QN
505
- // gegen das angegebene Feature (`users:query:user:list`).
506
- //
507
- // `labelField` (optional) — welches Feld der referenced Entity wird
508
- // im Select-Dropdown als Label gezeigt. Default: "id". Best practice
509
- // ist ein menschlich-lesbares Feld wie "name", "title", "email".
510
- export type ReferenceFieldDef = {
511
- readonly type: "reference";
512
- readonly description?: string;
513
- readonly entity: string;
514
- readonly required?: boolean;
515
- readonly filterable?: boolean;
516
- readonly sensitive?: boolean;
517
- readonly access?: FieldAccess;
518
- /** Welches Feld der referenced Entity als Display-Label im
519
- * Select-Dropdown erscheint. Default: "id". Boot-Validator pinst
520
- * dass das Feld auf der referenced Entity existiert. */
521
- readonly labelField?: string;
522
- /** Multi-Reference (Tier 2.7e-Multi): Wert ist ein Array von UUIDs
523
- * statt single UUID. Storage als jsonb-Array<uuid>. UI rendert
524
- * Multi-Select-Combobox mit Tag-Anzeige der gewählten Items. */
525
- readonly multiple?: boolean;
526
- /** Include a row in this entity's result set on a text-search hit against
527
- * the referenced entity's `labelField`, instead of only matching the UUID
528
- * column itself (fw#2660). Requires an explicit `labelField` — the boot
529
- * validator rejects it otherwise, since the default "id" is a UUID column
530
- * and ILIKE against it would crash at runtime. */
531
- readonly searchable?: true;
532
- /** Order a list by the referenced entity's `labelField` instead of by the raw
533
- * UUID column (fw#2741). Requires an explicit, non-"id" `labelField` — the
534
- * boot validator rejects it otherwise, since ordering by a UUID produces a
535
- * sequence that looks meaningful to the user but is arbitrary. */
536
- readonly sortable?: true;
537
- /** Fill the picker from this query handler instead of the referenced
538
- * entity's own list handler (fw#2780) — for targets whose identity is
539
- * composed from joined rows, where no single column of the referenced
540
- * entity is a readable label. Fully-qualified QN
541
- * ("<feature>:query:<short>"), boot-validated against the registered
542
- * handlers; result contract `{ rows: { id, label }[] }`, called with
543
- * `{ limit, search? }` like the default list handler. `id` rather than the
544
- * `{ value, label }` of `DashboardFilterDefinition.optionsQuery`, because
545
- * the stored value here IS the referenced row's id.
546
- *
547
- * Additive to `labelField`, which keeps serving the paths a query handler
548
- * cannot back: list cells, `searchable` and `sortable` all resolve to an
549
- * SQL column on the referenced table.
550
- *
551
- * The handler must return the row for an id that is already stored, even
552
- * when it otherwise curates its result (say, to active records only) —
553
- * the read-only display resolves a stored value against these rows and
554
- * falls back to the raw id for one it does not find. */
555
- readonly optionsQuery?: string;
556
- } & ResolvedPiiFlags;
557
-
558
- // --- Currency ---
559
-
560
- // Keep in sync with DEFAULT_CURRENCIES in ../field-helpers.ts.
561
- export type DefaultCurrency =
562
- | "EUR"
563
- | "USD"
564
- | "GBP"
565
- | "CHF"
566
- | "JPY"
567
- | "SEK"
568
- | "NOK"
569
- | "DKK"
570
- | "PLN"
571
- | "CZK"
572
- | "CAD"
573
- | "AUD"
574
- | "NZD"
575
- | "CNY"
576
- | "INR";
577
-
578
- // --- Embedded Object ---
579
-
580
- type EmbeddedSubFieldBase = {
581
- readonly required?: boolean;
582
- readonly searchable?: boolean;
583
- readonly access?: FieldAccess;
584
- };
585
-
586
- /** `money` is a signed integer amount in minor units (cents); which currency
587
- * it means is defined by the owning aggregate (a head-level field or tenant
588
- * config), not per row. Unlike the top-level `money` field (BIGINT + currency
589
- * column) the value lives in jsonb, so it must stay within ±(2^53 − 1).
590
- *
591
- * `decimal` bounds a JSON number to `scale` fractional digits (0–15). `scale`
592
- * is required — no silent default that could truncate — and there is no
593
- * `precision`: jsonb has no numeric column behind it whose width `precision`
594
- * could describe. */
595
- export type EmbeddedSubFieldDef =
596
- | (EmbeddedSubFieldBase & {
597
- readonly type: "text" | "number" | "boolean" | "date" | "money" | "timestamp";
598
- })
599
- | (EmbeddedSubFieldBase & {
600
- readonly type: "decimal";
601
- readonly scale: number;
602
- })
603
- | (EmbeddedSubFieldBase & {
604
- readonly type: "select";
605
- readonly options: readonly string[];
606
- })
607
- | (EmbeddedSubFieldBase & {
608
- readonly type: "reference";
609
- readonly entity: string;
610
- readonly labelField?: string;
611
- /** Same contract as `ReferenceFieldDef.optionsQuery` (fw#2780). */
612
- readonly optionsQuery?: string;
613
- });
614
-
615
- /** A cell computed from other cells of the same row. `from` names the
616
- * sub-fields the operation reads (order matters for `subtract`). Renderer
617
- * metadata — the derived value is rounded server- and client-side to the
618
- * precision its target sub-field in `schema` declares (money → integer,
619
- * decimal → `scale`) before it's validated. */
620
- export type EmbeddedDerivedCellDef = {
621
- readonly op: "multiply" | "sum" | "subtract";
622
- readonly from: readonly string[];
623
- };
624
-
625
- export type EmbeddedFieldDef = {
626
- readonly type: "embedded";
627
- readonly description?: string;
628
- readonly required?: boolean;
629
- readonly sensitive?: boolean;
630
- readonly schema: Readonly<Record<string, EmbeddedSubFieldDef>>;
631
- readonly access?: FieldAccess;
632
- /** A list of like-shaped objects instead of exactly one: the value is an
633
- * array and every element is validated against `schema`. Storage stays
634
- * jsonb, the column default becomes `[]` instead of `{}`. Built via
635
- * `createEmbeddedListField()`.
636
- *
637
- * Only for rows that come into being with their head and stay immutable
638
- * with it (posting lines, invoice positions). A row with its own lifetime
639
- * (own from/to dates, own history) belongs in its own entity referencing
640
- * the head — an embedded array is rewritten whole on every change and has
641
- * no per-row history. */
642
- readonly multiple?: boolean;
643
- /** Minimum row count for an embedded list. Only valid with `multiple: true`. */
644
- readonly minItems?: number;
645
- /** Maximum row count for an embedded list. Only valid with `multiple: true`. */
646
- readonly maxItems?: number;
647
- /** Renderer metadata: sub-field name → how to compute its cell from other
648
- * sub-fields of the same row, rounded to that sub-field's declared
649
- * precision. Only valid with `multiple: true`. */
650
- readonly derived?: Readonly<Record<string, EmbeddedDerivedCellDef>>;
651
- /** Renderer metadata: numeric sub-field names to sum in a totals row. Only
652
- * valid with `multiple: true`. */
653
- readonly totals?: readonly string[];
654
- /** Maps an embedded-list subfield name to a sibling top-level money field
655
- * name — the sum of that subfield across all rows must equal the sibling
656
- * field's amount. Validated both client- and server-side (schema-builder
657
- * superRefine). Only meaningful with `multiple: true`. Compares against
658
- * rounded `derived` cells, i.e. "sum-of-rounded" not "round-of-sum" —
659
- * see kumiko-framework#1866. */
660
- readonly totalsMatch?: Readonly<Record<string, string>>;
661
- } & ResolvedPiiFlags;
662
-
663
- // Free-form jsonb — keys/shape NOT validated at write-time. Use for:
664
- // - Tenant-defined extension data (custom-fields-bundle uses this for
665
- // `customFields` on host-entities — keys are dynamic per fieldDefinition)
666
- // - Configuration-blobs with shape that evolves outside Stammfeld-schema
667
- // - AI-inferred metadata where shape is provider-dependent
668
- //
669
- // Vs. embedded: embedded enforces a typed sub-schema; jsonb accepts any
670
- // JSON-shaped object. Read-side both map to Postgres `jsonb`. Default `{}`
671
- // + NOT NULL, identisch zu embedded.
672
- export type JsonbFieldDef = {
673
- readonly type: "jsonb";
674
- readonly description?: string;
675
- readonly sensitive?: boolean;
676
- /** A later run reads this field's value as an instruction — prompt, rule,
677
- * template. Every create/update whose payload writes it requires agent.risk
678
- * "high" on the directly-dispatched entry handler; a revert doesn't undo a
679
- * run that already read the compromised value. See irreversible-operation-gate. */
680
- readonly readAsInstruction?: boolean;
681
- readonly access?: FieldAccess;
682
- } & ResolvedPiiFlags;
683
-
684
- // Legacy "date" — JS-Date-Object, semantisch unklar (Wall-Clock vs Instant).
685
- // Für neue Felder bevorzuge:
686
- // - `timestamp` für UTC-Instant ("wann ist das passiert")
687
- // - `createLocatedTimestampField()` für Termine die an einem Ort
688
- // stattfinden ("Pickup um 10:00 in Lissabon")
689
- // - (kommt) `plainDate` für Kalender-Daten ohne Uhrzeit (z.B. Geburtstag)
690
- // Siehe docs/plans/architecture/timezones.md
691
- export type DateFieldDef = {
692
- readonly type: "date";
693
- readonly description?: string;
694
- readonly required?: boolean;
695
- readonly sortable?: boolean;
696
- readonly filterable?: boolean;
697
- readonly sensitive?: boolean;
698
- readonly access?: FieldAccess;
699
- /** Erlaubte Datumsgrenzen als ISO `yyyy-mm-dd` (z.B. Geburtsdatum nicht
700
- * in der Zukunft: `max` = heute). Begrenzt den Picker und wird vom
701
- * Zod-Schema beim Write durchgesetzt. */
702
- readonly min?: string;
703
- readonly max?: string;
704
- /** Format/Locale-Override für Anzeige und Eingabe-Parsing (z.B.
705
- * "de-DE"). Default = App-Locale. */
706
- readonly locale?: string;
707
- } & ResolvedPiiFlags;
708
-
709
- // UTC-Instant (Temporal.Instant). Für Ereignisse die zu einem bestimmten
710
- // Augenblick passieren, ohne Location-Bezug: createdAt, loginAt, actualPickupAt.
711
- // JSON-Form: ISO-UTC-String "2026-04-18T10:00:00Z" via .toJSON().
712
- //
713
- // Mit `locatedBy: "<name>Tz"` markiert: bildet ein Wall-Clock+TZ-Pair mit dem
714
- // referenzierten tz-Feld. JSON-Form wird dann zwei Felder ({ at, tz }), DB
715
- // speichert Wall-Clock+tz und konvertiert transparent (siehe DB-Wrapper,
716
- // kommt in einer späteren Iteration).
717
- //
718
- // Für neue Felder bevorzuge `createLocatedTimestampField()` — EIN atomares
719
- // Feld statt eines lose verdrahteten Pairs (siehe LocatedTimestampFieldDef).
720
- export type TimestampFieldDef = {
721
- readonly type: "timestamp";
722
- readonly description?: string;
723
- readonly required?: boolean;
724
- readonly sortable?: boolean;
725
- readonly filterable?: boolean;
726
- readonly sensitive?: boolean;
727
- readonly access?: FieldAccess;
728
- /**
729
- * Marker: dieses Timestamp-Feld ist Wall-Clock-Zeit an einem Ort.
730
- * Wert ist der Name des begleitenden tz-Felds (IANA-Zone).
731
- *
732
- * Beispiel: manuelles Pair
733
- * { pickupAt: { type: "timestamp", locatedBy: "pickupTz" }, pickupTz: { type: "tz" } }
734
- * — bevorzuge stattdessen `createLocatedTimestampField()`.
735
- */
736
- readonly locatedBy?: string;
737
- /** Erlaubte Grenzen als ISO-Datetime. Begrenzt den Picker auf
738
- * Tages-Granularität; die exakte Uhrzeit-Grenze setzt das Zod-Schema
739
- * beim Write durch. */
740
- readonly min?: string;
741
- readonly max?: string;
742
- /** Format/Locale-Override für Anzeige und Eingabe-Parsing. Default =
743
- * App-Locale. */
744
- readonly locale?: string;
745
- } & ResolvedPiiFlags;
746
-
747
- // IANA-Zonenname (z.B. "Europe/Berlin", "America/Los_Angeles").
748
- // Wird via `Intl.supportedValuesOf("timeZone")` validiert (kommt im
749
- // Zod-Validator-Schritt). Eigener Field-Typ damit Type-Safety + Storage
750
- // (TEXT-Spalte) korrekt sind und der `locatedBy`-Marker eindeutig auflöst.
751
- export type TzFieldDef = {
752
- readonly type: "tz";
753
- readonly description?: string;
754
- readonly required?: boolean;
755
- readonly sensitive?: boolean;
756
- readonly access?: FieldAccess;
757
- } & ResolvedPiiFlags;
758
-
759
- // Wall-Clock-Termin an einem Ort als ATOMARES Konzept.
760
- // EIN Feld in der Schema-Definition, ZWEI Spalten in der DB
761
- // (`<name>_utc TIMESTAMPTZ` + `<name>_tz TEXT`), DREI Felder im API-Object
762
- // ({ at, tz, utc }). Drizzle-Wrapper macht die Konvertierung transparent —
763
- // Feature-Code sieht das 3-Felder-Object beim Read und schreibt
764
- // { at, tz } beim Insert (utc wird berechnet).
765
- //
766
- // API-Form:
767
- // Write: { at: "2026-04-15T10:00:00", tz: "Europe/Lisbon" }
768
- // Read: { at: "2026-04-15T10:00:00", tz: "Europe/Lisbon", utc: "2026-04-15T09:00:00Z" }
769
- //
770
- // Default-Sicht für `at`: Wall-Clock am Ort (`tz`). Wer User-lokale Sicht
771
- // will, projeziert `utc` separat per ctx.tz.fromInstantInZone(utc, userTz).
772
- //
773
- // Ersetzt das alte `locatedTimestamp(name)` Helper-Pattern (zwei separate
774
- // Pair-Felder). Sauberer Single-Field-Typ + Auto-Convert-Logik.
775
- //
776
- // Siehe docs/plans/architecture/timezones.md.
777
- export type LocatedTimestampFieldDef = {
778
- readonly type: "locatedTimestamp";
779
- readonly description?: string;
780
- readonly required?: boolean;
781
- readonly sortable?: boolean;
782
- readonly filterable?: boolean;
783
- readonly sensitive?: boolean;
784
- readonly access?: FieldAccess;
785
- /** Erlaubte Grenzen als ISO-Datetime (Wall-Clock). Begrenzt den Picker
786
- * auf Tages-Granularität; die exakte Uhrzeit-Grenze setzt das Zod-Schema
787
- * beim Write durch. */
788
- readonly min?: string;
789
- readonly max?: string;
790
- /** Format/Locale-Override für Anzeige und Eingabe-Parsing. Default =
791
- * App-Locale. */
792
- readonly locale?: string;
793
- } & ResolvedPiiFlags;
794
-
795
- export type FileFieldDef = {
796
- readonly type: "file";
797
- readonly description?: string;
798
- readonly required?: boolean;
799
- readonly maxSize?: string;
800
- readonly accept?: readonly string[];
801
- readonly access?: FieldAccess;
802
- } & ResolvedPiiFlags;
803
-
804
- export type ImageFieldDef = {
805
- readonly type: "image";
806
- readonly description?: string;
807
- readonly required?: boolean;
808
- readonly maxSize?: string;
809
- readonly accept?: readonly string[];
810
- /** Named derived versions of the uploaded image. The name is the only
811
- * thing a caller may ask for — the spec lives here, never in a request,
812
- * so nobody can drive arbitrary renders. Boot-validated. */
813
- readonly variants?: Readonly<Record<string, VariantSpec>>;
814
- /** Forwarded to the file input's `capture` attribute so a phone opens the
815
- * camera instead of the file picker. */
816
- readonly capture?: "environment" | "user";
817
- readonly access?: FieldAccess;
818
- } & ResolvedPiiFlags;
819
-
820
- export type FilesFieldDef = {
821
- readonly type: "files";
822
- readonly description?: string;
823
- readonly maxSize?: string;
824
- readonly accept?: readonly string[];
825
- readonly maxCount?: number;
826
- readonly access?: FieldAccess;
827
- } & ResolvedPiiFlags;
828
-
829
- export type ImagesFieldDef = {
830
- readonly type: "images";
831
- readonly description?: string;
832
- readonly maxSize?: string;
833
- readonly accept?: readonly string[];
834
- readonly maxCount?: number;
835
- readonly variants?: Readonly<Record<string, VariantSpec>>;
836
- readonly access?: FieldAccess;
837
- } & ResolvedPiiFlags;
838
-
839
- export type FieldDefinition =
840
- | TextFieldDef
841
- | LongTextFieldDef
842
- | BooleanFieldDef
843
- | SelectFieldDef
844
- | MultiSelectFieldDef
845
- | NumberFieldDef
846
- | BigIntFieldDef
847
- | DecimalFieldDef
848
- | MoneyFieldDef
849
- | ReferenceFieldDef
850
- | EmbeddedFieldDef
851
- | JsonbFieldDef
852
- | DateFieldDef
853
- | TimestampFieldDef
854
- | TzFieldDef
855
- | LocatedTimestampFieldDef
856
- | FileFieldDef
857
- | ImageFieldDef
858
- | FilesFieldDef
859
- | ImagesFieldDef;
860
-
861
- // Exhaustive catalogue of `FieldDefinition["type"]` values, derived from a
862
- // `Record` so an added/removed union member fails to compile here instead of
863
- // silently drifting out of sync with the runtime validator that consults
864
- // this list (feature-ast's pattern-change-schema). Plain array, not a Set —
865
- // same rationale as NO_WIDGET_FIELD_TYPES (guard-types-class-free.test.ts).
866
- const FIELD_TYPE_NAME_FLAGS: Record<FieldDefinition["type"], true> = {
867
- text: true,
868
- longText: true,
869
- boolean: true,
870
- select: true,
871
- multiSelect: true,
872
- number: true,
873
- bigInt: true,
874
- decimal: true,
875
- money: true,
876
- reference: true,
877
- embedded: true,
878
- jsonb: true,
879
- date: true,
880
- timestamp: true,
881
- tz: true,
882
- locatedTimestamp: true,
883
- file: true,
884
- image: true,
885
- files: true,
886
- images: true,
887
- };
888
-
889
- export const FIELD_TYPE_NAMES: readonly FieldDefinition["type"][] = Object.keys(
890
- FIELD_TYPE_NAME_FLAGS,
891
- ) as FieldDefinition["type"][];
892
-
893
- /** FieldDefinition as an entity-less inline form screen (actionForm,
894
- * secretMint) may declare it: a `money` field there has no entity to borrow
895
- * `defaultCurrency` from, so it must name its own currency source, or the
896
- * form seeds a bare `0` that the handler's zod schema then rejects
897
- * (kumiko-framework#2839). Entity fields keep the optional `currency` —
898
- * `entity.defaultCurrency` is already boot-enforced for them. */
899
- export type FormFieldDefinition =
900
- | Exclude<FieldDefinition, MoneyFieldDef>
901
- | (MoneyFieldDef & { readonly currency: MoneyCurrencySource });
902
-
903
- // Union of all field variants that represent uploaded files. They share
904
- // `maxSize` and `accept`, which is what upload validation cares about.
905
- export type AnyFileFieldDef = FileFieldDef | ImageFieldDef | FilesFieldDef | ImagesFieldDef;
906
-
907
- // Field types with no bound, editable widget on the auto-wired entityEdit
908
- // path (render-field.tsx renders a read-only banner instead) — shared by
909
- // the boot validator (packages/framework/src/engine/boot-validator/screens.ts)
910
- // and the client-side required-field check (packages/renderer/src/app/form-schema.ts).
911
- // Embedded LIST fields (`multiple: true`) are the one exception — they get
912
- // their own EmbeddedListField grid widget (#1838) — callers must check that
913
- // separately, this array alone doesn't distinguish plain embedded from embedded lists.
914
- // A plain array, not a Set: kumiko-types stays free of module-scope runtime
915
- // identity (guard-types-class-free.test.ts) since a consumer may resolve two
916
- // copies of this package.
917
- export const NO_WIDGET_FIELD_TYPES: readonly FieldDefinition["type"][] = [
918
- "jsonb",
919
- "embedded",
920
- "files",
921
- "images",
922
- ];
923
-
924
- // --- Derived (computed) fields ---
925
- //
926
- // A derived field is read-time only: its value is computed from the stored row
927
- // (and the clock) when an entityList query runs (676/2 — NOT detail; only the
928
- // "list" case in entity-handlers.ts calls augmentDerivedFields), never
929
- // persisted. It lives in
930
- // `EntityDefinition.derivedFields` — deliberately NOT in `fields`, so it
931
- // produces no DB column, never enters a write schema, and can't be the target
932
- // of an entityEdit. A declarative `entityList` can name it like any column and
933
- // the view-model renders the appended value.
934
- //
935
- // LIMIT: derived columns are DISPLAY ONLY. A declarative `entityList` loads its
936
- // rows server-side and a column-header sort round-trips to the server, where
937
- // `executor.list` sorts/filters/searches over real SQL columns — so a derived
938
- // field (no column) silently no-ops. There is no client-side sort path. Need a
939
- // derived value sortable/searchable? Materialize it as a stored field; it then
940
- // rides the existing `searchable`/`sortable` machinery. Time-dependent values
941
- // (as-of-today) can't be materialized without a daily re-index anyway.
942
-
943
- /** Display type a derived value formats as — drives the column's renderer
944
- * choice in the view-model, parallel to FieldDefinition["type"]. Single-column
945
- * types only: `money` is excluded because it needs a `<name>Currency`
946
- * companion column a derived field has no place to put — use `number`/
947
- * `decimal` plus a `{ format: "currency" }` column renderer instead. */
948
- export type DerivedValueType = "text" | "number" | "decimal" | "boolean" | "date" | "timestamp";
949
-
950
- /** Clock injected into `derive` — never read `Temporal.Now`/`Date` inside a
951
- * derive body (no-date-api guard + testability). The list-query handler passes
952
- * the read-time instant; unit tests pass a fixed one. */
953
- export type DeriveContext = {
954
- readonly asOf: Temporal.Instant;
955
- };
956
-
957
- export type DerivedFieldDef = {
958
- readonly valueType: DerivedValueType;
959
- /** Pure function of the stored row + clock. Returns the JSON-safe display
960
- * value (e.g. integer minor units for a currency column, ISO string for a
961
- * `date`). */
962
- readonly derive: (row: Readonly<Record<string, unknown>>, ctx: DeriveContext) => unknown;
963
- };
964
-
965
- export type DerivedFieldsMap = Readonly<Record<string, DerivedFieldDef>>;
966
-
967
- /** Client-facing projection of DerivedFieldDef — `derive` is server-only and
968
- * not JSON-safe (would trip the output-walk guard), so the browser schema
969
- * only ever carries `valueType`. A real `Pick`, not a same-shape cast: TS
970
- * itself proves `derive` isn't there instead of a `{ valueType } as
971
- * DerivedFieldDef` cast lying about a field that's actually missing. */
972
- export type ClientDerivedFieldDef = Pick<DerivedFieldDef, "valueType">;
973
-
974
- // --- Entity ---
975
-
976
- // --- State Transitions ---
977
-
978
- export type TransitionMap = Readonly<Record<string, readonly string[]>>;
979
-
980
- /** Composite-Index auf einer Entity. Spalten werden via field-Name
981
- * referenziert (camelCase). buildEntityTable mapped sie auf snake_case-
982
- * Spaltennamen und benennt den Index nach Convention:
983
- *
984
- * <table>_<col1>_<col2>_idx (non-unique)
985
- * <table>_<col1>_<col2>_unique (unique)
986
- *
987
- * Eine `name`-Override ist erlaubt — Convention-Bruch in Bestandscode
988
- * vermeidet Migration-Churn beim Refactor.
989
- *
990
- * Single-column indices über `tenantId` sind redundant (buildEntityTable
991
- * legt die immer automatisch an); die Boot-Validation warnt (außer
992
- * `{ unique: true }` — semantische 1:1-Constraint, kein Performance-Hint). */
993
- export type EntityIndexDef = {
994
- readonly columns: readonly [string, ...string[]];
995
- readonly unique?: boolean;
996
- readonly name?: string;
997
- /**
998
- * Optional SQL-Fragment fuer Partial-Index — `CREATE [UNIQUE] INDEX
999
- * ... WHERE <condition>`. Postgres-Pattern fuer "Index nur unter
1000
- * bestimmten Bedingungen", typisches Beispiel: ExportJob-Idempotency
1001
- * `UNIQUE(userId) WHERE status IN ('pending', 'running')`.
1002
- *
1003
- * Caller baut das Fragment via drizzle-orm `sql\`...\``-Tagged-
1004
- * Template. table-builder.ts emittiert `.where(def.where)` auf den
1005
- * Drizzle-IndexBuilder — wirkt sowohl fuer unique- als auch fuer
1006
- * non-unique-Indexes (PG erlaubt beides; non-unique partial nutzt
1007
- * man z.B. fuer scharfe BTREE-Indexes nur auf einer Status-Teilmenge
1008
- * statt voller Tabelle).
1009
- */
1010
- readonly where?: unknown;
1011
- };
1012
-
1013
- export type FieldsMap = Readonly<Record<string, FieldDefinition>>;
1014
-
1015
- export type ParentRefDef = {
1016
- /** Field holding the name of the host entity this row hangs off. */
1017
- readonly entityTypeField: string;
1018
- /** Field holding the host row's id. */
1019
- readonly entityIdField: string;
1020
- /** Narrow which registered entities may act as host. Omitted means every
1021
- * registered entity is a candidate, which also widens the read-gate's SQL. */
1022
- readonly allowedTypes?: readonly string[];
1023
- };
1024
-
1025
- export type EntityDefinition<
1026
- F extends FieldsMap = FieldsMap,
1027
- T extends EntityTenancy = EntityTenancy,
1028
- > = {
1029
- readonly table?: string;
1030
- readonly fields: F;
1031
- readonly softDelete?: boolean;
1032
- /** "tenant" (default): auto-filtered by every db accessor. "global": shared across
1033
- * tenants, reachable only via `db.global(table)`. Not named `scope` — config keys use that. */
1034
- readonly tenancy?: T;
1035
- /** This aggregate's event stream lives on SYSTEM_TENANT_ID rather than the
1036
- * creator's tenant. Opt-in per entity (NOT inherited from r.systemScope()):
1037
- * only for genuinely tenant-independent aggregates like `user`. The first
1038
- * event (create) is what's routed; updates resolve the stream tenant upstream. */
1039
- readonly systemStream?: boolean;
1040
- readonly searchWeight?: number;
1041
- readonly defaultCurrency?: string;
1042
- /** Allowed state transitions per field. Boot validates against select options. */
1043
- readonly transitions?: Readonly<Record<string, TransitionMap>>;
1044
- /** Composite-Indices über mehrere Felder. Single-column FK-Indices und
1045
- * der tenant_id-Index werden weiterhin automatisch von buildEntityTable
1046
- * angelegt — diese Liste ist nur für Custom-Indices die der Author
1047
- * explizit deklariert (z.B. `{ unique: true, columns: ["key", "tenantId", "userId"] }`). */
1048
- readonly indexes?: readonly EntityIndexDef[];
1049
- /**
1050
- * PK-Typ der Entity.
1051
- * - `"serial"` (default): bigserial integer — schneller, kompakter, perfekt für klassische CRUD-Entities.
1052
- * - `"uuid"`: uuid mit `gen_random_uuid()` default — verpflichtend für Entities deren `id` als
1053
- * Foreign-Key-Wert in multi-tenant Kontexten reist (z.B. `tenant.id` IS der `tenantId`). Auch für
1054
- * ES-Aggregate (Phase 2+) notwendig, da Events per UUID aggregiert werden.
1055
- */
1056
- readonly idType?: "serial" | "uuid";
1057
- /**
1058
- * Row-level ownership rules (H.2). read runs as WHERE-predicate on list/
1059
- * detail/queryProjection, scoping which rows the caller sees. write runs
1060
- * pre-save on create/update/delete, scoping which rows the caller may
1061
- * modify (Straddle-safe, multi-role atomic — see engine/ownership.ts).
1062
- *
1063
- * Keys are role names; rules use the `from()` helper or `{ where }`
1064
- * escape hatch. Entity-level ownership is AND-ed with tenant isolation —
1065
- * a user's tenant filter still applies first.
1066
- */
1067
- readonly access?: {
1068
- readonly read?: OwnershipMap;
1069
- readonly write?: OwnershipMap;
1070
- };
1071
- /**
1072
- * Join-row carrier reference. Rows of this entity hang off a host row named
1073
- * by two of the entity's own fields. Both paths derive one visibility gate
1074
- * from this single declaration: write handlers deny with NotFoundError, and
1075
- * list/detail filter in SQL against the host entity's own read path (tenant
1076
- * scope, soft-delete, `access.read` ownership). A host type that names no
1077
- * registered entity is denied.
1078
- */
1079
- readonly parentRef?: ParentRefDef;
1080
- /**
1081
- * Default-Retention-Policy fuer diese Entity. Tenant-Admin kann via
1082
- * Compliance-Profile + Tenant-Override (Sprint 2) uebersteuern.
1083
- * Cleanup-Job (Sprint 2) verarbeitet die Strategy:
1084
- *
1085
- * - "hardDelete" → Row physisch weg nach keepFor
1086
- * - "softDelete" → deletedAt = now() (mit core-soft-delete-Feature)
1087
- * - "anonymize" → Felder mit `anonymize`-Funktion ueberschrieben,
1088
- * Row bleibt
1089
- * - "blockDelete" → Cleanup-Job ignoriert; User-Forget loest
1090
- * stattdessen anonymize aus. Buchhaltung, Mandate,
1091
- * Patientenakten.
1092
- *
1093
- * Siehe docs/plans/features/core-data-retention.md.
1094
- */
1095
- readonly retention?: RetentionDef;
1096
- /**
1097
- * Read-time computed fields, keyed by name. Not stored, not a DB column,
1098
- * not writable — appended to each row by the list/detail query handler and
1099
- * nameable as a column in a declarative `entityList`. See DerivedFieldDef.
1100
- */
1101
- readonly derivedFields?: DerivedFieldsMap;
1102
- /** Optional free-text semantics for the AI agent manifest — see TextFieldDef.description. */
1103
- readonly description?: string;
1104
- /**
1105
- * Opts this entity's rows into the `tenant-handover` bundled feature
1106
- * (kumiko-framework#3035): a row-bound-grant-legitimated ownership change
1107
- * from an anonymous/public tenant to a freshly signed-up account's tenant.
1108
- * An entity reachable from a transfer's root — the root itself, or any
1109
- * entity whose `parentRef` names the root's type — that is NOT declared
1110
- * `transferable: true` makes the whole transfer fail with a named error
1111
- * instead of silently leaving that entity's rows behind.
1112
- */
1113
- readonly transferable?: boolean;
1114
- };