@cosmicdrift/kumiko-types 0.326.1 → 0.328.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/action-icon.d.ts +3 -0
- package/dist/action-icon.js +51 -0
- package/{src/agent-exposure.ts → dist/agent-exposure.d.ts} +8 -15
- package/dist/agent-exposure.js +16 -0
- package/dist/concurrency-mode.d.ts +1 -0
- package/dist/config-scope.d.ts +1 -0
- package/dist/config-scope.js +1 -0
- package/dist/config.d.ts +234 -0
- package/dist/config.js +1 -0
- package/dist/cursor-types.d.ts +16 -0
- package/dist/cursor-types.js +1 -0
- package/dist/db-connection.d.ts +14 -0
- package/dist/db-connection.js +1 -0
- package/dist/define-handler.d.ts +58 -0
- package/dist/define-handler.js +1 -0
- package/dist/derivatives-types.d.ts +53 -0
- package/dist/derivatives-types.js +3 -0
- package/dist/entity-cache.d.ts +16 -0
- package/dist/entity-cache.js +1 -0
- package/dist/entity-handlers.d.ts +54 -0
- package/dist/entity-handlers.js +1 -0
- package/dist/entity-table-meta-types.d.ts +44 -0
- package/dist/entity-table-meta-types.js +6 -0
- package/dist/envelope-cipher-types.d.ts +5 -0
- package/dist/envelope-cipher-types.js +1 -0
- package/dist/event-store-executor-types.d.ts +86 -0
- package/dist/event-store-executor-types.js +1 -0
- package/dist/event-store-types.d.ts +37 -0
- package/dist/event-store-types.js +7 -0
- package/dist/event-type-map.d.ts +8 -0
- package/{src/event-type-map.ts → dist/event-type-map.js} +1 -16
- package/dist/executor-brand.d.ts +8 -0
- package/dist/executor-brand.js +1 -0
- package/dist/extension-options-map.d.ts +9 -0
- package/dist/extension-options-map.js +4 -0
- package/dist/feature.d.ts +518 -0
- package/dist/feature.js +1 -0
- package/dist/field-icon.d.ts +1 -0
- package/dist/field-icon.js +1 -0
- package/dist/fields.d.ts +729 -0
- package/dist/fields.js +50 -0
- package/dist/file-handle-types.d.ts +12 -0
- package/dist/file-handle-types.js +1 -0
- package/dist/file-provider-resolver-types.d.ts +3 -0
- package/dist/file-provider-resolver-types.js +1 -0
- package/dist/file-storage-provider-types.d.ts +17 -0
- package/dist/file-storage-provider-types.js +1 -0
- package/dist/geo-tz.d.ts +17 -0
- package/{src/geo-tz.ts → dist/geo-tz.js} +1 -20
- package/dist/handlers.d.ts +493 -0
- package/dist/handlers.js +49 -0
- package/dist/hook-phase.d.ts +1 -0
- package/dist/hook-phase.js +1 -0
- package/dist/hooks.d.ts +75 -0
- package/dist/hooks.js +1 -0
- package/dist/http-route.d.ts +52 -0
- package/dist/http-route.js +13 -0
- package/dist/identifiers.d.ts +8 -0
- package/{src/identifiers.ts → dist/identifiers.js} +7 -24
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/kms-adapter-types.d.ts +68 -0
- package/dist/kms-adapter-types.js +49 -0
- package/dist/logger.d.ts +7 -0
- package/dist/logger.js +1 -0
- package/dist/multi-stream-apply-context-types.d.ts +18 -0
- package/dist/multi-stream-apply-context-types.js +1 -0
- package/dist/nav-icon.d.ts +3 -0
- package/dist/nav-icon.js +100 -0
- package/dist/nav.d.ts +39 -0
- package/dist/nav.js +1 -0
- package/dist/observability/index.d.ts +3 -0
- package/dist/observability/index.js +2 -0
- package/dist/observability/metric.d.ts +34 -0
- package/dist/observability/metric.js +3 -0
- package/dist/observability/provider.d.ts +24 -0
- package/dist/observability/provider.js +4 -0
- package/dist/observability/span.d.ts +32 -0
- package/dist/observability/span.js +4 -0
- package/dist/ownership.d.ts +33 -0
- package/dist/ownership.js +5 -0
- package/dist/projection.d.ts +38 -0
- package/dist/projection.js +1 -0
- package/dist/rate-limit-types.d.ts +19 -0
- package/dist/rate-limit-types.js +1 -0
- package/dist/relations.d.ts +25 -0
- package/dist/relations.js +1 -0
- package/dist/schema-table-types.d.ts +15 -0
- package/{src/schema-table-types.ts → dist/schema-table-types.js} +0 -20
- package/dist/screen.d.ts +1265 -0
- package/dist/screen.js +3 -0
- package/dist/search-adapter.d.ts +32 -0
- package/dist/search-adapter.js +1 -0
- package/dist/secrets-types.d.ts +52 -0
- package/dist/secrets-types.js +30 -0
- package/dist/snapshot-types.d.ts +13 -0
- package/dist/snapshot-types.js +1 -0
- package/dist/step.d.ts +304 -0
- package/dist/step.js +11 -0
- package/dist/target-ref.d.ts +5 -0
- package/{src/target-ref.ts → dist/target-ref.js} +1 -6
- package/dist/tenancy-brand.d.ts +6 -0
- package/dist/tenancy-brand.js +1 -0
- package/dist/tenant-db-types.d.ts +67 -0
- package/dist/tenant-db-types.js +5 -0
- package/dist/tree-node.d.ts +26 -0
- package/dist/tree-node.js +22 -0
- package/dist/tz-context.d.ts +48 -0
- package/dist/tz-context.js +6 -0
- package/dist/websocket-route.d.ts +46 -0
- package/dist/websocket-route.js +10 -0
- package/dist/where-clause-types.d.ts +20 -0
- package/dist/where-clause-types.js +1 -0
- package/dist/workspace.d.ts +10 -0
- package/dist/workspace.js +1 -0
- package/dist/write-error-info-types.d.ts +12 -0
- package/dist/write-error-info-types.js +1 -0
- package/package.json +362 -119
- package/src/changes.json +12 -0
- package/src/__tests__/handlers.test.ts +0 -40
- package/src/__tests__/plural-forms.test.ts +0 -18
- package/src/action-icon.ts +0 -57
- package/src/concurrency-mode.ts +0 -5
- package/src/config-scope.ts +0 -5
- package/src/config.ts +0 -563
- package/src/cursor-types.ts +0 -18
- package/src/db-connection.ts +0 -25
- package/src/define-handler.ts +0 -138
- package/src/derivatives-types.ts +0 -106
- package/src/entity-cache.ts +0 -35
- package/src/entity-handlers.ts +0 -68
- package/src/entity-table-meta-types.ts +0 -101
- package/src/envelope-cipher-types.ts +0 -6
- package/src/event-store-executor-types.ts +0 -163
- package/src/event-store-types.ts +0 -85
- package/src/executor-brand.ts +0 -19
- package/src/extension-options-map.ts +0 -21
- package/src/feature.ts +0 -1132
- package/src/field-icon.ts +0 -23
- package/src/fields.ts +0 -1114
- package/src/file-handle-types.ts +0 -20
- package/src/file-provider-resolver-types.ts +0 -7
- package/src/file-storage-provider-types.ts +0 -54
- package/src/handlers.ts +0 -1246
- package/src/hook-phase.ts +0 -2
- package/src/hooks.ts +0 -170
- package/src/http-route.ts +0 -78
- package/src/kms-adapter-types.ts +0 -128
- package/src/logger.ts +0 -7
- package/src/multi-stream-apply-context-types.ts +0 -52
- package/src/nav-icon.ts +0 -105
- package/src/nav.ts +0 -139
- package/src/observability/index.ts +0 -28
- package/src/observability/metric.ts +0 -56
- package/src/observability/provider.ts +0 -32
- package/src/observability/span.ts +0 -58
- package/src/ownership.ts +0 -83
- package/src/projection.ts +0 -170
- package/src/rate-limit-types.ts +0 -32
- package/src/relations.ts +0 -52
- package/src/screen.ts +0 -1593
- package/src/search-adapter.ts +0 -45
- package/src/secrets-types.ts +0 -182
- package/src/snapshot-types.ts +0 -36
- package/src/step.ts +0 -390
- package/src/tenancy-brand.ts +0 -9
- package/src/tenant-db-types.ts +0 -116
- package/src/tree-node.ts +0 -134
- package/src/tz-context.ts +0 -63
- package/src/where-clause-types.ts +0 -31
- package/src/workspace.ts +0 -42
- package/src/write-error-info-types.ts +0 -20
- /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
|
-
};
|