@cosmicdrift/kumiko-framework 0.158.2 → 0.160.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 (168) hide show
  1. package/package.json +7 -2
  2. package/src/__tests__/consumer-cli.integration.test.ts +110 -0
  3. package/src/api/__tests__/api.test.ts +65 -0
  4. package/src/api/__tests__/auth-routes-cookie.test.ts +17 -1
  5. package/src/api/__tests__/auth-routes-invalid-body-invite.test.ts +237 -0
  6. package/src/api/__tests__/auth-routes-mfa-verify.test.ts +1 -0
  7. package/src/api/__tests__/csrf-constants-sync.test.ts +20 -0
  8. package/src/api/__tests__/dispatcher-live.integration.test.ts +74 -0
  9. package/src/api/__tests__/jwt.test.ts +150 -1
  10. package/src/api/__tests__/login-rate-limiter-sweep.test.ts +41 -0
  11. package/src/api/__tests__/server-boot-guards.test.ts +71 -0
  12. package/src/api/__tests__/server-jwt-ttl.test.ts +58 -0
  13. package/src/api/api-constants.ts +5 -0
  14. package/src/api/auth-middleware.ts +48 -59
  15. package/src/api/auth-routes.ts +51 -17
  16. package/src/api/index.ts +3 -3
  17. package/src/api/jwt.ts +148 -7
  18. package/src/api/pii-leak-guard.ts +5 -2
  19. package/src/api/routes.ts +57 -0
  20. package/src/api/server.ts +19 -5
  21. package/src/bun-db/__tests__/select-many-retry.test.ts +79 -0
  22. package/src/bun-db/query.ts +46 -27
  23. package/src/consumer-cli.ts +87 -0
  24. package/src/crypto/__tests__/pii-field-encryption.test.ts +69 -13
  25. package/src/crypto/blind-index.ts +8 -4
  26. package/src/crypto/event-pii.ts +1 -0
  27. package/src/crypto/kms-adapter.ts +2 -118
  28. package/src/crypto/pii-field-encryption.ts +49 -15
  29. package/src/db/__tests__/build-filter-where.test.ts +34 -0
  30. package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +67 -0
  31. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +396 -0
  32. package/src/db/__tests__/event-store-executor.integration.test.ts +5 -5
  33. package/src/db/blind-index-cleanup.ts +3 -1
  34. package/src/db/connection.ts +3 -11
  35. package/src/db/cursor.ts +1 -18
  36. package/src/db/dialect.ts +8 -19
  37. package/src/db/encryption.ts +2 -3
  38. package/src/db/entity-table-meta-types.ts +2 -0
  39. package/src/db/entity-table-meta.ts +16 -90
  40. package/src/db/event-store-executor.ts +4 -96
  41. package/src/db/queries/backfill-pii.ts +1 -0
  42. package/src/db/queries/event-consumer.ts +35 -2
  43. package/src/db/table-builder.ts +2 -19
  44. package/src/db/tenant-db.ts +6 -55
  45. package/src/engine/__tests__/boot-validator-boot-check.test.ts +99 -0
  46. package/src/engine/__tests__/boot-validator-gdpr-storage.test.ts +7 -233
  47. package/src/engine/__tests__/boot-validator.test.ts +46 -0
  48. package/src/engine/__tests__/codemod-pipeline.test.ts +139 -10
  49. package/src/engine/__tests__/define-roles.test.ts +21 -0
  50. package/src/engine/__tests__/engine.test.ts +28 -0
  51. package/src/engine/__tests__/event-type-map-augmentation.test.ts +24 -0
  52. package/src/engine/__tests__/registry-facade-sweep.test.ts +80 -0
  53. package/src/engine/__tests__/registry.test.ts +40 -0
  54. package/src/engine/__tests__/store-table.test.ts +12 -0
  55. package/src/engine/__tests__/tier-resolver-extension.test.ts +19 -1
  56. package/src/engine/boot-validator/action-wiring.ts +1 -1
  57. package/src/engine/boot-validator/boot-check.ts +21 -0
  58. package/src/engine/boot-validator/entity-handler.ts +10 -1
  59. package/src/engine/boot-validator/entity-list-screens.ts +1 -1
  60. package/src/engine/boot-validator/gdpr-storage.ts +0 -112
  61. package/src/engine/boot-validator/index.ts +3 -9
  62. package/src/engine/boot-validator/screens.ts +1 -1
  63. package/src/engine/define-feature.ts +2 -0
  64. package/src/engine/define-handler.ts +11 -91
  65. package/src/engine/entity-handlers.ts +15 -27
  66. package/src/engine/feature-ast/__tests__/canonical-form.test.ts +11 -1
  67. package/src/engine/feature-ast/__tests__/parse.test.ts +983 -3
  68. package/src/engine/feature-ast/__tests__/patch.test.ts +168 -0
  69. package/src/engine/feature-ast/__tests__/patcher.test.ts +7 -0
  70. package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +9 -0
  71. package/src/engine/feature-ast/extractors/handlers.ts +19 -2
  72. package/src/engine/feature-ast/extractors/index.ts +1 -0
  73. package/src/engine/feature-ast/index.ts +2 -0
  74. package/src/engine/feature-ast/parse.ts +3 -0
  75. package/src/engine/feature-ast/patch.ts +2 -0
  76. package/src/engine/feature-ast/patcher.ts +21 -0
  77. package/src/engine/feature-ast/patterns.ts +16 -0
  78. package/src/engine/feature-ast/render.ts +15 -0
  79. package/src/engine/feature-builder-state.ts +6 -0
  80. package/src/engine/feature-config-events-jobs.ts +1 -1
  81. package/src/engine/feature-entity-handlers.ts +36 -2
  82. package/src/engine/feature-ui-extensions.ts +5 -1
  83. package/src/engine/field-helpers.ts +31 -0
  84. package/src/engine/handler-helpers.ts +26 -0
  85. package/src/engine/hook-helpers.ts +14 -0
  86. package/src/engine/index.ts +5 -2
  87. package/src/engine/ownership.ts +22 -76
  88. package/src/engine/pattern-library/__tests__/library.test.ts +9 -0
  89. package/src/engine/pattern-library/library.ts +2 -0
  90. package/src/engine/pattern-library/mixed-schemas.ts +37 -0
  91. package/src/engine/registry-facade.ts +9 -0
  92. package/src/engine/registry-ingest.ts +10 -0
  93. package/src/engine/registry-state.ts +3 -0
  94. package/src/engine/registry-validate.ts +1 -1
  95. package/src/engine/screen-helpers.ts +54 -0
  96. package/src/engine/tier-resolver-extension.ts +3 -2
  97. package/src/engine/types/config.ts +2 -497
  98. package/src/engine/types/define-handler.ts +2 -0
  99. package/src/engine/types/entity-handlers.ts +2 -0
  100. package/src/engine/types/event-type-map.ts +1 -37
  101. package/src/engine/types/feature.ts +2 -976
  102. package/src/engine/types/fields.ts +2 -697
  103. package/src/engine/types/handlers.ts +2 -839
  104. package/src/engine/types/hooks.ts +2 -184
  105. package/src/engine/types/http-route.ts +1 -72
  106. package/src/engine/types/identifiers.ts +1 -47
  107. package/src/engine/types/index.ts +66 -33
  108. package/src/engine/types/nav.ts +2 -67
  109. package/src/engine/types/ownership.ts +2 -0
  110. package/src/engine/types/projection.ts +2 -165
  111. package/src/engine/types/relations.ts +1 -51
  112. package/src/engine/types/screen.ts +2 -793
  113. package/src/engine/types/step.ts +2 -334
  114. package/src/engine/types/target-ref.ts +1 -21
  115. package/src/engine/types/tree-node.ts +1 -129
  116. package/src/engine/types/workspace.ts +2 -42
  117. package/src/entrypoint/index.ts +2 -2
  118. package/src/errors/write-error-info.ts +6 -22
  119. package/src/event-store/__tests__/event-store.integration.test.ts +31 -0
  120. package/src/event-store/__tests__/unscoped-stream-primitives.guard.test.ts +43 -0
  121. package/src/event-store/errors.ts +2 -35
  122. package/src/event-store/event-store.ts +28 -51
  123. package/src/event-store/events-schema.ts +1 -10
  124. package/src/event-store/index.ts +3 -2
  125. package/src/event-store/snapshot.ts +11 -35
  126. package/src/event-store/types.ts +2 -0
  127. package/src/files/__tests__/in-memory-provider.contract.test.ts +4 -0
  128. package/src/files/file-handle.ts +2 -19
  129. package/src/files/provider-resolver.ts +3 -5
  130. package/src/files/types.ts +5 -54
  131. package/src/i18n/required-surface-keys.ts +1 -1
  132. package/src/jobs/__tests__/jobs.integration.test.ts +102 -1
  133. package/src/logging/types.ts +1 -7
  134. package/src/observability/types/index.ts +1 -29
  135. package/src/observability/types/metric.ts +1 -56
  136. package/src/observability/types/provider.ts +1 -32
  137. package/src/observability/types/span.ts +1 -58
  138. package/src/pipeline/__tests__/dispatcher.test.ts +134 -1
  139. package/src/pipeline/__tests__/event-dispatcher-delivery-max-attempts.test.ts +126 -0
  140. package/src/pipeline/__tests__/event-dispatcher-rearm.integration.test.ts +180 -0
  141. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +208 -0
  142. package/src/pipeline/dispatch-shared.ts +51 -3
  143. package/src/pipeline/dispatch-stream.ts +74 -0
  144. package/src/pipeline/dispatcher-utils.ts +1 -1
  145. package/src/pipeline/dispatcher.ts +7 -0
  146. package/src/pipeline/entity-cache.ts +2 -33
  147. package/src/pipeline/event-consumer-state.ts +28 -3
  148. package/src/pipeline/event-dispatcher-admin.ts +4 -0
  149. package/src/pipeline/event-dispatcher-delivery.ts +29 -3
  150. package/src/pipeline/event-dispatcher.ts +27 -1
  151. package/src/pipeline/multi-stream-apply-context.ts +4 -42
  152. package/src/pipeline/system-hooks.ts +7 -0
  153. package/src/rate-limit/resolver.ts +10 -30
  154. package/src/search/types.ts +1 -39
  155. package/src/secrets/__tests__/envelope-cipher.test.ts +2 -30
  156. package/src/secrets/__tests__/envelope.test.ts +1 -1
  157. package/src/secrets/envelope-cipher.ts +17 -45
  158. package/src/secrets/types.ts +2 -177
  159. package/src/stack/__tests__/event-collector.test.ts +42 -0
  160. package/src/testing/__tests__/late-bound.test.ts +25 -0
  161. package/src/testing/__tests__/wait-for.test.ts +53 -0
  162. package/src/testing/boot-validator-fixture.ts +1 -1
  163. package/src/testing/file-provider-contract.ts +84 -0
  164. package/src/testing/handler-context.ts +1 -1
  165. package/src/testing/index.ts +1 -0
  166. package/src/time/geo-tz.ts +1 -32
  167. package/src/time/tz-context.ts +9 -56
  168. package/src/ui-types/index.ts +7 -7
@@ -1,839 +1,2 @@
1
- import type { Redis } from "ioredis";
2
- import type { ZodType } from "zod";
3
- import type { DbConnection } from "../../db/connection";
4
- import type { TenantDb } from "../../db/tenant-db";
5
- import type { FileContext } from "../../files/file-handle";
6
- import type { FileProviderResolver } from "../../files/provider-resolver";
7
- import type { Logger } from "../../logging/types";
8
- import type { Meter, MetricsHandle, Tracer } from "../../observability/types";
9
- import type { EntityCache } from "../../pipeline/entity-cache";
10
- import type { SearchAdapter } from "../../search/types";
11
- import type { GeoTzProvider, TzContext } from "../../time";
12
- import type { ConfigAccessor, ConfigAccessorFactory, ConfigResolver } from "./config";
13
- import type { KumikoEventTypeMap } from "./event-type-map";
14
-
15
- // --- Access ---
16
-
17
- // AccessRule is DEFAULT-DENY: a handler without an access rule is not reachable.
18
- // To grant access, set one of:
19
- // - { roles: ["Admin", ...] } — role-based allowlist (empty array denies everyone)
20
- // - { openToAll: true } — any authenticated user may call (still requires a valid JWT)
21
- export type AccessRule = { readonly roles: readonly string[] } | { readonly openToAll: true };
22
-
23
- // --- Pipeline User ---
24
-
25
- export type SessionUser = {
26
- // UUID-string so user.id threads through the event-store (aggregate-id) and
27
- // the projection tables (uuid PK) without casts. Auth middleware reads the
28
- // JWT `sub` claim as a string; legacy integer ids were a pre-ES artefact.
29
- readonly id: string;
30
- readonly tenantId: TenantId;
31
- readonly roles: readonly string[];
32
- // App-specific identity facts baked into the JWT at login time.
33
- // Populated by `r.authClaims()` hooks (not yet implemented — see the
34
- // auth-claims design note in docs/plans). Reserved here so the type shape
35
- // is stable when the hook system lands.
36
- readonly claims?: Readonly<Record<string, unknown>>;
37
- // Session-ID — transported via the JWT `jti` standard claim. Present when
38
- // an app has wired a `sessionCreator` callback on the auth-routes config
39
- // (e.g. via the `sessions` feature). Absent for stateless-JWT deployments.
40
- // When present, middleware can validate that the sid is still alive before
41
- // accepting the request (session revocation).
42
- readonly sid?: string;
43
- // Set ONLY when the request authenticated via a Personal Access Token
44
- // (bearer, PAT_TOKEN_PREFIX). Absent for cookie/JWT logins, which stay
45
- // unrestricted. `allowedQns` are the QN globs the token's granted scopes
46
- // expand to; the API boundary (routes.ts) rejects any dispatch type not
47
- // matched by one of them (fail-closed). `scopes` are the granted scope
48
- // names, kept for audit/display only.
49
- readonly pat?: {
50
- // The token row id — the per-token key for PAT rate limiting. Not the
51
- // secret; safe to carry on the principal.
52
- readonly tokenId: string;
53
- readonly scopes: readonly string[];
54
- readonly allowedQns: readonly string[];
55
- };
56
- };
57
-
58
- // --- Claim Keys (r.claimKey declarations) ---
59
-
60
- // Declared claim shape. Features call r.claimKey("teamId", { type: "string" })
61
- // and get back a typed handle. Feature code then uses the handle both when
62
- // reading via readClaim(user, handle) and (optionally) when returning from
63
- // r.authClaims hooks. Two-fold payoff:
64
- //
65
- // 1. Read-site is typesafe: `const teamId = readClaim(user, DriverClaims.teamId)`
66
- // narrows to `string | undefined` automatically — no hand-written cast,
67
- // no magic "drivers:teamId" string.
68
- // 2. Runtime check: the resolver warns when a hook returns an inner-key
69
- // that the feature didn't declare — catches rename/typo drift. Opt-in
70
- // per feature: only checked when r.claimKey was used at least once.
71
- //
72
- // Keep the type union small and explicit. JS-side inference via ClaimKeyJsType
73
- // maps each literal to a primitive or array — broader shapes (nested
74
- // records) can land in "object" but lose narrowness; that's the trade-off
75
- // for keeping the type-system simple.
76
- export type ClaimKeyType = "string" | "number" | "boolean" | "string[]" | "object";
77
-
78
- export type ClaimKeyJsType<T extends ClaimKeyType> = T extends "string"
79
- ? string
80
- : T extends "number"
81
- ? number
82
- : T extends "boolean"
83
- ? boolean
84
- : T extends "string[]"
85
- ? readonly string[]
86
- : T extends "object"
87
- ? Readonly<Record<string, unknown>>
88
- : never;
89
-
90
- // Stored on the FeatureDefinition. `qualifiedName` is auto-set at
91
- // registration time ("<feature>:<inner-kebab>") — same naming convention
92
- // as auth-claim keys.
93
- export type ClaimKeyDefinition = {
94
- readonly shortName: string;
95
- readonly qualifiedName: string;
96
- readonly type: ClaimKeyType;
97
- };
98
-
99
- // Typed handle returned by r.claimKey(). `name` is the qualified key the
100
- // JWT stores; `type` threads through to readClaim's generic so consumers
101
- // get the right narrowed type without casting.
102
- export type ClaimKeyHandle<T extends ClaimKeyType = ClaimKeyType> = {
103
- readonly name: string;
104
- readonly type: T;
105
- };
106
-
107
- // --- Auth Claims (r.authClaims hook) ---
108
-
109
- // Features contribute "identity facts" into the JWT at login time. Claim keys
110
- // are auto-prefixed with the feature name at merge time (`"<feature>:<key>"`)
111
- // so two features can't collide — Reading code in a handler picks the claim
112
- // by its prefixed key: `user.claims["drivers:teamId"]`.
113
- //
114
- // The context is deliberately trimmed compared to HandlerContext: login is a
115
- // READ, not a write-path. Exposing appendEvent/loadAggregate/tz here would
116
- // let claims hooks reach into write-time concerns — not their job, bigger
117
- // mocking surface in tests. `db` is guaranteed tenant-scoped to the chosen
118
- // tenant (the one the user is logging INTO, not the one making the request).
119
- // `queryAs` lets a hook call another feature's query handler without direct
120
- // imports — same cross-feature contract hooks otherwise follow.
121
- export type AuthClaimsContext = {
122
- readonly db: import("../../db/tenant-db").TenantDb;
123
- readonly queryAs: (user: SessionUser, qn: string, payload: unknown) => Promise<unknown>;
124
- readonly config?: ConfigAccessor;
125
- };
126
-
127
- export type AuthClaimsFn = (
128
- user: SessionUser,
129
- ctx: AuthClaimsContext,
130
- ) => Promise<Record<string, unknown>>;
131
-
132
- // What the registry stores per registered hook. `featureName` drives the
133
- // auto-prefix at merge time, so the registry is the source of truth for the
134
- // naming — features never ship pre-prefixed keys.
135
- //
136
- // `declaredKeys` is the set of inner-keys this hook's feature declared via
137
- // r.claimKey() — the resolver uses it to warn when a hook returns a key
138
- // that was never declared (typo / rename drift). `undefined` when the
139
- // feature never called r.claimKey(), in which case the resolver skips the
140
- // check entirely (backwards-compat for features that only use r.authClaims).
141
- export type AuthClaimsHookDef = {
142
- readonly featureName: string;
143
- readonly fn: AuthClaimsFn;
144
- readonly declaredKeys?: ReadonlySet<string>;
145
- };
146
-
147
- // --- Handler Events ---
148
-
149
- export type WriteEvent<TPayload = unknown> = {
150
- readonly type: string;
151
- readonly payload: TPayload;
152
- readonly user: SessionUser;
153
- };
154
-
155
- export type QueryEvent<TPayload = unknown> = {
156
- readonly type: string;
157
- readonly payload: TPayload;
158
- readonly user: SessionUser;
159
- };
160
-
161
- // --- Handler Results ---
162
-
163
- import type { WriteFailure } from "../../errors/write-error-info";
164
-
165
- export type WriteResult<TData = unknown> =
166
- | { readonly isSuccess: true; readonly data: TData }
167
- | WriteFailure;
168
-
169
- /**
170
- * Override the success-side `data` of a WriteResult while forwarding the
171
- * failure half untouched. Useful for handlers that delegate to the
172
- * event-store executor (which returns a SaveContext / DeleteContext
173
- * envelope) but want to keep their own response shape — caller contract
174
- * stays flat instead of leaking the executor's internals.
175
- *
176
- * ```ts
177
- * const result = await executor.delete({ id }, user, db);
178
- * return withResponseData(result, { userId, tenantId });
179
- * ```
180
- *
181
- * On failure the same WriteFailure instance is returned — the error
182
- * object round-trips without any wrapping, so the dispatcher / HTTP layer
183
- * still read the original error code + httpStatus + i18nKey.
184
- */
185
- export function withResponseData<T>(result: WriteResult<unknown>, data: T): WriteResult<T> {
186
- if (!result.isSuccess) return result;
187
- return { isSuccess: true, data };
188
- }
189
-
190
- // --- Context Types ---
191
-
192
- // Forward import: Registry is in feature.ts (circular type import — fine in TS)
193
- import type { Registry } from "./feature";
194
- import type { TenantId } from "./identifiers";
195
-
196
- // Minimal interface for job event triggers (framework-owned, concrete type in jobs/)
197
- export type JobRunnerRef = {
198
- handleEvent(
199
- eventName: string,
200
- payload: Record<string, unknown>,
201
- user?: SessionUser,
202
- ): Promise<void>;
203
- };
204
-
205
- // Priority levels for notifications
206
- export type NotifyPriority = "critical" | "normal" | "low";
207
-
208
- // Options passed to a NotifyFn / DeliveryService.notify. Defined here so the
209
- // framework side and the concrete delivery implementation can't drift apart.
210
- export type NotifyOptions = {
211
- readonly to?: string | readonly string[] | { readonly tenant: TenantId };
212
- readonly route?: Readonly<Record<string, string>>;
213
- readonly data?: Readonly<Record<string, unknown>>;
214
- readonly priority?: NotifyPriority;
215
- // Opt-in dedup. Same key within 24h = single delivery. Use when a handler
216
- // can be replayed (webhook retry, user double-click) and you don't want
217
- // the notification to fire twice.
218
- readonly idempotencyKey?: string;
219
- };
220
-
221
- // Minimal interface for delivery notifications (concrete type in bundled-features/delivery)
222
- export type NotifyFn = (notificationType: string, options: NotifyOptions) => Promise<void>;
223
-
224
- // Factory that produces a bound NotifyFn for a specific user+tenant
225
- // Concrete implementation in bundled-features/delivery (cross-package boundary)
226
- export type NotifyFactory = (user: SessionUser, tenantId: TenantId) => NotifyFn;
227
-
228
- // Shared optional fields across all execution contexts
229
- type SharedContextFields = {
230
- readonly redis?: Redis;
231
- readonly jobRunner?: JobRunnerRef;
232
- readonly configResolver?: ConfigResolver;
233
- readonly config?: ConfigAccessor;
234
- readonly _configAccessorFactory?: ConfigAccessorFactory;
235
- // Encryption round-trip partner for the config feature. Separate from
236
- // configResolver so the read-only resolver contract stays clean — the
237
- // set handler needs to encrypt on write, the resolver needs to decrypt
238
- // on read, and both reach for the same cipher. Wired via extraContext;
239
- // run{Prod,Dev}App build it from the secrets master key automatically.
240
- readonly configEncryption?: import("../../secrets").EnvelopeCipher;
241
- // Rate-limit resolver. Wired by the framework when the `rate-limiting`
242
- // feature is loaded — pipeline reads handler.rateLimit and calls
243
- // .enforce() on this resolver before access-check. Absent when the
244
- // app didn't load the feature: handlers with rateLimit set are
245
- // rejected at boot to surface the misconfig early.
246
- readonly rateLimit?: import("../../rate-limit").RateLimitResolver;
247
- readonly searchAdapter?: SearchAdapter;
248
- // Binary storage. The dispatcher builds this per-call, bound to the caller's
249
- // tenant, from `_fileProviderResolver` (below) — so uploads, ctx.files and the
250
- // GDPR jobs all resolve through the same file-foundation provider. Hooks/
251
- // handlers use ctx.files.ref(key) instead of receiving binaries in payloads.
252
- readonly files?: FileContext;
253
- // Boot-built, per-tenant file-provider resolver. Set by buildServer when a
254
- // `file-provider-*` plugin is mounted; the dispatcher reads it to materialise
255
- // ctx.files (and the upload routes + MSP-applies use the same resolver).
256
- // Resolution runs under system identity for the s3.secretAccessKey read.
257
- readonly _fileProviderResolver?: FileProviderResolver;
258
- readonly entityCache?: EntityCache;
259
- readonly notify?: NotifyFn;
260
- readonly _notifyFactory?: NotifyFactory;
261
- // Tenant-scoped secrets accessor. Present when the app wired a
262
- // MasterKeyProvider at boot. Feature code reads ctx.secrets.get(...)
263
- // to pull a plaintext secret; Secret<string> carries the brand that
264
- // the response guard rejects on serialization.
265
- readonly secrets?: import("../../secrets").SecretsContext;
266
- // Raw KEK provider. Present alongside ctx.secrets — needed by the rotation
267
- // job which deliberately operates outside the per-call audit trail (it
268
- // processes rows system-wide, not a per-user read).
269
- readonly masterKeyProvider?: import("../../secrets").MasterKeyProvider;
270
- // Subject-key adapter for crypto-shredding (GDPR Art. 17). Present when
271
- // the app wired a KmsAdapter at boot; the PII envelope engine and the
272
- // forget pipeline reach for it. Absent = crypto-shredding not enabled.
273
- readonly kms?: import("../../crypto").KmsAdapter;
274
- // Observability: optional at the outer boundary, always populated by the
275
- // time a handler receives its ctx (Noop fallback when no provider is
276
- // configured, so handler code can call ctx.tracer/ctx.metrics without
277
- // defensive checks).
278
- readonly tracer?: Tracer;
279
- readonly meter?: Meter;
280
- // Cancellation. Aborts when the HTTP client disconnects (mobile back,
281
- // tab close). Undefined for non-HTTP entry-points (jobs, MSP-applies).
282
- // Long-running handlers (export jobs, multi-step workflows) should
283
- // throw `signal.throwIfAborted()` at chunk boundaries; short handlers
284
- // can ignore it. Framework primitives (streamAllEventsByType,
285
- // rebuildProjection) honour it automatically.
286
- readonly signal?: AbortSignal;
287
- // Effective feature-toggle resolver. Wired by the dispatcher when the
288
- // feature-toggles or tier-engine feature is loaded — the lifecycle
289
- // pipeline, MSP runner, and ctx.hasFeature all read from this single
290
- // source. Per-tenant: tenantId argument enables tier-cuts (Sprint 8a)
291
- // where Tenant-A sees Pro features and Tenant-B sees Free features in
292
- // the same process. Returns the Set of feature names effectively
293
- // enabled for that tenant. Absent = all features on (back-compat).
294
- readonly effectiveFeatures?: (tenantId: TenantId) => ReadonlySet<string>;
295
- };
296
-
297
- // All optional — used at pipeline/system boundaries.
298
- // `db` is a DbConnection at the outer boundary (server/stack) and a TenantDb
299
- // once a HandlerContext has been built — hooks receive the HandlerContext as
300
- // AppContext, so the union keeps that assignment straightforward.
301
- export type AppContext = SharedContextFields & {
302
- readonly db?: DbConnection | TenantDb;
303
- readonly registry?: Registry;
304
- readonly systemUser?: SessionUser;
305
- readonly log?: Logger;
306
- readonly triggeredBy?: { readonly id: string; readonly tenantId: TenantId } | null;
307
- /** Bei Job-Handler-Aufrufen die aus einem Event-Trigger heraus laufen
308
- * (r.job mit `trigger: { on: ... }`): der Name des Handlers der das
309
- * Event ausgelöst hat. Bei Multi-Trigger-Jobs (`on: [...]`) ist das
310
- * die einzige Möglichkeit für den Handler zu wissen WELCHER Trigger
311
- * gefeuert hat. Cron- und manual-Jobs lassen das Feld undefined. */
312
- readonly triggerName?: string;
313
- readonly _userId?: string | undefined;
314
- readonly _handlerType?: string | undefined;
315
- /** Optionaler Geo→Zone-Adapter. Wenn gesetzt (via buildServer-context oder
316
- * runProdApp/runDevApp extraContext), reicht der Dispatcher ihn an
317
- * ctx.tz.fromCoordinates / fromAddress weiter. Ohne Provider werfen diese. */
318
- readonly geoTzProvider?: GeoTzProvider;
319
- /** Tenant des aktuellen Pipeline-Calls. Wird vom Dispatcher beim Bauen
320
- * des HandlerContext aus `user.tenantId` gespiegelt, damit lifecycle-
321
- * pipeline + system-hooks den Wert ohne Zugriff auf user-Object haben.
322
- * Sprint-8a Tier-Composition: `effectiveFeatures(ctx._tenantId)`-call
323
- * in den hook-filter-Stellen. */
324
- readonly _tenantId?: TenantId;
325
- };
326
-
327
- // Handler execution: db (tenant-scoped) + registry guaranteed.
328
- //
329
- // Cross-feature bridge:
330
- // ctx.query / ctx.write run the target handler AS THE CURRENT USER,
331
- // sharing the active tx + afterCommit queue. Field-access filters apply.
332
- // ctx.queryAs / ctx.writeAs switch identity (e.g. SYSTEM for privileged
333
- // lookups like "find user by email for auth" — system reads aren't filtered
334
- // by field-access read rules).
335
- //
336
- // The design: handlers are the contract between features. Feature A requires
337
- // Feature B and talks to it through B's registered handlers — never through
338
- // direct imports of B's tables or internal types.
339
- //
340
- // TMap propagates the strict event-type-map through `appendEvent`. Defaults
341
- // to the global KumikoEventTypeMap (augmented per app via
342
- // `declare module "@cosmicdrift/kumiko-framework/engine"`). Code that bypasses the
343
- // type-map (runtime-pluggable events) uses `unsafeAppendEvent`.
344
- export type HandlerContext<TMap extends object = KumikoEventTypeMap> = SharedContextFields & {
345
- readonly db: TenantDb;
346
- readonly registry: Registry;
347
- /** Aktiver SessionUser des Handler-Aufrufs — Convenience-Alias zu
348
- * `event.user`. Existiert weil Handler intuitiv `ctx.user.tenantId`
349
- * schreiben (Context = "kennt seinen User") und der Pfad sonst nur
350
- * über `event.user` läuft, was typo-anfällig ist und stillschweigend
351
- * zu `internal_error` führt wenn der falsche Pfad gewählt wird.
352
- * Identisch zum event.user-Wert; Identity-Switches nutzen
353
- * weiterhin queryAs/writeAs. */
354
- readonly user: SessionUser;
355
- readonly systemUser?: SessionUser;
356
- readonly log?: Logger;
357
- readonly triggeredBy?: { readonly id: string; readonly tenantId: TenantId } | null;
358
- readonly _userId?: string | undefined;
359
- readonly _handlerType?: string | undefined;
360
- // Trash query opt-in (soft-delete). When true, the auto entity-list handler
361
- // asks the executor to include soft-deleted rows; a custom query handler can
362
- // read it to branch its own logic. Set by the dispatcher from the query
363
- // payload's `includeDeleted` field — visibility (tenant/ownership) filters
364
- // still apply, so this never widens what a user may see beyond the live list.
365
- readonly includeDeleted?: boolean;
366
-
367
- readonly query: (qn: string, payload: unknown) => Promise<unknown>;
368
- readonly queryAs: (user: SessionUser, qn: string, payload: unknown) => Promise<unknown>;
369
- readonly write: (qn: string, payload: unknown) => Promise<WriteResult>;
370
- readonly writeAs: (user: SessionUser, qn: string, payload: unknown) => Promise<WriteResult>;
371
-
372
- // Runtime-check whether a feature is currently effectively-enabled. Use
373
- // inside an active handler when logic should opt into behaviour that
374
- // depends on another toggleable feature being on (e.g. "if premiumInvoices
375
- // is on, add extra columns to the export"). The dispatcher gate already
376
- // blocks calls to handlers of disabled features — this is the fine-grained
377
- // opt-in counterpart, not a substitute for the gate.
378
- readonly hasFeature: (featureName: string) => boolean;
379
-
380
- // Append a domain event to a specific aggregate stream in the current tx.
381
- // Marten-aligned: every event belongs to exactly one aggregate. The runtime
382
- // reads the current stream version, bumps it, and fires projections that
383
- // match the event type in the same transaction. Use it when a write-handler
384
- // wants to record a domain event alongside the auto-generated CRUD events
385
- // (e.g. "invoice.approved" on the same invoice stream that already carries
386
- // "invoice.created" + "invoice.updated").
387
- readonly appendEvent: AppendEventFn<TMap>;
388
-
389
- // Escape-hatch for runtime-pluggable features without a compile-time
390
- // augmentation. See UnsafeAppendEventFn — same runtime as appendEvent,
391
- // but the type-surface is `payload: unknown`. Use only when the event-
392
- // type is not knowable at compile-time; otherwise the strict path
393
- // (appendEvent) is the contract Designer/AI rely on.
394
- readonly unsafeAppendEvent: UnsafeAppendEventFn;
395
-
396
- // Savepoint-scoped append. Use when the handler must gracefully continue
397
- // after losing a race against a concurrent writer on the same aggregate
398
- // stream (e.g. two idempotent ingest calls racing the same dedup key) —
399
- // see TryAppendEventFn for why this doesn't poison the transaction the
400
- // way a caught unsafeAppendEvent throw would.
401
- readonly tryAppendEvent: TryAppendEventFn;
402
-
403
- // Marten FetchForWriting equivalent: load the current stream, optionally
404
- // enforce expectedVersion, and get a handle that appends further events
405
- // onto that stream without re-specifying aggregateId/aggregateType.
406
- // Fails fast with VersionConflictError when expectedVersion doesn't
407
- // match — the write-handler never touches state it didn't expect.
408
- readonly fetchForWriting: (args: FetchForWritingArgs) => Promise<AggregateStreamHandle>;
409
-
410
- // Load the full stream of events for an aggregate, tenant-scoped to the
411
- // current user. Events pass through the registered upcaster chain, so the
412
- // payloads returned match the current schema shape regardless of when
413
- // they were written. Use inside a queryHandler to expose Marten-style
414
- // AggregateStreamAsync: hand the events to a reducer and return the
415
- // derived state (live aggregation).
416
- //
417
- // `options.asOf` restricts to events whose createdAt is ≤ the given
418
- // timestamp — the point-in-time / "what did this aggregate look like
419
- // yesterday" query.
420
- readonly loadAggregate: (
421
- aggregateId: string,
422
- options?: { readonly asOf?: Temporal.Instant },
423
- ) => Promise<readonly import("../../event-store").StoredEvent[]>;
424
-
425
- // Marten-aligned stream lifecycle. Archived streams become read-only:
426
- // ctx.appendEvent throws ArchivedStreamError, ctx.loadAggregate returns []
427
- // (pass { includeArchived: true } on the low-level loaders to override).
428
- // restoreStream reopens a stream; aggregate-level lifecycle states like
429
- // "closed" stay in the domain events, not the archive flag.
430
- readonly archiveStream: (
431
- aggregateId: string,
432
- args: { readonly aggregateType: string; readonly reason?: string },
433
- ) => Promise<void>;
434
- readonly restoreStream: (aggregateId: string) => Promise<void>;
435
- readonly isStreamArchived: (aggregateId: string) => Promise<boolean>;
436
-
437
- // Cache the current state of an aggregate as a snapshot. Callers that
438
- // hold the state (e.g. just reduced the stream in a queryHandler, or
439
- // finished a write batch) pass it in alongside the version it reflects.
440
- // The framework handles storage + upsert semantics; the snapshot policy
441
- // (every N events, every M minutes, on-demand) stays with the feature —
442
- // or pass { snapshotEvery } to loadAggregateWithSnapshot for the common case.
443
- // Snapshots are a perf optimisation — the event log remains the source
444
- // of truth.
445
- readonly snapshotAggregate: (args: {
446
- readonly aggregateId: string;
447
- readonly aggregateType: string;
448
- readonly version: number;
449
- readonly state: Record<string, unknown>;
450
- // Reducer-shape generation stamped onto the snapshot (default 1) — see
451
- // loadAggregateWithSnapshot's snapshotVersion option.
452
- readonly snapshotVersion?: number;
453
- }) => Promise<void>;
454
-
455
- // Snapshot-aware rehydrate. Loads the latest snapshot (if any), runs the
456
- // registered upcaster chain on every delta event, and folds them onto
457
- // the snapshot state with the caller's reducer. Returns the final state,
458
- // the latest event version, and whether a snapshot was used — the last
459
- // lets a feature's snapshot policy make informed decisions
460
- // (e.g. "snapshot every 100 events past the last snapshot").
461
- //
462
- // Archived streams behave like ctx.loadAggregate — empty result with
463
- // version=0, not an exception.
464
- readonly loadAggregateWithSnapshot: <TState extends Record<string, unknown>>(
465
- aggregateId: string,
466
- reducer: import("../../event-store").SnapshotReducer<TState>,
467
- initial: TState,
468
- options?: Omit<import("../../event-store").LoadAggregateWithSnapshotOptions, "upcastEvent">,
469
- ) => Promise<import("../../event-store").LoadAggregateWithSnapshotResult<TState>>;
470
-
471
- // Read rows from a registered projection table, tenant-scoped to the
472
- // current user. Marten's equivalent of session.Query<T>() — the projection
473
- // table is the read model; this surface makes it reachable by qualified
474
- // name without the feature having to import the drizzle-table directly.
475
- //
476
- // Auto-applies tenant_id filter when the projection table has a tenant_id
477
- // column (or opt out with { unsafeAllTenants: true } for system-scoped reads
478
- // like cross-tenant analytics). Unknown projection name throws.
479
- readonly queryProjection: <T = Record<string, unknown>>(
480
- qualifiedName: string,
481
- options?: { readonly unsafeAllTenants?: boolean },
482
- ) => Promise<readonly T[]>;
483
-
484
- // Always populated — Noop when no observability provider is configured.
485
- // Feature code can call ctx.metrics.inc(...) / ctx.tracer.startSpan(...)
486
- // without null-checks.
487
- readonly metrics: MetricsHandle;
488
- readonly tracer: Tracer;
489
-
490
- // Time + TZ helper. Feature-Code MUSS hier durch statt `new Date()` —
491
- // ctx.tz.now() liefert Temporal.Instant, ctx.tz.parse(wallClock, tz)
492
- // produziert ZonedDateTime, ctx.tz.toLocatedJson serialisiert für die
493
- // API-Boundary. Lint-Regel gegen `new Date()` kommt sobald alle internen
494
- // usages migriert sind. Tenant + User-TZ defaults aktuell "UTC", werden
495
- // aus tenant.timezone / user.timezone gelesen sobald die Felder existieren.
496
- readonly tz: TzContext;
497
-
498
- // Resolve every registered r.authClaims() hook against `user` and return
499
- // the merged claim record (keys auto-prefixed with the feature name). Used
500
- // by login + switch-tenant write-handlers to populate SessionUser.claims
501
- // before the JWT is signed. Thin pass-through to dispatcher.resolveAuthClaims
502
- // so there's a single resolve impl — both entry-points can't drift.
503
- readonly resolveAuthClaims: (user: SessionUser) => Promise<Record<string, unknown>>;
504
- };
505
-
506
- // Job execution: db + registry + systemUser + logging guaranteed
507
- export type JobContext = SharedContextFields & {
508
- readonly db: DbConnection;
509
- readonly registry: Registry;
510
- readonly systemUser: SessionUser;
511
- readonly log: Logger;
512
- readonly triggeredBy: { readonly id: string; readonly tenantId: TenantId } | null;
513
- };
514
-
515
- // --- Handler Functions ---
516
-
517
- export type WriteHandlerFn<TPayload = unknown, TData = unknown> = (
518
- event: WriteEvent<TPayload>,
519
- context: HandlerContext,
520
- ) => Promise<WriteResult<TData>>;
521
-
522
- export type QueryHandlerFn<TPayload = unknown, TResult = unknown> = (
523
- query: QueryEvent<TPayload>,
524
- context: HandlerContext,
525
- ) => Promise<TResult>;
526
-
527
- // --- Event Definitions ---
528
-
529
- /**
530
- * Compile-time mirror of `engine/qualified-name.ts:toKebab` for camelCase
531
- * → kebab-case. Drives the literal-type of `EventDef.name`, so that
532
- * `r.defineEvent("foo", schema)` inside `defineFeature("driverOrders")`
533
- * carries `name: "driver-orders:event:foo"` as a literal — strict-mode
534
- * for `ctx.appendEvent({ type: eventDef.name, ... })` lights up.
535
- *
536
- * Algorithm mirrors the runtime regex pipeline:
537
- * 1. `.` → `-` (dot acts as word-boundary)
538
- * 2. Insert `-` between `[A-Z]+` and `[A-Z][a-z]` (so `SSEFoo` →
539
- * `SSE-Foo`, splitting an uppercase run before a camel-hump)
540
- * 3. Insert `-` between `[a-z0-9]` and `[A-Z]` (camelCase boundary,
541
- * so `ticketAssigned` → `ticket-Assigned`)
542
- * 4. lowercase everything
543
- *
544
- * Implemented as a state machine with one-char lookahead. State:
545
- * - "start" — at start of string, or right after a dot-boundary
546
- * - "upper" — last emitted char came from an uppercase letter
547
- * - "post-letter" — last emitted char was a lowercase letter or digit
548
- *
549
- * Sync vs runtime is verified by `engine/__tests__/camel-to-kebab.test-d.ts`
550
- * — the type-tests cross-check identical inputs against `toKebab()`.
551
- */
552
- export type CamelToKebab<S extends string> = CamelToKebabImpl<S, "start", "">;
553
-
554
- type CamelToKebabImpl<
555
- S extends string,
556
- Prev extends "start" | "upper" | "post-letter",
557
- Acc extends string,
558
- > = S extends `${infer C}${infer Rest}`
559
- ? CharKind<C> extends "upper"
560
- ? Prev extends "start"
561
- ? CamelToKebabImpl<Rest, "upper", `${Acc}${Lowercase<C>}`>
562
- : Prev extends "post-letter"
563
- ? CamelToKebabImpl<Rest, "upper", `${Acc}-${Lowercase<C>}`>
564
- : // Prev = "upper" — peek next char to decide between
565
- // continuing-the-run and splitting-before-camel-hump.
566
- Rest extends `${infer Next}${string}`
567
- ? CharKind<Next> extends "lower"
568
- ? CamelToKebabImpl<Rest, "upper", `${Acc}-${Lowercase<C>}`>
569
- : CamelToKebabImpl<Rest, "upper", `${Acc}${Lowercase<C>}`>
570
- : `${Acc}${Lowercase<C>}`
571
- : CharKind<C> extends "lower"
572
- ? CamelToKebabImpl<Rest, "post-letter", `${Acc}${C}`>
573
- : // Non-letter: dots become word-boundaries (state resets to "start"
574
- // so the next uppercase letter doesn't pick up a redundant dash).
575
- // Other non-letters (digits etc.) act like lowercase for transitions.
576
- C extends "."
577
- ? CamelToKebabImpl<Rest, "start", `${Acc}-`>
578
- : CamelToKebabImpl<Rest, "post-letter", `${Acc}${C}`>
579
- : Acc;
580
-
581
- /**
582
- * Three-way classification used by `CamelToKebab`:
583
- * - "lower" — a lowercase letter (a-z and Unicode lowercase)
584
- * - "upper" — an uppercase letter (A-Z and Unicode uppercase)
585
- * - "non-letter" — digit, dot, dash, etc. (Lowercase==Uppercase for them)
586
- */
587
- type CharKind<C extends string> =
588
- C extends Lowercase<C> ? (C extends Uppercase<C> ? "non-letter" : "lower") : "upper";
589
-
590
- /**
591
- * Builds the qualified event-name from feature + inner-name in the same
592
- * shape the runtime emits via `qn(toKebab(feature), "event", toKebab(inner))`.
593
- */
594
- export type QualifiedEventName<
595
- TFeature extends string,
596
- TInner extends string,
597
- > = `${CamelToKebab<TFeature>}:event:${CamelToKebab<TInner>}`;
598
-
599
- // PII payload fields on a custom event (#799): `field` is encrypted under
600
- // the DEK of the user named by the payload's `subjectField` (crypto-
601
- // shredding). A null subject field leaves the value plaintext — there is
602
- // no user key to shred for system-triggered events.
603
- export type EventPiiFields = Readonly<Record<string, { readonly subjectField: string }>>;
604
-
605
- export type EventDef<TPayload = unknown, TName extends string = string> = {
606
- readonly name: TName;
607
- readonly schema: ZodType<TPayload>;
608
- // Schema generation number. Starts at 1; bumped whenever a breaking change
609
- // to the payload shape lands together with a matching r.eventMigration that
610
- // upcasts older stored events. Reads consult this to decide if upcasters
611
- // need to run before the payload hits consumer code.
612
- readonly version: number;
613
- readonly piiFields?: EventPiiFields;
614
- };
615
-
616
- // Args for ctx.appendEvent — explicit aggregate target, Marten-style.
617
- // `type` must match a name returned by r.defineEvent in any registered
618
- // feature; payload is validated against that event's Zod schema before
619
- // being written to the events-table.
620
- //
621
- // `headers` lands in StoredEvent.metadata.headers — Marten-conform free
622
- // key/value space for app-specific metadata (A/B-bucket, geo-region,
623
- // client SDK version). Framework does not interpret values; keep them
624
- // JSON-primitive (string|number|boolean) for safe serialization.
625
- export type AppendEventArgs = {
626
- readonly aggregateId: string;
627
- readonly aggregateType: string;
628
- readonly type: string;
629
- readonly payload: unknown;
630
- readonly headers?: Readonly<Record<string, string | number | boolean>>;
631
- };
632
-
633
- // Typed-payload variant — used by the strict ctx.appendEvent. Keyed via
634
- // the discriminator type-arg so payload inference flows from `type`-literal
635
- // to the matching schema-payload.
636
- //
637
- // TMap is propagated as a generic parameter (not hard-coded to
638
- // KumikoEventTypeMap) so the constraint `K extends keyof TMap` resolves at
639
- // USE-site instead of definition-site. Cross-package augmentation only
640
- // becomes visible at use-site — the App's tsc compiles the augmentation
641
- // alongside its own code, so `keyof TMap` widens to include all augmented
642
- // event names. Hard-coding `keyof KumikoEventTypeMap` here would resolve
643
- // at definition-site (framework's compile) where the augmentation is
644
- // invisible → K = never, no strict-checking. The default = KumikoEventTypeMap
645
- // keeps existing call-sites zero-config.
646
- export type TypedAppendEventArgs<TMap extends object, K extends keyof TMap> = {
647
- readonly aggregateId: string;
648
- readonly aggregateType: string;
649
- readonly type: K;
650
- readonly payload: TMap[K];
651
- readonly headers?: Readonly<Record<string, string | number | boolean>>;
652
- };
653
-
654
- // Strict-only form. Single overload — `<K extends keyof TMap>` against the
655
- // app's pre-bound TMap. No fallback overload: apps that need runtime-pluggable
656
- // events (where the type-string isn't known at compile-time) reach for
657
- // `unsafeAppendEvent`.
658
- //
659
- // Why no fallback overload:
660
- // A two-overload form (`(args: AppendEventArgs)` as the second sig)
661
- // silently accepts any args via the loose overload as soon as TS can't
662
- // prove the strict one matches. Cross-package, the strict overload's
663
- // `K = keyof TMap` collapses to `never` when called WITHOUT a local
664
- // wrapper (default-substitution is eager at definition-site → augmentation
665
- // invisible). Either every caller binds TMap via wrapper → strict fires;
666
- // or they don't, and the fallback would silently swallow every typo.
667
- // We pick the first option and force the wrong path to fail visibly.
668
- //
669
- // How this is wired in practice:
670
- // - Apps run `bun kumiko codegen`, which writes `.kumiko/define.ts`
671
- // with locally-bound `defineWriteHandler<TName, TSchema, TData,
672
- // KumikoEventTypeMap>(...)` wrappers. Handlers inside those wrappers
673
- // get a strict ctx.appendEvent.
674
- // - Cross-package callers (e.g. bundled-features's set.write.ts) that
675
- // can't afford a local wrapper reach for `ctx.unsafeAppendEvent`
676
- // instead — same runtime, looser type-surface.
677
- export type AppendEventFn<TMap extends object = KumikoEventTypeMap> = <K extends keyof TMap>(
678
- args: TypedAppendEventArgs<TMap, K>,
679
- ) => Promise<void>;
680
-
681
- export type UnsafeAppendEventFn = (args: AppendEventArgs) => Promise<void>;
682
-
683
- // Savepoint-scoped append — returns a discriminated result instead of
684
- // throwing on VersionConflictError, so a handler can react gracefully to
685
- // losing a race against a concurrent writer on the same aggregate stream
686
- // (e.g. two idempotent ingest calls for the same dedup key). The append
687
- // runs inside a driver-native SAVEPOINT: a conflict rolls back only that
688
- // nested scope, leaving the rest of the handler's transaction usable —
689
- // unlike unsafeAppendEvent, whose thrown VersionConflictError poisons the
690
- // entire enclosing transaction.
691
- export type TryAppendEventResult =
692
- | { readonly ok: true; readonly event: import("../../event-store").StoredEvent }
693
- | { readonly ok: false; readonly conflict: import("../../event-store").VersionConflictError };
694
-
695
- export type TryAppendEventFn = (args: AppendEventArgs) => Promise<TryAppendEventResult>;
696
-
697
- // Args for ctx.fetchForWriting — Marten FetchForWriting equivalent. Returns
698
- // the current stream state + a handle that appends without re-specifying
699
- // aggregateId/aggregateType. When expectedVersion is provided, the handle
700
- // rejects the write immediately if the stream is ahead — optimistic
701
- // concurrency enforced BEFORE any downstream work. Without expectedVersion,
702
- // the handle trusts whatever version the stream currently has.
703
- export type FetchForWritingArgs = {
704
- readonly aggregateId: string;
705
- readonly aggregateType: string;
706
- readonly expectedVersion?: number;
707
- };
708
-
709
- export type AggregateStreamHandle = {
710
- // Snapshot at fetch time — upcasted via the registered upcaster chain,
711
- // so payloads match the current schema regardless of when they landed.
712
- readonly events: readonly import("../../event-store").StoredEvent[];
713
- readonly version: number;
714
- // Append an event on this stream. Derives aggregateId/aggregateType/
715
- // expectedVersion from the handle automatically. Multiple calls in a
716
- // row bump the handle's internal version and the events-table in order.
717
- readonly appendOne: (args: { readonly type: string; readonly payload: unknown }) => Promise<void>;
718
- };
719
-
720
- // --- Event Upcasters (schema migration) ---
721
- //
722
- // Marten's Upcaster pattern adapted for TypeScript. An event's payload shape
723
- // may evolve over releases; stored events stay immutable on disk. Features
724
- // register step-wise transforms that upgrade v(N) payloads to v(N+1) at read
725
- // time. The framework chains them automatically — a v1 event gets walked
726
- // through every registered migration up to the current version before the
727
- // payload reaches a projection apply() or ctx.appendEvent consumer.
728
- //
729
- // Sync transforms: just return the upgraded payload. Most schema-evolution
730
- // (renames, additions, format-fixes) needs no IO and stays sync — fast on
731
- // the hot path of projection-rebuild.
732
- //
733
- // Async transforms (Marten's "AsyncOnlyEventUpcaster"): when the upgrade
734
- // needs DB enrichment (e.g. v1 stored only a customerId, v2 also needs the
735
- // customer's segment which lives in a reference table), accept the optional
736
- // ctx-arg, run the lookup via ctx.db, return a Promise. The framework
737
- // awaits unconditionally — sync transforms return a plain value and pay
738
- // only the await-microtask overhead. Pattern-match Marten:
739
- // r.defineEvent("invoiceCreated", schema, {
740
- // version: 2,
741
- // migrations: [{ fromVersion: 1, toVersion: 2, transform: async (payload, ctx) => {
742
- // const customer = await ctx.db.select().from(customersTable)...;
743
- // return { ...payload, customerSegment: customer.segment };
744
- // } }],
745
- // });
746
- export type EventUpcastCtx = {
747
- readonly db: import("../../db").DbRunner;
748
- readonly tenantId: import("./identifiers").TenantId;
749
- };
750
-
751
- export type EventUpcastFn = (payload: unknown, ctx: EventUpcastCtx) => unknown | Promise<unknown>;
752
-
753
- // Declarative single-step migration — common payload transforms without an
754
- // imperative function. Applied in fixed order: rename → default → map.
755
- export type DeclarativeEventMigration = {
756
- // old key → new key; a missing source key is a no-op
757
- readonly rename?: Readonly<Record<string, string>>;
758
- // set only when the key is absent — never overwrites an existing value
759
- readonly default?: Readonly<Record<string, unknown>>;
760
- // per-key value transform; skipped when the key is absent
761
- readonly map?: Readonly<Record<string, (value: unknown) => unknown>>;
762
- };
763
-
764
- export type EventMigrationDef = {
765
- // Qualified event name, matching r.defineEvent(...).name.
766
- readonly eventName: string;
767
- readonly fromVersion: number;
768
- readonly toVersion: number; // must be fromVersion + 1
769
- readonly transform: EventUpcastFn;
770
- };
771
-
772
- // --- References ---
773
-
774
- // Anything that carries a name — accepted by hooks, relations, jobs, etc.
775
- export type NameOrRef = string | { readonly name: string };
776
-
777
- export function resolveName(ref: NameOrRef): string {
778
- return typeof ref === "string" ? ref : ref.name;
779
- }
780
-
781
- export type EntityRef = {
782
- readonly name: string;
783
- readonly table: string;
784
- };
785
-
786
- export type HandlerRef = {
787
- readonly name: string;
788
- };
789
-
790
- // --- Handler Definitions (stored in feature/registry) ---
791
-
792
- // Per-handler rate limit. Bucket key derived from `per`:
793
- // "user" → userId
794
- // "tenant" → tenantId
795
- // "ip" → request IP
796
- // "user+handler" → userId + handlerName
797
- // "tenant+handler" → tenantId + handlerName
798
- // "ip+handler" → IP + handlerName (anonymous endpoints)
799
- // `cost` is the tokens this handler-call deducts. Default 1 — bump for
800
- // expensive operations (bulk export, bulk import).
801
- export type RateLimitPer =
802
- | "user"
803
- | "tenant"
804
- | "ip"
805
- | "user+handler"
806
- | "tenant+handler"
807
- | "ip+handler";
808
-
809
- export type RateLimitOption = {
810
- readonly per: RateLimitPer;
811
- readonly limit: number;
812
- readonly windowSeconds: number;
813
- readonly cost?: number;
814
- };
815
-
816
- export type WriteHandlerDef = {
817
- readonly name: string;
818
- readonly schema: ZodType;
819
- readonly handler: WriteHandlerFn;
820
- readonly access?: AccessRule;
821
- readonly unsafeSkipTransitionGuard?: boolean;
822
- readonly rateLimit?: RateLimitOption;
823
- // Set when the author wrote a `perform: stepsPipeline(...)` block. Boot-
824
- // validators (projection-allowlist) and Designer/AI tooling read this
825
- // to inspect the step list. Absent on free-form handlers.
826
- // Inline-import is intentional: step.ts imports HandlerContext from
827
- // this file, a top-level `import type { PipelineDef } from "./step"`
828
- // would form a type-only circular import that TS resolves but tooling
829
- // (incremental compile, IDEs) sometimes mis-handles.
830
- readonly perform?: import("./step").PipelineDef;
831
- };
832
-
833
- export type QueryHandlerDef = {
834
- readonly name: string;
835
- readonly schema: ZodType;
836
- readonly handler: QueryHandlerFn;
837
- readonly access?: AccessRule;
838
- readonly rateLimit?: RateLimitOption;
839
- };
1
+ // Legacy path re-exported for callers still importing this module directly.
2
+ export * from "@cosmicdrift/kumiko-types/handlers";