@cosmicdrift/kumiko-types 0.327.0 → 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 (174) 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 +360 -121
  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 -1141
  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/websocket-route.ts +0 -68
  171. package/src/where-clause-types.ts +0 -31
  172. package/src/workspace.ts +0 -42
  173. package/src/write-error-info-types.ts +0 -20
  174. /package/{src/index.ts → dist/concurrency-mode.js} +0 -0
package/src/handlers.ts DELETED
@@ -1,1246 +0,0 @@
1
- import type { Redis } from "ioredis";
2
- import type { ZodType } from "zod";
3
- import type { ConfigAccessor, ConfigAccessorFactory, ConfigResolver } from "./config";
4
- import type { DbConnection } from "./db-connection";
5
- import type { DerivativesContext } from "./derivatives-types";
6
- import type { EntityCache } from "./entity-cache";
7
- import type { EventMetadata, WriteOrigin } from "./event-store-types";
8
- import type { KumikoEventTypeMap } from "./event-type-map";
9
- import type { FileContext } from "./file-handle-types";
10
- import type { FileProviderResolver } from "./file-provider-resolver-types";
11
- import type { GeoTzProvider } from "./geo-tz";
12
- import type { Logger } from "./logger";
13
- import type { Meter, MetricsHandle, Tracer } from "./observability";
14
- import type { SearchAdapter } from "./search-adapter";
15
- import type { TenantDb } from "./tenant-db-types";
16
- import type { TzContext } from "./tz-context";
17
-
18
- // --- Access ---
19
-
20
- // "tenant-members": signed-in members may write personal data not bound to the caller by an owner rule.
21
- export type OpenToAllPersonalData = "tenant-members";
22
-
23
- export type OpenToAllDeclaration = {
24
- readonly reason: string;
25
- readonly personalData?: OpenToAllPersonalData;
26
- };
27
-
28
- export type OpenToAllAccessRule = {
29
- readonly openToAll: OpenToAllDeclaration;
30
- };
31
-
32
- // "public-intake": anonymous callers may write personal data; the required rateLimit is the only protection.
33
- export type RoleAccessPersonalData = "public-intake";
34
-
35
- export type RoleAccessRule = {
36
- readonly roles: readonly string[];
37
- readonly personalData?: RoleAccessPersonalData;
38
- };
39
-
40
- // AccessRule is DEFAULT-DENY: a handler without an access rule is not reachable.
41
- // To grant access, set one of:
42
- // - { roles: ["Admin", ...] } — role-based allowlist (empty array denies everyone)
43
- // - { openToAll: { reason: "..." } } — any authenticated user may call (still requires a valid JWT)
44
- export type AccessRule = RoleAccessRule | OpenToAllAccessRule;
45
-
46
- export type EscapeHatchDeclaration = { readonly reason: string };
47
-
48
- // AccessRule can arrive from untyped sources (pattern-library JSON, Designer)
49
- // where `openToAll` doesn't actually match the declared union — narrow via
50
- // `unknown` instead of trusting the static type, deny on anything malformed.
51
- export function isOpenToAllGranted(rule: AccessRule): boolean {
52
- if (!("openToAll" in rule)) return false;
53
- const openToAll: unknown = rule.openToAll;
54
- if (typeof openToAll !== "object" || openToAll === null) return false;
55
- if (!("reason" in openToAll) || typeof openToAll.reason !== "string") return false;
56
- return openToAll.reason.trim().length > 0;
57
- }
58
-
59
- // UI-visibility predicate — DEFAULT-VISIBLE, the opposite stance from
60
- // `hasAccess`/AccessRule's own default-deny doc above: an unset `access`
61
- // means every signed-in user may see the screen/nav/workspace, not nobody.
62
- // The engine's dispatcher access check stays default-deny; this only
63
- // decides what a client renders/lists, never what a handler accepts.
64
- // Same `unknown`-narrowing caution as isOpenToAllGranted — `access` can
65
- // arrive from untyped sources.
66
- export function isUiAccessGranted(
67
- access: AccessRule | undefined,
68
- userRoles: readonly string[] | undefined,
69
- ): boolean {
70
- if (access === undefined) return true;
71
- if ("openToAll" in access) return isOpenToAllGranted(access);
72
- if (userRoles === undefined) return false;
73
- if (!("roles" in access)) return false;
74
- return access.roles.some((role) => userRoles.includes(role));
75
- }
76
-
77
- // --- Pipeline User ---
78
-
79
- // Set only on a SessionUser the framework resolved internally for a
80
- // background read (ctx.queryAsMember) — such a principal never carries `sid`.
81
- export type SessionUserOrigin = "member-resolution";
82
-
83
- export type SessionUser = {
84
- // UUID-string so user.id threads through the event-store (aggregate-id) and
85
- // the projection tables (uuid PK) without casts. Auth middleware reads the
86
- // JWT `sub` claim as a string; legacy integer ids were a pre-ES artefact.
87
- readonly id: string;
88
- readonly tenantId: TenantId;
89
- readonly roles: readonly string[];
90
- // IANA zone from user.timezone, set at login. Absent → ctx.tz falls back
91
- // to tenant.timezone, then "UTC" (buildHandlerContext, fw#1636). Stale
92
- // until re-login — same tradeoff as `roles`.
93
- readonly timezone?: string;
94
- // BCP-47 tag from user.locale, set at login. Absent → ctx.locale falls
95
- // back to the app's boot-configured defaultLocale, then "en"
96
- // (dispatch-shared.ts, fw#2333). Stale until re-login — same tradeoff as
97
- // `timezone`.
98
- readonly locale?: string;
99
- // App-specific identity facts baked into the JWT at login time.
100
- // Populated by `r.authClaims()` hooks (not yet implemented — see the
101
- // auth-claims design note in docs/plans). Reserved here so the type shape
102
- // is stable when the hook system lands.
103
- readonly claims?: Readonly<Record<string, unknown>>;
104
- // Session-ID — transported via the JWT `jti` standard claim. Present when
105
- // an app has wired a `sessionCreator` callback on the auth-routes config
106
- // (e.g. via the `sessions` feature). Absent for stateless-JWT deployments.
107
- // When present, middleware can validate that the sid is still alive before
108
- // accepting the request (session revocation).
109
- readonly sid?: string;
110
- // Set ONLY when the request authenticated via a Personal Access Token
111
- // (bearer, PAT_TOKEN_PREFIX). Absent for cookie/JWT logins, which stay
112
- // unrestricted. `allowedQns` are the QN globs the token's granted scopes
113
- // expand to; the API boundary (routes.ts) rejects any dispatch type not
114
- // matched by one of them (fail-closed). `scopes` are the granted scope
115
- // names, kept for audit/display only.
116
- readonly pat?: {
117
- // The token row id — the per-token key for PAT rate limiting. Not the
118
- // secret; safe to carry on the principal.
119
- readonly tokenId: string;
120
- readonly scopes: readonly string[];
121
- readonly allowedQns: readonly string[];
122
- };
123
- readonly origin?: SessionUserOrigin;
124
- };
125
-
126
- // --- Claim Keys (r.claimKey declarations) ---
127
-
128
- // Declared claim shape. Features call r.claimKey("teamId", { type: "string" })
129
- // and get back a typed handle. Feature code then uses the handle both when
130
- // reading via readClaim(user, handle) and (optionally) when returning from
131
- // r.authClaims hooks. Two-fold payoff:
132
- //
133
- // 1. Read-site is typesafe: `const teamId = readClaim(user, DriverClaims.teamId)`
134
- // narrows to `string | undefined` automatically — no hand-written cast,
135
- // no magic "drivers:teamId" string.
136
- // 2. Runtime check: the resolver warns when a hook returns an inner-key
137
- // that the feature didn't declare — catches rename/typo drift. Opt-in
138
- // per feature: only checked when r.claimKey was used at least once.
139
- //
140
- // Keep the type union small and explicit. JS-side inference via ClaimKeyJsType
141
- // maps each literal to a primitive or array — broader shapes (nested
142
- // records) can land in "object" but lose narrowness; that's the trade-off
143
- // for keeping the type-system simple.
144
- export type ClaimKeyType = "string" | "number" | "boolean" | "string[]" | "object";
145
-
146
- export type ClaimKeyJsType<T extends ClaimKeyType> = T extends "string"
147
- ? string
148
- : T extends "number"
149
- ? number
150
- : T extends "boolean"
151
- ? boolean
152
- : T extends "string[]"
153
- ? readonly string[]
154
- : T extends "object"
155
- ? Readonly<Record<string, unknown>>
156
- : never;
157
-
158
- // Stored on the FeatureDefinition. `qualifiedName` is auto-set at
159
- // registration time ("<feature>:<inner-kebab>") — same naming convention
160
- // as auth-claim keys.
161
- export type ClaimKeyDefinition = {
162
- readonly shortName: string;
163
- readonly qualifiedName: string;
164
- readonly type: ClaimKeyType;
165
- };
166
-
167
- // Typed handle returned by r.claimKey(). `name` is the qualified key the
168
- // JWT stores; `type` threads through to readClaim's generic so consumers
169
- // get the right narrowed type without casting.
170
- export type ClaimKeyHandle<T extends ClaimKeyType = ClaimKeyType> = {
171
- readonly name: string;
172
- readonly type: T;
173
- };
174
-
175
- // --- Auth Claims (r.authClaims hook) ---
176
-
177
- // Features contribute "identity facts" into the JWT at login time. Claim keys
178
- // are auto-prefixed with the feature name at merge time (`"<feature>:<key>"`)
179
- // so two features can't collide — Reading code in a handler picks the claim
180
- // by its prefixed key: `user.claims["drivers:teamId"]`.
181
- //
182
- // The context is deliberately trimmed compared to HandlerContext: login is a
183
- // READ, not a write-path. Exposing appendEvent/loadAggregate/tz here would
184
- // let claims hooks reach into write-time concerns — not their job, bigger
185
- // mocking surface in tests. `db` is guaranteed tenant-scoped to the chosen
186
- // tenant (the one the user is logging INTO, not the one making the request).
187
- // `queryAs` lets a hook call another feature's query handler without direct
188
- // imports — same cross-feature contract hooks otherwise follow.
189
- export type AuthClaimsContext = {
190
- readonly db: import("./tenant-db-types").TenantDb;
191
- readonly queryAs: (user: SessionUser, qn: string, payload: unknown) => Promise<unknown>;
192
- readonly config?: ConfigAccessor;
193
- };
194
-
195
- export type AuthClaimsFn = (
196
- user: SessionUser,
197
- ctx: AuthClaimsContext,
198
- ) => Promise<Record<string, unknown>>;
199
-
200
- // What the registry stores per registered hook. `featureName` drives the
201
- // auto-prefix at merge time, so the registry is the source of truth for the
202
- // naming — features never ship pre-prefixed keys.
203
- //
204
- // `declaredKeys` is the set of inner-keys this hook's feature declared via
205
- // r.claimKey() — the resolver uses it to warn when a hook returns a key
206
- // that was never declared (typo / rename drift). `undefined` when the
207
- // feature never called r.claimKey(), in which case the resolver skips the
208
- // check entirely (backwards-compat for features that only use r.authClaims).
209
- export type AuthClaimsHookDef = {
210
- readonly featureName: string;
211
- readonly fn: AuthClaimsFn;
212
- readonly declaredKeys?: ReadonlySet<string>;
213
- };
214
-
215
- // --- Active Membership ---
216
-
217
- // Not a member of the target tenant, the principal is blocked (see
218
- // engine/active-membership.ts's principalStatus contract), or the tenant is in teardown.
219
- export type ActiveMembershipRejection = "not_a_member" | "principal_blocked" | "tenant_teardown";
220
-
221
- export type ActiveMembership = {
222
- readonly tenantId: TenantId;
223
- // Raw membership roles — the caller runs buildSessionRoles(globalRoles,
224
- // roles) itself; this building block never merges global roles in.
225
- readonly roles: readonly string[];
226
- readonly tenantName?: string;
227
- readonly tenantKey?: string;
228
- };
229
-
230
- export type ActiveMembershipResult =
231
- | { readonly kind: "active"; readonly membership: ActiveMembership }
232
- | { readonly kind: "rejected"; readonly reason: ActiveMembershipRejection };
233
-
234
- // --- Handler Events ---
235
-
236
- export type WriteEvent<TPayload = unknown> = {
237
- readonly type: string;
238
- readonly payload: TPayload;
239
- readonly user: SessionUser;
240
- };
241
-
242
- export type QueryEvent<TPayload = unknown> = {
243
- readonly type: string;
244
- readonly payload: TPayload;
245
- readonly user: SessionUser;
246
- };
247
-
248
- // --- Handler Results ---
249
-
250
- import type { WriteFailure } from "./write-error-info-types";
251
-
252
- export type WriteResult<TData = unknown> =
253
- | { readonly isSuccess: true; readonly data: TData }
254
- | WriteFailure;
255
-
256
- // --- Context Types ---
257
-
258
- // Forward import: Registry is in feature.ts (circular type import — fine in TS)
259
- import type { Registry } from "./feature";
260
- import type { TenantId } from "./identifiers";
261
- import type { UncheckedSystemDb } from "./tenant-db-types";
262
-
263
- // The framework resolves the member internally, so no hand-built SessionUser reaches app code.
264
- export type MemberReader = (userId: string, qn: string, payload: unknown) => Promise<unknown>;
265
-
266
- // The stored event that made an r.job trigger fire, carried through so the
267
- // job handler can key an idempotency check on it (at-least-once delivery via
268
- // the job-trigger event consumer, see pipeline/system-hooks.ts).
269
- export type JobTriggerEvent = {
270
- readonly id: string;
271
- readonly headers: NonNullable<EventMetadata["headers"]>;
272
- };
273
-
274
- // Minimal interface for job event triggers (framework-owned, concrete type in jobs/)
275
- export type JobRunnerRef = {
276
- handleEvent(
277
- eventName: string,
278
- payload: Record<string, unknown>,
279
- user?: SessionUser,
280
- triggerEvent?: JobTriggerEvent,
281
- ): Promise<void>;
282
- };
283
-
284
- // Minimal interface a JobRunner attaches via attachDispatcher() to reach the
285
- // write pipeline — framework-owned, concrete type wraps the dispatcher's
286
- // executeWrite/executeQuery. Unlike HandlerContext.write (identity implicit
287
- // via closure), both take an explicit user: a JobRunner is a boot-time
288
- // singleton, not bound to one caller's identity. Only write+queryAs — no
289
- // .query/.stream/.command/.batch/.resolveAuthClaims, that stays Dispatcher-only.
290
- export type DispatchWriteRef = {
291
- // A job under a gated anonymous origin passes it, else the target would
292
- // reset to the job's own non-anonymous root.
293
- readonly write: (
294
- user: SessionUser,
295
- qn: string,
296
- payload: unknown,
297
- inheritedOrigin?: WriteOrigin,
298
- ) => Promise<WriteResult>;
299
- readonly queryAs: (
300
- user: SessionUser,
301
- qn: string,
302
- payload: unknown,
303
- inheritedOrigin?: WriteOrigin,
304
- ) => Promise<unknown>;
305
- // Builds a tenant-scoped MemberReader — one per JobContext.queryAsMember
306
- // caller (job-runner.ts lazily creates one per job run).
307
- readonly createMemberReader: (tenantId: TenantId) => MemberReader;
308
- };
309
-
310
- // Priority levels for notifications
311
- export type NotifyPriority = "critical" | "normal" | "low";
312
-
313
- // Options passed to a NotifyFn / DeliveryService.notify. Defined here so the
314
- // framework side and the concrete delivery implementation can't drift apart.
315
- export type NotifyOptions = {
316
- readonly to?: string | readonly string[] | { readonly tenant: TenantId };
317
- readonly route?: Readonly<Record<string, string>>;
318
- readonly data?: Readonly<Record<string, unknown>>;
319
- readonly priority?: NotifyPriority;
320
- // Opt-in dedup. Same key within 24h = single delivery. Use when a handler
321
- // can be replayed (webhook retry, user double-click) and you don't want
322
- // the notification to fire twice.
323
- readonly idempotencyKey?: string;
324
- // Pseudonymous subject key for the `route` path (no user account). Threaded
325
- // into the delivery-attempt event's recipientId so crypto-shredding's
326
- // forget-subject can reach recipientAddress under this subject. Ignored on
327
- // the `to` path — there the resolved user id is the recipientId, silently.
328
- readonly recipientId?: string;
329
- };
330
-
331
- // Minimal interface for delivery notifications (concrete type in bundled-features/delivery)
332
- export type NotifyFn = (notificationType: string, options: NotifyOptions) => Promise<void>;
333
-
334
- // Factory that produces a bound NotifyFn for a specific user+tenant
335
- // Concrete implementation in bundled-features/delivery (cross-package boundary)
336
- export type NotifyFactory = (user: SessionUser, tenantId: TenantId) => NotifyFn;
337
-
338
- export type EscapeHatchKind =
339
- | "unsafe-raw"
340
- | "acknowledge-cross-tenant"
341
- | "global-write"
342
- | "identity-switch"
343
- | "unsafe-all-tenants";
344
- export type EscapeHatchTarget = { readonly id: string; readonly tenantId: TenantId };
345
- export type EscapeHatchUseEvent = {
346
- readonly handler: string;
347
- readonly kind: EscapeHatchKind;
348
- readonly reason: string;
349
- readonly tenantId: TenantId;
350
- readonly actor: string;
351
- readonly target?: EscapeHatchTarget;
352
- };
353
- export type EscapeHatchAuditSink = (event: EscapeHatchUseEvent) => Promise<void>;
354
- // Bound to handler/tenant/actor of one invocation; called once per actual escape-hatch use.
355
- export type EscapeHatchReporter = (
356
- kind: EscapeHatchKind,
357
- reason: string,
358
- target?: EscapeHatchTarget,
359
- ) => void;
360
-
361
- // Shared optional fields across all execution contexts
362
- type SharedContextFields = {
363
- readonly redis?: Redis;
364
- readonly jobRunner?: JobRunnerRef;
365
- readonly configResolver?: ConfigResolver;
366
- readonly config?: ConfigAccessor;
367
- readonly _configAccessorFactory?: ConfigAccessorFactory;
368
- // Encryption round-trip partner for the config feature. Separate from
369
- // configResolver so the read-only resolver contract stays clean — the
370
- // set handler needs to encrypt on write, the resolver needs to decrypt
371
- // on read, and both reach for the same cipher. Wired via extraContext;
372
- // run{Prod,Dev}App build it from the secrets master key automatically.
373
- readonly configEncryption?: import("./envelope-cipher-types").EnvelopeCipher;
374
- // Rate-limit resolver. Wired by the framework when the `rate-limiting`
375
- // feature is loaded — pipeline reads handler.rateLimit and calls
376
- // .enforce() on this resolver before access-check. Absent when the
377
- // app didn't load the feature: handlers with rateLimit set are
378
- // rejected at boot to surface the misconfig early.
379
- readonly rateLimit?: import("./rate-limit-types").RateLimitResolver;
380
- readonly searchAdapter?: SearchAdapter;
381
- // Binary storage. The dispatcher builds this per-call, bound to the caller's
382
- // tenant, from `_fileProviderResolver` (below) — so uploads, ctx.files and the
383
- // GDPR jobs all resolve through the same file-foundation provider. Hooks/
384
- // handlers use ctx.files.ref(key) instead of receiving binaries in payloads.
385
- readonly files?: FileContext;
386
- // Derive-on-first-use variants (thumbnails, resized/reformatted images) of a
387
- // tracked FileRef. Present whenever `files` resolves — see files above.
388
- readonly derivatives?: DerivativesContext;
389
- // Boot-built, per-tenant file-provider resolver. Set by buildServer when a
390
- // `file-provider-*` plugin is mounted; the dispatcher reads it to materialise
391
- // ctx.files (and the upload routes + MSP-applies use the same resolver).
392
- // Resolution runs under system identity for the s3.secretAccessKey read.
393
- readonly _fileProviderResolver?: FileProviderResolver;
394
- readonly entityCache?: EntityCache;
395
- readonly notify?: NotifyFn;
396
- readonly _notifyFactory?: NotifyFactory;
397
- // Wired at boot when the `audit` feature is mounted; absent → structured warn log.
398
- readonly _escapeHatchAuditSink?: EscapeHatchAuditSink;
399
- // Tenant-scoped secrets accessor. Present when the app wired a
400
- // MasterKeyProvider at boot. Feature code reads ctx.secrets.get(...)
401
- // to pull a plaintext secret; Secret<string> carries the brand that
402
- // the response guard rejects on serialization.
403
- readonly secrets?: import("./secrets-types").SecretsContext;
404
- // Raw KEK provider. Present alongside ctx.secrets — needed by the rotation
405
- // job which deliberately operates outside the per-call audit trail (it
406
- // processes rows system-wide, not a per-user read).
407
- readonly masterKeyProvider?: import("./secrets-types").MasterKeyProvider;
408
- // Subject-key adapter for crypto-shredding (GDPR Art. 17). Present when
409
- // the app wired a KmsAdapter at boot; the PII envelope engine and the
410
- // forget pipeline reach for it. Absent = crypto-shredding not enabled.
411
- readonly kms?: import("./kms-adapter-types").KmsAdapter;
412
- // Observability: optional at the outer boundary, always populated by the
413
- // time a handler receives its ctx (Noop fallback when no provider is
414
- // configured, so handler code can call ctx.tracer/ctx.metrics without
415
- // defensive checks).
416
- readonly tracer?: Tracer;
417
- readonly meter?: Meter;
418
- // Cancellation. Aborts when the HTTP client disconnects (mobile back,
419
- // tab close). Undefined for non-HTTP entry-points (jobs, MSP-applies).
420
- // Long-running handlers (export jobs, multi-step workflows) should
421
- // throw `signal.throwIfAborted()` at chunk boundaries; short handlers
422
- // can ignore it. Framework primitives (streamAllEventsByType,
423
- // rebuildProjection) honour it automatically.
424
- readonly signal?: AbortSignal;
425
- // Effective feature-toggle resolver. Wired by the dispatcher when the
426
- // feature-toggles or tier-engine feature is loaded — the lifecycle
427
- // pipeline, MSP runner, and ctx.hasFeature all read from this single
428
- // source. Per-tenant: tenantId argument enables tier-cuts (Sprint 8a)
429
- // where Tenant-A sees Pro features and Tenant-B sees Free features in
430
- // the same process. Returns the Set of feature names effectively
431
- // enabled for that tenant. Absent = all features on (back-compat).
432
- readonly effectiveFeatures?: (tenantId: TenantId) => ReadonlySet<string>;
433
- // Queue a side-effect to run after the outermost write transaction commits
434
- // (same sink as ctx.write / dispatch-write lifecycle afterCommit). Seed
435
- // helpers and other mid-tx callers use this instead of firing afterCommit
436
- // hooks synchronously (kumiko-framework#1566). Absent outside a write
437
- // pipeline — callers fall back to immediate fire (fixture / no-tx paths).
438
- readonly scheduleAfterCommit?: (hook: () => Promise<void>) => void;
439
- // Present on HandlerContext/JobContext; hooks receive HandlerContext as
440
- // AppContext, so it's optional here. See HandlerContext.queryAsMember.
441
- readonly queryAsMember?: MemberReader;
442
- };
443
-
444
- // All optional — used at pipeline/system boundaries.
445
- // `db` is a DbConnection at the outer boundary (server/stack) and a TenantDb
446
- // once a HandlerContext has been built — hooks receive the HandlerContext as
447
- // AppContext, so the union keeps that assignment straightforward.
448
- export type AppContext = SharedContextFields & {
449
- readonly db?: DbConnection | TenantDb;
450
- // Hooks receive the same HandlerContext as their AppContext, so `db` is fail-closed here too.
451
- readonly systemDb?: UncheckedSystemDb;
452
- readonly registry?: Registry;
453
- readonly systemUser?: SessionUser;
454
- readonly log?: Logger;
455
- readonly triggeredBy?: { readonly id: string; readonly tenantId: TenantId } | null;
456
- /** Bei Job-Handler-Aufrufen die aus einem Event-Trigger heraus laufen
457
- * (r.job mit `trigger: { on: ... }`): der Name des Handlers der das
458
- * Event ausgelöst hat. Bei Multi-Trigger-Jobs (`on: [...]`) ist das
459
- * die einzige Möglichkeit für den Handler zu wissen WELCHER Trigger
460
- * gefeuert hat. Cron- und manual-Jobs lassen das Feld undefined. */
461
- readonly triggerName?: string;
462
- readonly _userId?: string | undefined;
463
- readonly _handlerType?: string | undefined;
464
- /** Optionaler Geo→Zone-Adapter. Wenn gesetzt (via buildServer-context oder
465
- * runProdApp/runDevApp extraContext), reicht der Dispatcher ihn an
466
- * ctx.tz.fromCoordinates / fromAddress weiter. Ohne Provider werfen diese. */
467
- readonly geoTzProvider?: GeoTzProvider;
468
- /** Tenant des aktuellen Pipeline-Calls. Wird vom Dispatcher beim Bauen
469
- * des HandlerContext aus `user.tenantId` gespiegelt, damit lifecycle-
470
- * pipeline + system-hooks den Wert ohne Zugriff auf user-Object haben.
471
- * Sprint-8a Tier-Composition: `effectiveFeatures(ctx._tenantId)`-call
472
- * in den hook-filter-Stellen. */
473
- readonly _tenantId?: TenantId;
474
- /** Boot-configured default locale (BCP-47). Last fallback in ctx.locale's
475
- * Request-Locale → Boot-Default chain (dispatch-shared.ts) — used when
476
- * the request carries no X-Locale header and no usable Accept-Language.
477
- * Absent falls back to "en". */
478
- readonly defaultLocale?: string;
479
- };
480
-
481
- // Handler execution: db (tenant-scoped) + registry guaranteed.
482
- //
483
- // Cross-feature bridge:
484
- // ctx.query / ctx.write run the target handler AS THE CURRENT USER,
485
- // sharing the active tx + afterCommit queue. Field-access filters apply.
486
- // ctx.queryAs / ctx.writeAs switch identity (e.g. SYSTEM for privileged
487
- // lookups like "find user by email for auth" — system reads aren't filtered
488
- // by field-access read rules). Any target other than the caller itself (or
489
- // a subset of its roles) is gated: reachable only from an r.systemScope()
490
- // feature, a job, or a handler/hook that
491
- // declared { escapeHatch: { reason } } (system-identity-switch.ts).
492
- //
493
- // The design: handlers are the contract between features. Feature A requires
494
- // Feature B and talks to it through B's registered handlers — never through
495
- // direct imports of B's tables or internal types.
496
- //
497
- // TMap propagates the strict event-type-map through `appendEvent`. Defaults
498
- // to the global KumikoEventTypeMap (augmented per app via
499
- // `declare module "@cosmicdrift/kumiko-framework/engine"`). Code that bypasses the
500
- // type-map (runtime-pluggable events) uses `unsafeAppendEvent`.
501
- export type HandlerContext<TMap extends object = KumikoEventTypeMap> = SharedContextFields & {
502
- // Tenant-filtered for ordinary handlers. For r.systemScope() handlers this
503
- // is fail-closed instead (dispatch-shared.ts hands back a guard that
504
- // throws on first touch) — there's no type-level way to make `db` optional
505
- // only for system handlers (isSystem is a runtime-only registry lookup),
506
- // so a system handler must reach for `systemDb` below.
507
- readonly db: TenantDb;
508
- // Same tenant scoping as `db`, but never bound to the handler's tx: writes
509
- // through it survive a rollback, for side effects that already happened
510
- // outside the DB. `undefined` when the pipeline has no outside-tx source
511
- // for this dispatch (see dispatch-shared.ts) — callers must check before
512
- // use. For r.systemScope() handlers this is fail-closed instead, same as
513
- // `db` above — a truthy Proxy that throws on first property read, so a
514
- // system handler must reach for `systemDb.outsideTransaction` below.
515
- readonly dbOutsideTransaction: TenantDb | undefined;
516
- // Only present for r.systemScope() handlers. Non-system handlers never
517
- // receive this — reach for `db` instead, which is fail-closed for them.
518
- readonly systemDb?: UncheckedSystemDb;
519
- readonly registry: Registry;
520
- /** Aktiver SessionUser des Handler-Aufrufs — Convenience-Alias zu
521
- * `event.user`. Existiert weil Handler intuitiv `ctx.user.tenantId`
522
- * schreiben (Context = "kennt seinen User") und der Pfad sonst nur
523
- * über `event.user` läuft, was typo-anfällig ist und stillschweigend
524
- * zu `internal_error` führt wenn der falsche Pfad gewählt wird.
525
- * Identisch zum event.user-Wert; Identity-Switches nutzen
526
- * weiterhin queryAs/writeAs. */
527
- readonly user: SessionUser;
528
- readonly systemUser?: SessionUser;
529
- readonly log?: Logger;
530
- readonly triggeredBy?: { readonly id: string; readonly tenantId: TenantId } | null;
531
- readonly _userId?: string | undefined;
532
- readonly _handlerType?: string | undefined;
533
- // Trash query opt-in (soft-delete). When true, the auto entity-list handler
534
- // asks the executor to include soft-deleted rows; a custom query handler can
535
- // read it to branch its own logic. Set by the dispatcher from the query
536
- // payload's `includeDeleted` field — visibility (tenant/ownership) filters
537
- // still apply, so this never widens what a user may see beyond the live list.
538
- readonly includeDeleted?: boolean;
539
-
540
- // Wired by the write dispatcher whenever a lifecycle pipeline is active;
541
- // runs the registered `preSave` hooks (+ system preSave hooks) for
542
- // `handlerName`, returning the hook-transformed `changes`. Entity CRUD
543
- // handlers (create/update) forward this to the executor so preSave hooks
544
- // actually run before persistence (kumiko-framework#1672). A custom
545
- // `r.writeHandler` handler that doesn't go through the executor must call
546
- // this itself to get preSave semantics — undefined when no lifecycle
547
- // pipeline is wired (e.g. bare HandlerContext stubs in unit tests).
548
- readonly runPreSave?: (
549
- handlerName: string,
550
- changes: Record<string, unknown>,
551
- previous: Readonly<Record<string, unknown>>,
552
- isNew: boolean,
553
- ) => Promise<Record<string, unknown>>;
554
-
555
- readonly query: (qn: string, payload: unknown) => Promise<unknown>;
556
- readonly queryAs: (user: SessionUser, qn: string, payload: unknown) => Promise<unknown>;
557
- readonly write: (qn: string, payload: unknown) => Promise<WriteResult>;
558
- readonly writeAs: (user: SessionUser, qn: string, payload: unknown) => Promise<WriteResult>;
559
-
560
- // Runtime-check whether a feature is currently effectively-enabled. Use
561
- // inside an active handler when logic should opt into behaviour that
562
- // depends on another toggleable feature being on (e.g. "if premiumInvoices
563
- // is on, add extra columns to the export"). The dispatcher gate already
564
- // blocks calls to handlers of disabled features — this is the fine-grained
565
- // opt-in counterpart, not a substitute for the gate.
566
- //
567
- // Async: falls back to the live trial-gate (tenant.inserted_at-derived,
568
- // can't live in the boot-cached sync resolver) whenever the synchronous
569
- // feature-set says a feature is off — otherwise trial tenants checking a
570
- // companion feature (not their own handler's owning feature) would see a
571
- // stale `false`.
572
- readonly hasFeature: (featureName: string) => Promise<boolean>;
573
-
574
- // Append a domain event to a specific aggregate stream in the current tx.
575
- // Marten-aligned: every event belongs to exactly one aggregate. The runtime
576
- // reads the current stream version, bumps it, and fires projections that
577
- // match the event type in the same transaction. Use it when a write-handler
578
- // wants to record a domain event alongside the auto-generated CRUD events
579
- // (e.g. "invoice.approved" on the same invoice stream that already carries
580
- // "invoice.created" + "invoice.updated").
581
- readonly appendEvent: AppendEventFn<TMap>;
582
-
583
- // Escape-hatch for runtime-pluggable features without a compile-time
584
- // augmentation. See UnsafeAppendEventFn — same runtime as appendEvent,
585
- // but the type-surface is `payload: unknown`. Use only when the event-
586
- // type is not knowable at compile-time; otherwise the strict path
587
- // (appendEvent) is the contract Designer/AI rely on.
588
- readonly unsafeAppendEvent: UnsafeAppendEventFn;
589
-
590
- // Savepoint-scoped append. Use when the handler must gracefully continue
591
- // after losing a race against a concurrent writer on the same aggregate
592
- // stream (e.g. two idempotent ingest calls racing the same dedup key) —
593
- // see TryAppendEventFn for why this doesn't poison the transaction the
594
- // way a caught unsafeAppendEvent throw would.
595
- readonly tryAppendEvent: TryAppendEventFn;
596
-
597
- // Marten FetchForWriting equivalent: load the current stream, optionally
598
- // enforce expectedVersion, and get a handle that appends further events
599
- // onto that stream without re-specifying aggregateId/aggregateType.
600
- // Fails fast with VersionConflictError when expectedVersion doesn't
601
- // match — the write-handler never touches state it didn't expect.
602
- readonly fetchForWriting: (args: FetchForWritingArgs) => Promise<AggregateStreamHandle>;
603
-
604
- // Load the full stream of events for an aggregate, tenant-scoped to the
605
- // current user. Events pass through the registered upcaster chain, so the
606
- // payloads returned match the current schema shape regardless of when
607
- // they were written. Use inside a queryHandler to expose Marten-style
608
- // AggregateStreamAsync: hand the events to a reducer and return the
609
- // derived state (live aggregation).
610
- //
611
- // `options.asOf` restricts to events whose createdAt is ≤ the given
612
- // timestamp — the point-in-time / "what did this aggregate look like
613
- // yesterday" query.
614
- readonly loadAggregate: (
615
- aggregateId: string,
616
- options?: { readonly asOf?: Temporal.Instant },
617
- ) => Promise<readonly import("./event-store-types").StoredEvent[]>;
618
-
619
- // Marten-aligned stream lifecycle. Archived streams become read-only:
620
- // ctx.appendEvent throws ArchivedStreamError, ctx.loadAggregate returns []
621
- // (pass { includeArchived: true } on the low-level loaders to override).
622
- // restoreStream reopens a stream; aggregate-level lifecycle states like
623
- // "closed" stay in the domain events, not the archive flag.
624
- readonly archiveStream: (
625
- aggregateId: string,
626
- args: { readonly aggregateType: string; readonly reason?: string },
627
- ) => Promise<void>;
628
- readonly restoreStream: (aggregateId: string) => Promise<void>;
629
- readonly isStreamArchived: (aggregateId: string) => Promise<boolean>;
630
-
631
- // Cache the current state of an aggregate as a snapshot. Callers that
632
- // hold the state (e.g. just reduced the stream in a queryHandler, or
633
- // finished a write batch) pass it in alongside the version it reflects.
634
- // The framework handles storage + upsert semantics; the snapshot policy
635
- // (every N events, every M minutes, on-demand) stays with the feature —
636
- // or pass { snapshotEvery } to loadAggregateWithSnapshot for the common case.
637
- // Snapshots are a perf optimisation — the event log remains the source
638
- // of truth.
639
- readonly snapshotAggregate: (args: {
640
- readonly aggregateId: string;
641
- readonly aggregateType: string;
642
- readonly version: number;
643
- readonly state: Record<string, unknown>;
644
- // Reducer-shape generation stamped onto the snapshot (default 1) — see
645
- // loadAggregateWithSnapshot's snapshotVersion option.
646
- readonly snapshotVersion?: number;
647
- }) => Promise<void>;
648
-
649
- // Snapshot-aware rehydrate. Loads the latest snapshot (if any), runs the
650
- // registered upcaster chain on every delta event, and folds them onto
651
- // the snapshot state with the caller's reducer. Returns the final state,
652
- // the latest event version, and whether a snapshot was used — the last
653
- // lets a feature's snapshot policy make informed decisions
654
- // (e.g. "snapshot every 100 events past the last snapshot").
655
- //
656
- // Archived streams behave like ctx.loadAggregate — empty result with
657
- // version=0, not an exception.
658
- readonly loadAggregateWithSnapshot: <TState extends Record<string, unknown>>(
659
- aggregateId: string,
660
- reducer: import("./snapshot-types").SnapshotReducer<TState>,
661
- initial: TState,
662
- options?: Omit<import("./snapshot-types").LoadAggregateWithSnapshotOptions, "upcastEvent">,
663
- ) => Promise<import("./snapshot-types").LoadAggregateWithSnapshotResult<TState>>;
664
-
665
- // Read rows from a registered projection table, tenant-scoped to the
666
- // current user. Marten's equivalent of session.Query<T>() — the projection
667
- // table is the read model; this surface makes it reachable by qualified
668
- // name without the feature having to import the drizzle-table directly.
669
- //
670
- // Auto-applies tenant_id filter when the projection table has a tenant_id
671
- // column. { unsafeAllTenants: true } opts out but requires a grant —
672
- // r.systemScope() or a declared escapeHatch on the handler (or hook).
673
- // Unknown projection name throws.
674
- readonly queryProjection: <T = Record<string, unknown>>(
675
- qualifiedName: string,
676
- options?: { readonly unsafeAllTenants?: boolean },
677
- ) => Promise<readonly T[]>;
678
-
679
- // Always populated — Noop when no observability provider is configured.
680
- // Feature code can call ctx.metrics.inc(...) / ctx.tracer.startSpan(...)
681
- // without null-checks.
682
- readonly metrics: MetricsHandle;
683
-
684
- // For shared/library code invoked from many features' HandlerContext
685
- // (e.g. a helper package called by several consumer features): binds to
686
- // `featureName` instead of the dispatching handler's own feature, so the
687
- // library counts under one stable kumiko_<featureName>_x name instead of
688
- // splintering per caller. `featureName` accepts the same kebab-case form
689
- // used as the feature's own name at defineFeature() — normalized to
690
- // snake_case internally before resolving the metric name.
691
- //
692
- // Unlike ctx.metrics, an unregistered metric name here is a silent
693
- // no-op, not a throw (framework#1844) — this handle is meant for
694
- // error/catch-path counters in shared code, where a missing registration
695
- // must not turn an already-swallowed error into a thrown one.
696
- readonly metricsFor: (featureName: string) => MetricsHandle;
697
- readonly tracer: Tracer;
698
-
699
- // Time + TZ helper. Feature code MUST go through this instead of
700
- // `new Date()` — ctx.tz.now() returns Temporal.Instant, ctx.tz.parse
701
- // (wallClock, tz) produces a ZonedDateTime, ctx.tz.toLocatedJson
702
- // serializes for the API boundary. The lint rule against `new Date()`
703
- // lands once all internal usages are migrated. tenant reads
704
- // tenant:config:timezone (falls back to "UTC" when no config feature is
705
- // mounted or the value is unset); user reads SessionUser.timezone (falls
706
- // back to tenant).
707
- readonly tz: TzContext;
708
-
709
- // Resolved request locale (BCP-47): X-Locale header → Accept-Language →
710
- // the app's boot-configured defaultLocale → "en" (dispatch-shared.ts).
711
- // Always a concrete tag, never optional — mirrors ctx.tz's Request →
712
- // Boot-Default fallback chain, so handlers never need to check for
713
- // absence before using it.
714
- readonly locale: string;
715
-
716
- // Resolve every registered r.authClaims() hook against `user` and return
717
- // the merged claim record (keys auto-prefixed with the feature name). Used
718
- // by login + switch-tenant write-handlers to populate SessionUser.claims
719
- // before the JWT is signed. Thin pass-through to dispatcher.resolveAuthClaims
720
- // so there's a single resolve impl — both entry-points can't drift.
721
- readonly resolveAuthClaims: (user: SessionUser) => Promise<Record<string, unknown>>;
722
-
723
- // Membership check for interactive sign-in paths (login, MFA completion,
724
- // tenant switch). Thin pass-through to dispatcher.resolveActiveMembership so there's a single resolve impl.
725
- readonly resolveActiveMembership: (
726
- userId: string,
727
- tenantId: TenantId,
728
- ) => Promise<ActiveMembershipResult>;
729
-
730
- // Read-only principal without `sid`; needs the same grant as a SYSTEM queryAs
731
- // (membership is resolved as SYSTEM), cached per handler invocation or job run.
732
- readonly queryAsMember: MemberReader;
733
- };
734
-
735
- // Job execution: db + registry + systemUser + logging guaranteed, plus a
736
- // write path into the same dispatcher pipeline write-handlers use.
737
- //
738
- // write/writeAs/queryAs throw until JobRunner.attachDispatcher() has run — a
739
- // call this early is a framework boot bug, not a job-author mistake.
740
- //
741
- // `write` runs as the job's own systemUser, tenant-scoped via the job's
742
- // _tenantId (falls back to SYSTEM_TENANT_ID for jobs with no tenant context,
743
- // e.g. plain cron jobs). A job that writes without fanning out per-tenant
744
- // (`_perTenant:` trigger) silently writes into the system tenant instead of
745
- // each tenant it may have meant to reach — no guard against this, only this
746
- // warning.
747
- //
748
- // `writeAs` is the explicit-identity counterpart, symmetric with `queryAs`:
749
- // `hasAccess` has no system bypass, so a write handler whose `access` lists
750
- // concrete roles is unreachable through `write` — the job's systemUser only
751
- // carries SYSTEM_ROLE and the dispatch comes back `access_denied`
752
- // (kumiko-framework#2585). The job has to assemble a full SessionUser for it:
753
- // `triggeredBy` alone is not enough, it carries only id + tenantId and no
754
- // roles, so the roles have to come from the job's own lookup.
755
- //
756
- // The passed identity also decides the target tenant — `writeAs` hands
757
- // `user.tenantId` straight to the dispatcher with nothing cross-checking it
758
- // against the job's own tenant, exactly as `queryAs` does. Building an
759
- // identity from job payload data is therefore a cross-tenant write path;
760
- // derive it from a trusted lookup instead.
761
- export type JobContext = SharedContextFields & {
762
- // Tenant-filtered to the job's own tenant. Cross-tenant raw access needs
763
- // `escapeHatch` on the job (`ctx.db.unsafeRaw(reason)`) or `r.systemScope()`
764
- // (`ctx.systemDb`).
765
- readonly db: TenantDb;
766
- readonly registry: Registry;
767
- readonly systemUser: SessionUser;
768
- readonly log: Logger;
769
- readonly triggeredBy: { readonly id: string; readonly tenantId: TenantId } | null;
770
- // 1-based run number; finalAttempt = no retry follows if this run throws. A sequential-lock re-enqueue restarts at 1.
771
- readonly attempt: number;
772
- readonly finalAttempt: boolean;
773
- // Only present for jobs whose owning feature declares r.systemScope().
774
- // assertTenantMatch()/acknowledgeCrossTenant() return a TenantDb; a raw,
775
- // tenant-unfiltered DbRunner instead comes from ctx.systemDb.unsafeRaw(reason)
776
- // — only safe for a helper that filters by tenantId itself (e.g. reindexEntity).
777
- readonly systemDb?: UncheckedSystemDb;
778
- readonly write: (qn: string, payload: unknown) => Promise<WriteResult>;
779
- readonly writeAs: (user: SessionUser, qn: string, payload: unknown) => Promise<WriteResult>;
780
- readonly queryAs: (user: SessionUser, qn: string, payload: unknown) => Promise<unknown>;
781
- // Tenant = the job's resolved tenant (may originate from payload.tenantId
782
- // for tenant-less triggers, see _tenantId below). Ungated, like queryAs.
783
- readonly queryAsMember: MemberReader;
784
- // Multi-trigger jobs (`on: [...]`) use this to tell which trigger fired —
785
- // undefined for cron/manual jobs. Mirrors AppContext.triggerName.
786
- readonly triggerName?: string;
787
- // The stored event that triggered this job run, when reached via the
788
- // job-trigger event consumer (async, at-least-once). Undefined for
789
- // cron/manual jobs and for the synchronous handler-QN dispatch path, which
790
- // has no stored event to hand over. Use triggerEvent.id (or a header) as
791
- // an idempotency key — at-least-once delivery can run the same trigger
792
- // more than once.
793
- readonly triggerEvent?: JobTriggerEvent;
794
- // Fallback tenant/user when systemUser doesn't carry it (tenant-less cron
795
- // jobs). Mirrors AppContext._tenantId/_userId — see the tenant-scoping
796
- // footgun above.
797
- readonly _tenantId?: TenantId;
798
- readonly _userId?: string | undefined;
799
- };
800
-
801
- // --- Handler Functions ---
802
-
803
- export type WriteHandlerFn<TPayload = unknown, TData = unknown> = (
804
- event: WriteEvent<TPayload>,
805
- context: HandlerContext,
806
- ) => Promise<WriteResult<TData>>;
807
-
808
- export type QueryHandlerFn<TPayload = unknown, TResult = unknown> = (
809
- query: QueryEvent<TPayload>,
810
- context: HandlerContext,
811
- ) => Promise<TResult>;
812
-
813
- // Dispatch (#1378) awaits this as an async-generator rather than a single
814
- // response — the chunk-by-chunk shape is load-bearing, not a Promise<TResult>.
815
- export type StreamHandlerFn<TPayload = unknown, TChunk = unknown> = (
816
- query: QueryEvent<TPayload>,
817
- context: HandlerContext,
818
- ) => AsyncGenerator<TChunk>;
819
-
820
- // --- Event Definitions ---
821
-
822
- /**
823
- * Compile-time mirror of `engine/qualified-name.ts:toKebab` for camelCase
824
- * → kebab-case. Drives the literal-type of `EventDef.name`, so that
825
- * `r.defineEvent("foo", schema)` inside `defineFeature("driverOrders")`
826
- * carries `name: "driver-orders:event:foo"` as a literal — strict-mode
827
- * for `ctx.appendEvent({ type: eventDef.name, ... })` lights up.
828
- *
829
- * Algorithm mirrors the runtime regex pipeline:
830
- * 1. `.` → `-` (dot acts as word-boundary)
831
- * 2. Insert `-` between `[A-Z]+` and `[A-Z][a-z]` (so `SSEFoo` →
832
- * `SSE-Foo`, splitting an uppercase run before a camel-hump)
833
- * 3. Insert `-` between `[a-z0-9]` and `[A-Z]` (camelCase boundary,
834
- * so `ticketAssigned` → `ticket-Assigned`)
835
- * 4. lowercase everything
836
- *
837
- * Implemented as a state machine with one-char lookahead. State:
838
- * - "start" — at start of string, or right after a dot-boundary
839
- * - "upper" — last emitted char came from an uppercase letter
840
- * - "post-letter" — last emitted char was a lowercase letter or digit
841
- *
842
- * Sync vs runtime is verified by `engine/__tests__/camel-to-kebab.test-d.ts`
843
- * — the type-tests cross-check identical inputs against `toKebab()`.
844
- */
845
- export type CamelToKebab<S extends string> = CamelToKebabImpl<S, "start", "">;
846
-
847
- type CamelToKebabImpl<
848
- S extends string,
849
- Prev extends "start" | "upper" | "post-letter",
850
- Acc extends string,
851
- > = S extends `${infer C}${infer Rest}`
852
- ? CharKind<C> extends "upper"
853
- ? Prev extends "start"
854
- ? CamelToKebabImpl<Rest, "upper", `${Acc}${Lowercase<C>}`>
855
- : Prev extends "post-letter"
856
- ? CamelToKebabImpl<Rest, "upper", `${Acc}-${Lowercase<C>}`>
857
- : // Prev = "upper" — peek next char to decide between
858
- // continuing-the-run and splitting-before-camel-hump.
859
- Rest extends `${infer Next}${string}`
860
- ? CharKind<Next> extends "lower"
861
- ? CamelToKebabImpl<Rest, "upper", `${Acc}-${Lowercase<C>}`>
862
- : CamelToKebabImpl<Rest, "upper", `${Acc}${Lowercase<C>}`>
863
- : `${Acc}${Lowercase<C>}`
864
- : CharKind<C> extends "lower"
865
- ? CamelToKebabImpl<Rest, "post-letter", `${Acc}${C}`>
866
- : // Non-letter: dots become word-boundaries (state resets to "start"
867
- // so the next uppercase letter doesn't pick up a redundant dash).
868
- // Other non-letters (digits etc.) act like lowercase for transitions.
869
- C extends "."
870
- ? CamelToKebabImpl<Rest, "start", `${Acc}-`>
871
- : CamelToKebabImpl<Rest, "post-letter", `${Acc}${C}`>
872
- : Acc;
873
-
874
- /**
875
- * Three-way classification used by `CamelToKebab`:
876
- * - "lower" — a lowercase letter (a-z and Unicode lowercase)
877
- * - "upper" — an uppercase letter (A-Z and Unicode uppercase)
878
- * - "non-letter" — digit, dot, dash, etc. (Lowercase==Uppercase for them)
879
- */
880
- type CharKind<C extends string> =
881
- C extends Lowercase<C> ? (C extends Uppercase<C> ? "non-letter" : "lower") : "upper";
882
-
883
- /**
884
- * Builds the qualified event-name from feature + inner-name in the same
885
- * shape the runtime emits via `qn(toKebab(feature), "event", toKebab(inner))`.
886
- */
887
- export type QualifiedEventName<
888
- TFeature extends string,
889
- TInner extends string,
890
- > = `${CamelToKebab<TFeature>}:event:${CamelToKebab<TInner>}`;
891
-
892
- // PII payload fields on a custom event (#799): `field` is encrypted under
893
- // the DEK the subject spec names (crypto-shredding).
894
- //
895
- // `{ personal: { of: "<ownerField>" } }` is the canonical user-subject form,
896
- // matching the entity-field `personal` vocabulary (see fields.ts).
897
- // `personal: "tenant"` and `personal: "self"` declare the event's tenant
898
- // (envelope.tenantId) or its own aggregate stream (envelope.aggregateType +
899
- // aggregateId) as the subject instead — resolved via `resolveEventSubject`
900
- // in `packages/framework/src/crypto/subject-resolver.ts` (fw#2801).
901
- //
902
- // `whenAbsent` decides what happens when the named owner field carries no
903
- // id (system- or cron-triggered writes): "tenant" re-targets the envelope
904
- // tenant key, "plaintext" is an explicit acknowledgement that the value
905
- // ships unencrypted and cannot be crypto-shredded. Undeclared is not a
906
- // third option — the append fails closed instead of leaking (fw#2776).
907
- export type EventPiiAbsentSubject = "plaintext" | "tenant";
908
-
909
- export type EventPiiSubject =
910
- | {
911
- readonly personal: {
912
- readonly of: string;
913
- readonly whenAbsent?: EventPiiAbsentSubject;
914
- };
915
- }
916
- | { readonly personal: "tenant" }
917
- | { readonly personal: "self" }
918
- | {
919
- /** @deprecated use `{ personal: { of: "<ownerField>" } }` instead. */
920
- readonly subjectField: string;
921
- };
922
-
923
- export type EventPiiFields = Readonly<Record<string, EventPiiSubject>>;
924
-
925
- export type NormalizedEventPiiSubject =
926
- | {
927
- readonly kind: "user";
928
- readonly ownerField: string;
929
- readonly whenAbsent: EventPiiAbsentSubject | undefined;
930
- }
931
- | { readonly kind: "tenant" }
932
- | { readonly kind: "self" };
933
-
934
- export function normalizeEventPiiSubject(spec: EventPiiSubject): NormalizedEventPiiSubject {
935
- if ("subjectField" in spec) {
936
- return { kind: "user", ownerField: spec.subjectField, whenAbsent: undefined };
937
- }
938
- if (spec.personal === "tenant") return { kind: "tenant" };
939
- if (spec.personal === "self") return { kind: "self" };
940
- return { kind: "user", ownerField: spec.personal.of, whenAbsent: spec.personal.whenAbsent };
941
- }
942
-
943
- // The full set of PII stances a defineEvent() call may declare. "none" is a
944
- // distinct value from `{}` — an empty object is indistinguishable from "the
945
- // author forgot to list fields", while "none" is an explicit assertion that
946
- // the payload carries no personal data.
947
- export type EventPiiStance = EventPiiFields | "none";
948
-
949
- export type EventDef<TPayload = unknown, TName extends string = string> = {
950
- readonly name: TName;
951
- readonly schema: ZodType<TPayload>;
952
- // Schema generation number. Starts at 1; bumped whenever a breaking change
953
- // to the payload shape lands together with a matching r.eventMigration that
954
- // upcasts older stored events. Reads consult this to decide if upcasters
955
- // need to run before the payload hits consumer code.
956
- readonly version: number;
957
- readonly piiFields?: EventPiiStance;
958
- };
959
-
960
- // Args for ctx.appendEvent — explicit aggregate target, Marten-style.
961
- // `type` must match a name returned by r.defineEvent in any registered
962
- // feature; payload is validated against that event's Zod schema before
963
- // being written to the events-table.
964
- //
965
- // `headers` lands in StoredEvent.metadata.headers — Marten-conform free
966
- // key/value space for app-specific metadata (A/B-bucket, geo-region,
967
- // client SDK version). Framework does not interpret values; keep them
968
- // JSON-primitive (string|number|boolean) for safe serialization.
969
- export type AppendEventArgs = {
970
- readonly aggregateId: string;
971
- readonly aggregateType: string;
972
- readonly type: string;
973
- readonly payload: unknown;
974
- readonly headers?: Readonly<Record<string, string | number | boolean>>;
975
- };
976
-
977
- // Typed-payload variant — used by the strict ctx.appendEvent. Keyed via
978
- // the discriminator type-arg so payload inference flows from `type`-literal
979
- // to the matching schema-payload.
980
- //
981
- // TMap is propagated as a generic parameter (not hard-coded to
982
- // KumikoEventTypeMap) so the constraint `K extends keyof TMap` resolves at
983
- // USE-site instead of definition-site. Cross-package augmentation only
984
- // becomes visible at use-site — the App's tsc compiles the augmentation
985
- // alongside its own code, so `keyof TMap` widens to include all augmented
986
- // event names. Hard-coding `keyof KumikoEventTypeMap` here would resolve
987
- // at definition-site (framework's compile) where the augmentation is
988
- // invisible → K = never, no strict-checking. The default = KumikoEventTypeMap
989
- // keeps existing call-sites zero-config.
990
- export type TypedAppendEventArgs<TMap extends object, K extends keyof TMap> = {
991
- readonly aggregateId: string;
992
- readonly aggregateType: string;
993
- readonly type: K;
994
- readonly payload: TMap[K];
995
- readonly headers?: Readonly<Record<string, string | number | boolean>>;
996
- };
997
-
998
- // Strict-only form. Single overload — `<K extends keyof TMap>` against the
999
- // app's pre-bound TMap. No fallback overload: apps that need runtime-pluggable
1000
- // events (where the type-string isn't known at compile-time) reach for
1001
- // `unsafeAppendEvent`.
1002
- //
1003
- // Why no fallback overload:
1004
- // A two-overload form (`(args: AppendEventArgs)` as the second sig)
1005
- // silently accepts any args via the loose overload as soon as TS can't
1006
- // prove the strict one matches. Cross-package, the strict overload's
1007
- // `K = keyof TMap` collapses to `never` when called WITHOUT a local
1008
- // wrapper (default-substitution is eager at definition-site → augmentation
1009
- // invisible). Either every caller binds TMap via wrapper → strict fires;
1010
- // or they don't, and the fallback would silently swallow every typo.
1011
- // We pick the first option and force the wrong path to fail visibly.
1012
- //
1013
- // How this is wired in practice:
1014
- // - Apps run `bun kumiko codegen`, which writes `.kumiko/define.ts`
1015
- // with locally-bound `defineWriteHandler<TName, TSchema, TData,
1016
- // KumikoEventTypeMap>(...)` wrappers. Handlers inside those wrappers
1017
- // get a strict ctx.appendEvent.
1018
- // - Cross-package callers (e.g. bundled-features's set.write.ts) that
1019
- // can't afford a local wrapper reach for `ctx.unsafeAppendEvent`
1020
- // instead — same runtime, looser type-surface.
1021
- export type AppendEventFn<TMap extends object = KumikoEventTypeMap> = <K extends keyof TMap>(
1022
- args: TypedAppendEventArgs<TMap, K>,
1023
- ) => Promise<void>;
1024
-
1025
- export type UnsafeAppendEventFn = (args: AppendEventArgs) => Promise<void>;
1026
-
1027
- // Savepoint-scoped append — returns a discriminated result instead of
1028
- // throwing on VersionConflictError, so a handler can react gracefully to
1029
- // losing a race against a concurrent writer on the same aggregate stream
1030
- // (e.g. two idempotent ingest calls for the same dedup key). The append
1031
- // runs inside a driver-native SAVEPOINT: a conflict rolls back only that
1032
- // nested scope, leaving the rest of the handler's transaction usable —
1033
- // unlike unsafeAppendEvent, whose thrown VersionConflictError poisons the
1034
- // entire enclosing transaction.
1035
- export type TryAppendEventResult =
1036
- | { readonly ok: true; readonly event: import("./event-store-types").StoredEvent }
1037
- | { readonly ok: false; readonly conflict: import("./event-store-types").VersionConflict };
1038
-
1039
- export type TryAppendEventFn = (args: AppendEventArgs) => Promise<TryAppendEventResult>;
1040
-
1041
- // Args for ctx.fetchForWriting — Marten FetchForWriting equivalent. Returns
1042
- // the current stream state + a handle that appends without re-specifying
1043
- // aggregateId/aggregateType. When expectedVersion is provided, the handle
1044
- // rejects the write immediately if the stream is ahead — optimistic
1045
- // concurrency enforced BEFORE any downstream work. Without expectedVersion,
1046
- // the handle trusts whatever version the stream currently has.
1047
- export type FetchForWritingArgs = {
1048
- readonly aggregateId: string;
1049
- readonly aggregateType: string;
1050
- readonly expectedVersion?: number;
1051
- };
1052
-
1053
- export type AggregateStreamHandle = {
1054
- // Snapshot at fetch time — upcasted via the registered upcaster chain,
1055
- // so payloads match the current schema regardless of when they landed.
1056
- readonly events: readonly import("./event-store-types").StoredEvent[];
1057
- readonly version: number;
1058
- // Append an event on this stream. Derives aggregateId/aggregateType/
1059
- // expectedVersion from the handle automatically. Multiple calls in a
1060
- // row bump the handle's internal version and the events-table in order.
1061
- readonly appendOne: (args: { readonly type: string; readonly payload: unknown }) => Promise<void>;
1062
- // Idempotency check against `events` (fetch-time snapshot only — an
1063
- // appendOne in the same handler isn't reflected). Guards a handler that
1064
- // may run twice against the same stream without risking a false
1065
- // "already succeeded" read that appendIfAbsent-style helpers would give
1066
- // on a genuine optimistic-concurrency conflict.
1067
- readonly hasEvent: (type: string) => boolean;
1068
- };
1069
-
1070
- // --- Event Upcasters (schema migration) ---
1071
- //
1072
- // Marten's Upcaster pattern adapted for TypeScript. An event's payload shape
1073
- // may evolve over releases; stored events stay immutable on disk. Features
1074
- // register step-wise transforms that upgrade v(N) payloads to v(N+1) at read
1075
- // time. The framework chains them automatically — a v1 event gets walked
1076
- // through every registered migration up to the current version before the
1077
- // payload reaches a projection apply() or ctx.appendEvent consumer.
1078
- //
1079
- // Sync transforms: just return the upgraded payload. Most schema-evolution
1080
- // (renames, additions, format-fixes) needs no IO and stays sync — fast on
1081
- // the hot path of projection-rebuild.
1082
- //
1083
- // Async transforms (Marten's "AsyncOnlyEventUpcaster"): when the upgrade
1084
- // needs DB enrichment (e.g. v1 stored only a customerId, v2 also needs the
1085
- // customer's segment which lives in a reference table), accept the optional
1086
- // ctx-arg, run the lookup via ctx.db, return a Promise. The framework
1087
- // awaits unconditionally — sync transforms return a plain value and pay
1088
- // only the await-microtask overhead. Pattern-match Marten:
1089
- // r.defineEvent("invoiceCreated", schema, {
1090
- // version: 2,
1091
- // migrations: [{ fromVersion: 1, toVersion: 2, transform: async (payload, ctx) => {
1092
- // const customer = await ctx.db.select().from(customersTable)...;
1093
- // return { ...payload, customerSegment: customer.segment };
1094
- // } }],
1095
- // });
1096
- export type EventUpcastCtx = {
1097
- readonly db: import("./db-connection").DbRunner;
1098
- readonly tenantId: import("./identifiers").TenantId;
1099
- };
1100
-
1101
- export type EventUpcastFn = (payload: unknown, ctx: EventUpcastCtx) => unknown | Promise<unknown>;
1102
-
1103
- // Declarative single-step migration — common payload transforms without an
1104
- // imperative function. Applied in fixed order: rename → default → map.
1105
- export type DeclarativeEventMigration = {
1106
- // old key → new key; a missing source key is a no-op
1107
- readonly rename?: Readonly<Record<string, string>>;
1108
- // set only when the key is absent — never overwrites an existing value
1109
- readonly default?: Readonly<Record<string, unknown>>;
1110
- // per-key value transform; skipped when the key is absent
1111
- readonly map?: Readonly<Record<string, (value: unknown) => unknown>>;
1112
- };
1113
-
1114
- export type EventMigrationDef = {
1115
- // Qualified event name, matching r.defineEvent(...).name.
1116
- readonly eventName: string;
1117
- readonly fromVersion: number;
1118
- readonly toVersion: number; // must be fromVersion + 1
1119
- readonly transform: EventUpcastFn;
1120
- };
1121
-
1122
- // --- References ---
1123
-
1124
- // Anything that carries a name — accepted by hooks, relations, jobs, etc.
1125
- export type NameOrRef = string | { readonly name: string };
1126
-
1127
- export type EntityRef = {
1128
- readonly name: string;
1129
- readonly table: string;
1130
- };
1131
-
1132
- export type HandlerRef = {
1133
- readonly name: string;
1134
- };
1135
-
1136
- // --- Handler Definitions (stored in feature/registry) ---
1137
-
1138
- // Per-handler rate limit. Bucket key derived from `per`:
1139
- // "user" → userId
1140
- // "tenant" → tenantId
1141
- // "ip" → request IP
1142
- // "user+handler" → userId + handlerName
1143
- // "tenant+handler" → tenantId + handlerName
1144
- // "ip+handler" → IP + handlerName (anonymous endpoints)
1145
- // `cost` is the tokens this handler-call deducts. Default 1 — bump for
1146
- // expensive operations (bulk export, bulk import).
1147
- export type RateLimitPer =
1148
- | "user"
1149
- | "tenant"
1150
- | "ip"
1151
- | "user+handler"
1152
- | "tenant+handler"
1153
- | "ip+handler";
1154
-
1155
- export type RateLimitOption = {
1156
- readonly per: RateLimitPer;
1157
- readonly limit: number;
1158
- readonly windowSeconds: number;
1159
- readonly cost?: number;
1160
- };
1161
-
1162
- export type RateLimitDisabled = { readonly disabled: true; readonly reason: string };
1163
- export type RateLimitDeclaration = RateLimitOption | RateLimitDisabled;
1164
-
1165
- export function isRateLimitDisabled(v: RateLimitDeclaration | undefined): v is RateLimitDisabled {
1166
- return v !== undefined && "disabled" in v && v.disabled === true;
1167
- }
1168
-
1169
- export type AgentRisk = "low" | "mid" | "high";
1170
-
1171
- /** Per-handler hints for the AI-agent manifest. `expose` overrides the
1172
- * default derived from `description`; `risk` overrides the per-kind default. */
1173
- export type AgentHandlerHints = {
1174
- readonly expose?: boolean;
1175
- readonly risk?: AgentRisk;
1176
- };
1177
-
1178
- export type AgentExposure = {
1179
- readonly expose: boolean;
1180
- readonly risk: AgentRisk;
1181
- };
1182
-
1183
- export type WriteHandlerDef = {
1184
- readonly name: string;
1185
- readonly schema: ZodType;
1186
- readonly handler: WriteHandlerFn;
1187
- readonly access: AccessRule;
1188
- readonly description?: string;
1189
- readonly agent?: AgentHandlerHints;
1190
- readonly unsafeSkipTransitionGuard?: boolean;
1191
- readonly rateLimit?: RateLimitDeclaration;
1192
- readonly escapeHatch?: EscapeHatchDeclaration;
1193
- // Set when the author wrote a `perform: stepsPipeline(...)` block. Boot-
1194
- // validators (projection-allowlist) and Designer/AI tooling read this
1195
- // to inspect the step list. Absent on free-form handlers.
1196
- // Inline-import is intentional: step.ts imports HandlerContext from
1197
- // this file, a top-level `import type { PipelineDef } from "./step"`
1198
- // would form a type-only circular import that TS resolves but tooling
1199
- // (incremental compile, IDEs) sometimes mis-handles.
1200
- readonly perform?: import("./step").PipelineDef;
1201
- };
1202
-
1203
- // Marker set by defineEntityQueryHandler on both of its verbs. A string, not
1204
- // a Symbol(), for the same reason as PAGED_QUERY_HANDLER_BRAND: a symlinked
1205
- // workspace can evaluate a module twice, and two Symbol() calls would not
1206
- // compare equal across those instances.
1207
- export const ENTITY_CONVENTION_QUERY_BRAND = "__kumikoEntityConventionQuery";
1208
-
1209
- export type QueryHandlerDef = {
1210
- readonly name: string;
1211
- readonly schema: ZodType;
1212
- readonly handler: QueryHandlerFn;
1213
- /** Set only by defineEntityQueryHandler, which wires the parent-ref
1214
- * read-gate. The boot-validator requires it on a `parentRef` entity's
1215
- * list/detail handler — see boot-validator/parent-ref.ts. */
1216
- readonly [ENTITY_CONVENTION_QUERY_BRAND]?: true;
1217
- readonly access: AccessRule;
1218
- readonly description?: string;
1219
- readonly agent?: AgentHandlerHints;
1220
- readonly rateLimit?: RateLimitDeclaration;
1221
- /** Zod schema of the handler's actual return value — the paged envelope
1222
- * `{ rows, nextCursor, total? }` for a `definePagedQueryHandler`, or the
1223
- * flat record (optionally `.nullable()`) for a plain query handler.
1224
- * Optional and purely additive: absent on every existing handler, and
1225
- * the boot-validator skips any column-reference check that would need
1226
- * it (screens.ts projectionList/relatedList `columns`, projectionDetail
1227
- * `header`/`metrics`, dashboard `valueField`/`subField`/etc.) rather
1228
- * than requiring it retroactively. See fw#2493. */
1229
- readonly outputSchema?: ZodType;
1230
- // Query handlers can't reach db.global() (that gate is write-only), but
1231
- // they can still switch identity to SYSTEM via ctx.queryAs — this opts
1232
- // in, same contract as WriteHandlerDef.escapeHatch.
1233
- readonly escapeHatch?: EscapeHatchDeclaration;
1234
- };
1235
-
1236
- export type StreamHandlerDef = {
1237
- readonly name: string;
1238
- readonly schema: ZodType;
1239
- readonly handler: StreamHandlerFn;
1240
- readonly access: AccessRule;
1241
- readonly rateLimit?: RateLimitDeclaration;
1242
- // Stream handlers can't reach db.global() (that gate is write-only), but
1243
- // they can still switch identity to SYSTEM via ctx.queryAs — this opts
1244
- // in, same contract as WriteHandlerDef.escapeHatch.
1245
- readonly escapeHatch?: EscapeHatchDeclaration;
1246
- };