@cosmicdrift/kumiko-framework 0.146.2 → 0.146.3
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/package.json +2 -2
- package/src/pipeline/dispatch-batch.ts +187 -0
- package/src/pipeline/dispatch-query.ts +165 -0
- package/src/pipeline/dispatch-shared.ts +685 -0
- package/src/pipeline/dispatch-write.ts +451 -0
- package/src/pipeline/dispatcher.ts +30 -1374
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
import { requestContext } from "../api/request-context";
|
|
2
|
+
import type { DbConnection, DbTx } from "../db/connection";
|
|
3
|
+
import { selectMany } from "../db/query";
|
|
4
|
+
import type { buildEntityTable } from "../db/table-builder";
|
|
5
|
+
import { createTenantDb } from "../db/tenant-db";
|
|
6
|
+
import type { defineTransitions } from "../engine/state-machine";
|
|
7
|
+
import type { EffectiveFeaturesResolver } from "../engine/tier-resolver-extension";
|
|
8
|
+
import type {
|
|
9
|
+
AggregateStreamHandle,
|
|
10
|
+
AppContext,
|
|
11
|
+
AppendEventArgs,
|
|
12
|
+
AppendEventFn,
|
|
13
|
+
AuthClaimsContext,
|
|
14
|
+
FetchForWritingArgs,
|
|
15
|
+
HandlerContext,
|
|
16
|
+
JobRunnerRef,
|
|
17
|
+
Registry,
|
|
18
|
+
SessionUser,
|
|
19
|
+
WriteResult,
|
|
20
|
+
} from "../engine/types";
|
|
21
|
+
import type { TenantId } from "../engine/types/identifiers";
|
|
22
|
+
import {
|
|
23
|
+
FeatureDisabledError,
|
|
24
|
+
InternalError,
|
|
25
|
+
VersionConflictError,
|
|
26
|
+
type WriteErrorInfo,
|
|
27
|
+
} from "../errors";
|
|
28
|
+
import {
|
|
29
|
+
archiveStream as archiveStreamHelper,
|
|
30
|
+
isStreamArchived,
|
|
31
|
+
restoreStream as restoreStreamHelper,
|
|
32
|
+
} from "../event-store/archive";
|
|
33
|
+
import {
|
|
34
|
+
getStreamVersion,
|
|
35
|
+
loadAggregate,
|
|
36
|
+
loadAggregateAsOf,
|
|
37
|
+
type StoredEvent,
|
|
38
|
+
} from "../event-store/event-store";
|
|
39
|
+
import {
|
|
40
|
+
type LoadAggregateWithSnapshotOptions,
|
|
41
|
+
type LoadAggregateWithSnapshotResult,
|
|
42
|
+
loadAggregateWithSnapshot,
|
|
43
|
+
type SnapshotReducer,
|
|
44
|
+
saveSnapshot,
|
|
45
|
+
} from "../event-store/snapshot";
|
|
46
|
+
import { upcastStoredEvent, upcastStoredEvents } from "../event-store/upcaster";
|
|
47
|
+
import { createFileContext } from "../files/file-handle";
|
|
48
|
+
import {
|
|
49
|
+
createMetricsHandle,
|
|
50
|
+
createNoopMetricsHandle,
|
|
51
|
+
emitDispatcherError,
|
|
52
|
+
emitDispatcherHandler,
|
|
53
|
+
type getFallbackMeter,
|
|
54
|
+
getFallbackTracer,
|
|
55
|
+
} from "../observability";
|
|
56
|
+
import { buildBucketKey } from "../rate-limit";
|
|
57
|
+
import { createTzContext } from "../time";
|
|
58
|
+
import { appendDomainEventCore } from "./append-event-core";
|
|
59
|
+
import { resolveAuthClaims as runAuthClaimsResolver } from "./auth-claims-resolver";
|
|
60
|
+
import { executeQuery } from "./dispatch-query";
|
|
61
|
+
import { executeWrite } from "./dispatch-write";
|
|
62
|
+
import {
|
|
63
|
+
type AfterCommitHook,
|
|
64
|
+
dispatcherSpanAttributes,
|
|
65
|
+
isFailedWriteResult,
|
|
66
|
+
} from "./dispatcher-utils";
|
|
67
|
+
import type { IdempotencyGuard } from "./idempotency";
|
|
68
|
+
import type { LifecycleHooks } from "./lifecycle-pipeline";
|
|
69
|
+
|
|
70
|
+
export type BatchCommand = {
|
|
71
|
+
readonly type: string;
|
|
72
|
+
readonly payload: unknown;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export type BatchResult =
|
|
76
|
+
| { readonly isSuccess: true; readonly results: readonly WriteResult[] }
|
|
77
|
+
| {
|
|
78
|
+
readonly isSuccess: false;
|
|
79
|
+
readonly error: WriteErrorInfo;
|
|
80
|
+
readonly failedIndex: number;
|
|
81
|
+
readonly results: readonly WriteResult[];
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
// Bundles everything the dispatch-phase functions (query/write/batch) need —
|
|
85
|
+
// hoisted to module scope out of createDispatcher's former closure, so those
|
|
86
|
+
// captures travel explicitly instead of implicitly.
|
|
87
|
+
export type DispatchContext = {
|
|
88
|
+
registry: Registry;
|
|
89
|
+
appContext: AppContext;
|
|
90
|
+
idempotency: IdempotencyGuard | undefined;
|
|
91
|
+
lifecycle: LifecycleHooks | undefined;
|
|
92
|
+
jobRunner: JobRunnerRef | undefined;
|
|
93
|
+
effectiveFeatures: EffectiveFeaturesResolver | undefined;
|
|
94
|
+
tableCache: Map<string, ReturnType<typeof buildEntityTable>>;
|
|
95
|
+
transitionCache: Map<string, ReturnType<typeof defineTransitions>>;
|
|
96
|
+
tracer: ReturnType<typeof getFallbackTracer>;
|
|
97
|
+
meter: ReturnType<typeof getFallbackMeter>;
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// Narrowing-helper: AppContext.db ist DbConnection|TenantDb|undefined. Die
|
|
101
|
+
// dispatch-Pfade brauchen DbConnection (oder DbTx aus Caller-Scope) für
|
|
102
|
+
// appendEvent/projection-writes; TenantDb-Branch wird hier ausgeschlossen.
|
|
103
|
+
export function resolveDbSource(
|
|
104
|
+
ctx: DispatchContext,
|
|
105
|
+
tx: DbTx | undefined,
|
|
106
|
+
): DbConnection | DbTx | undefined {
|
|
107
|
+
const { appContext: context } = ctx;
|
|
108
|
+
return tx ?? (context.db as DbConnection | undefined); // @cast-boundary db-operator
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ctx.appendEvent — append a domain event onto a specific aggregate stream
|
|
112
|
+
// in the current tx, then fire matching inline projections. Core logic
|
|
113
|
+
// lives in appendDomainEventCore; this wrapper just locates dbSource +
|
|
114
|
+
// stringifies the SessionUser id for the shared helper.
|
|
115
|
+
async function appendDomainEvent(
|
|
116
|
+
ctx: DispatchContext,
|
|
117
|
+
args: AppendEventArgs,
|
|
118
|
+
user: SessionUser,
|
|
119
|
+
tx: DbTx | undefined,
|
|
120
|
+
callerFeature: string | undefined,
|
|
121
|
+
): Promise<void> {
|
|
122
|
+
const { registry } = ctx;
|
|
123
|
+
const dbSource = resolveDbSource(ctx, tx);
|
|
124
|
+
if (!dbSource) {
|
|
125
|
+
throw new InternalError({
|
|
126
|
+
message: `ctx.appendEvent("${args.type}") requires a database connection — none is configured.`,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
await appendDomainEventCore(
|
|
130
|
+
{
|
|
131
|
+
registry,
|
|
132
|
+
db: dbSource,
|
|
133
|
+
tenantId: user.tenantId,
|
|
134
|
+
userId: String(user.id),
|
|
135
|
+
callSiteLabel: "ctx.appendEvent",
|
|
136
|
+
callerFeature,
|
|
137
|
+
},
|
|
138
|
+
args,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function buildHandlerContext(
|
|
143
|
+
ctx: DispatchContext,
|
|
144
|
+
type: string,
|
|
145
|
+
user: SessionUser,
|
|
146
|
+
tx?: DbTx,
|
|
147
|
+
afterCommitHooks?: AfterCommitHook[],
|
|
148
|
+
includeDeleted?: boolean,
|
|
149
|
+
): HandlerContext {
|
|
150
|
+
const { registry, appContext: context, effectiveFeatures } = ctx;
|
|
151
|
+
const isSystem = registry.isHandlerSystemScoped(type);
|
|
152
|
+
// The outer dispatcher receives a DbConnection from the server/stack;
|
|
153
|
+
// AppContext's `db` union also allows TenantDb (for downstream hook calls),
|
|
154
|
+
// but at this point we're the root of the pipeline — cast is safe.
|
|
155
|
+
const dbSource = resolveDbSource(ctx, tx);
|
|
156
|
+
const reqCtx = requestContext.get();
|
|
157
|
+
const db = dbSource
|
|
158
|
+
? createTenantDb(
|
|
159
|
+
dbSource,
|
|
160
|
+
user.tenantId,
|
|
161
|
+
isSystem ? "system" : "tenant",
|
|
162
|
+
context.tracer,
|
|
163
|
+
context.meter,
|
|
164
|
+
// Propagate the request's AbortSignal so every TenantDb query
|
|
165
|
+
// throws when the client has disconnected — handlers with many
|
|
166
|
+
// sequential queries skip the rest of the chain instead of
|
|
167
|
+
// burning DB-CPU for results no one reads.
|
|
168
|
+
reqCtx?.signal,
|
|
169
|
+
)
|
|
170
|
+
: undefined;
|
|
171
|
+
const log = context.log?.child({
|
|
172
|
+
handler: type,
|
|
173
|
+
tenantId: user.tenantId,
|
|
174
|
+
userId: user.id,
|
|
175
|
+
...(reqCtx && { requestId: reqCtx.requestId }),
|
|
176
|
+
});
|
|
177
|
+
const notify = context._notifyFactory ? context._notifyFactory(user, user.tenantId) : undefined;
|
|
178
|
+
// Mirror notify: only built when the config feature wired its factory.
|
|
179
|
+
const config =
|
|
180
|
+
context._configAccessorFactory && db
|
|
181
|
+
? context._configAccessorFactory({
|
|
182
|
+
user: { id: user.id, tenantId: user.tenantId },
|
|
183
|
+
db,
|
|
184
|
+
secrets: context.secrets,
|
|
185
|
+
})
|
|
186
|
+
: undefined;
|
|
187
|
+
// ctx.files resolved per-tenant through file-foundation (lazy — the
|
|
188
|
+
// provider is only resolved when a handle actually does I/O). Boot wires
|
|
189
|
+
// _fileProviderResolver when a file-provider plugin is mounted; falls back
|
|
190
|
+
// to a statically-injected context.files (tests).
|
|
191
|
+
const fileResolver = context._fileProviderResolver;
|
|
192
|
+
const files = fileResolver ? createFileContext(() => fileResolver(user.tenantId)) : context.files;
|
|
193
|
+
|
|
194
|
+
// Observability — feature-bound metrics handle, so ctx.metrics.inc("foo")
|
|
195
|
+
// resolves to kumiko_<feature>_foo. Unknown feature falls back to noop
|
|
196
|
+
// so legacy internal handlers don't crash.
|
|
197
|
+
const tracer = context.tracer ?? getFallbackTracer();
|
|
198
|
+
const meter = context.meter;
|
|
199
|
+
const featureName = registry.getHandlerFeature(type);
|
|
200
|
+
const metrics =
|
|
201
|
+
meter && featureName ? createMetricsHandle(meter, featureName) : createNoopMetricsHandle();
|
|
202
|
+
|
|
203
|
+
// Cross-feature bridge. Queries and writes invoked through ctx.* share:
|
|
204
|
+
// - the current transaction (tx) — nested writes roll back with the parent
|
|
205
|
+
// - the current afterCommitHooks sink — deferred side-effects fire once
|
|
206
|
+
// when the outermost transaction commits
|
|
207
|
+
// `queryAs` / `writeAs` let a handler explicitly switch identity
|
|
208
|
+
// (e.g. system-privileged lookups that bypass field-access read filters).
|
|
209
|
+
const bridgeSink = afterCommitHooks ?? [];
|
|
210
|
+
const bridge = {
|
|
211
|
+
query: (targetType: string, payload: unknown) =>
|
|
212
|
+
executeQuery(ctx, targetType, payload, user, tx), // @wrapper-known semantic-alias
|
|
213
|
+
queryAs: (asUser: SessionUser, targetType: string, payload: unknown) =>
|
|
214
|
+
executeQuery(ctx, targetType, payload, asUser, tx), // @wrapper-known semantic-alias
|
|
215
|
+
write: async (targetType: string, payload: unknown) => {
|
|
216
|
+
const res = await executeWrite(ctx, targetType, payload, user, tx, bridgeSink);
|
|
217
|
+
return res;
|
|
218
|
+
},
|
|
219
|
+
writeAs: async (asUser: SessionUser, targetType: string, payload: unknown) => {
|
|
220
|
+
const res = await executeWrite(ctx, targetType, payload, asUser, tx, bridgeSink);
|
|
221
|
+
return res;
|
|
222
|
+
},
|
|
223
|
+
// Strict + unsafe share the same runtime — only the type-surface
|
|
224
|
+
// differs. The strict signature is what's exposed to typed callers;
|
|
225
|
+
// unsafe is the explicit escape-hatch for runtime-pluggable events.
|
|
226
|
+
appendEvent: (async (args: AppendEventArgs) => {
|
|
227
|
+
await appendDomainEvent(ctx, args, user, tx, registry.getHandlerFeature(type));
|
|
228
|
+
}) as AppendEventFn, // @cast-boundary engine-bridge
|
|
229
|
+
unsafeAppendEvent: async (args: AppendEventArgs) => {
|
|
230
|
+
await appendDomainEvent(ctx, args, user, tx, registry.getHandlerFeature(type));
|
|
231
|
+
},
|
|
232
|
+
fetchForWriting: async (args: FetchForWritingArgs): Promise<AggregateStreamHandle> => {
|
|
233
|
+
const dbSource = resolveDbSource(ctx, tx);
|
|
234
|
+
if (!dbSource) {
|
|
235
|
+
throw new InternalError({
|
|
236
|
+
message: `ctx.fetchForWriting("${args.aggregateId}") requires a database connection — none is configured.`,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
// Stream-version authoritative (same policy as CRUD executor + Block 0).
|
|
240
|
+
// A single SELECT MAX(version) is cheaper than loading the full stream
|
|
241
|
+
// when the caller just wants to append — but most callers also want
|
|
242
|
+
// the events (business-rule checks), so fetch both in parallel.
|
|
243
|
+
const [storedEvents, fetchedVersion] = await Promise.all([
|
|
244
|
+
loadAggregate(dbSource, args.aggregateId, user.tenantId),
|
|
245
|
+
getStreamVersion(dbSource, args.aggregateId, user.tenantId),
|
|
246
|
+
]);
|
|
247
|
+
const events = await upcastStoredEvents(storedEvents, registry.getEventUpcasters(), {
|
|
248
|
+
db: dbSource,
|
|
249
|
+
tenantId: user.tenantId,
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
// Optimistic concurrency: if the caller knows the version they
|
|
253
|
+
// worked against (e.g. from a prior read-model row) and the stream
|
|
254
|
+
// has moved on, fail fast before any downstream work.
|
|
255
|
+
if (args.expectedVersion !== undefined && args.expectedVersion !== fetchedVersion) {
|
|
256
|
+
throw new VersionConflictError({
|
|
257
|
+
entityId: args.aggregateId,
|
|
258
|
+
expectedVersion: args.expectedVersion,
|
|
259
|
+
currentVersion: fetchedVersion,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Handle's internal version bumps on every appendOne so multiple
|
|
264
|
+
// appends in a row stay in order without re-reading the DB.
|
|
265
|
+
let handleVersion = fetchedVersion;
|
|
266
|
+
const appendOne = async (appendArgs: {
|
|
267
|
+
readonly type: string;
|
|
268
|
+
readonly payload: unknown;
|
|
269
|
+
}): Promise<void> => {
|
|
270
|
+
await appendDomainEvent(
|
|
271
|
+
ctx,
|
|
272
|
+
{
|
|
273
|
+
aggregateId: args.aggregateId,
|
|
274
|
+
aggregateType: args.aggregateType,
|
|
275
|
+
type: appendArgs.type,
|
|
276
|
+
payload: appendArgs.payload,
|
|
277
|
+
},
|
|
278
|
+
user,
|
|
279
|
+
tx,
|
|
280
|
+
registry.getHandlerFeature(type),
|
|
281
|
+
);
|
|
282
|
+
handleVersion += 1;
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
events,
|
|
287
|
+
get version() {
|
|
288
|
+
return handleVersion;
|
|
289
|
+
},
|
|
290
|
+
appendOne,
|
|
291
|
+
};
|
|
292
|
+
},
|
|
293
|
+
loadAggregate: async (
|
|
294
|
+
aggregateId: string,
|
|
295
|
+
loadOptions?: { readonly asOf?: Temporal.Instant },
|
|
296
|
+
): Promise<readonly StoredEvent[]> => {
|
|
297
|
+
const dbSource = resolveDbSource(ctx, tx);
|
|
298
|
+
if (!dbSource) {
|
|
299
|
+
throw new InternalError({
|
|
300
|
+
message: `ctx.loadAggregate("${aggregateId}") requires a database connection — none is configured.`,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
const events = loadOptions?.asOf
|
|
304
|
+
? await loadAggregateAsOf(dbSource, aggregateId, user.tenantId, loadOptions.asOf)
|
|
305
|
+
: await loadAggregate(dbSource, aggregateId, user.tenantId);
|
|
306
|
+
return upcastStoredEvents(events, registry.getEventUpcasters(), {
|
|
307
|
+
db: dbSource,
|
|
308
|
+
tenantId: user.tenantId,
|
|
309
|
+
});
|
|
310
|
+
},
|
|
311
|
+
archiveStream: async (
|
|
312
|
+
aggregateId: string,
|
|
313
|
+
archiveArgs: { readonly aggregateType: string; readonly reason?: string },
|
|
314
|
+
): Promise<void> => {
|
|
315
|
+
const dbSource = resolveDbSource(ctx, tx);
|
|
316
|
+
if (!dbSource) {
|
|
317
|
+
throw new InternalError({
|
|
318
|
+
message: `ctx.archiveStream("${aggregateId}") requires a database connection — none is configured.`,
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
await archiveStreamHelper(dbSource, {
|
|
322
|
+
tenantId: user.tenantId,
|
|
323
|
+
aggregateId,
|
|
324
|
+
aggregateType: archiveArgs.aggregateType,
|
|
325
|
+
archivedBy: user.id,
|
|
326
|
+
reason: archiveArgs.reason,
|
|
327
|
+
});
|
|
328
|
+
},
|
|
329
|
+
restoreStream: async (aggregateId: string): Promise<void> => {
|
|
330
|
+
const dbSource = resolveDbSource(ctx, tx);
|
|
331
|
+
if (!dbSource) {
|
|
332
|
+
throw new InternalError({
|
|
333
|
+
message: `ctx.restoreStream("${aggregateId}") requires a database connection — none is configured.`,
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
await restoreStreamHelper(dbSource, user.tenantId, aggregateId);
|
|
337
|
+
},
|
|
338
|
+
isStreamArchived: async (aggregateId: string): Promise<boolean> => {
|
|
339
|
+
const dbSource = resolveDbSource(ctx, tx);
|
|
340
|
+
if (!dbSource) {
|
|
341
|
+
throw new InternalError({
|
|
342
|
+
message: `ctx.isStreamArchived("${aggregateId}") requires a database connection — none is configured.`,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
return isStreamArchived(dbSource, user.tenantId, aggregateId);
|
|
346
|
+
},
|
|
347
|
+
snapshotAggregate: async (snapshotArgs: {
|
|
348
|
+
readonly aggregateId: string;
|
|
349
|
+
readonly aggregateType: string;
|
|
350
|
+
readonly version: number;
|
|
351
|
+
readonly state: Record<string, unknown>;
|
|
352
|
+
readonly snapshotVersion?: number;
|
|
353
|
+
}): Promise<void> => {
|
|
354
|
+
const dbSource = resolveDbSource(ctx, tx);
|
|
355
|
+
if (!dbSource) {
|
|
356
|
+
throw new InternalError({
|
|
357
|
+
message: `ctx.snapshotAggregate("${snapshotArgs.aggregateId}") requires a database connection — none is configured.`,
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
await saveSnapshot(dbSource, {
|
|
361
|
+
aggregateId: snapshotArgs.aggregateId,
|
|
362
|
+
tenantId: user.tenantId,
|
|
363
|
+
aggregateType: snapshotArgs.aggregateType,
|
|
364
|
+
version: snapshotArgs.version,
|
|
365
|
+
state: snapshotArgs.state,
|
|
366
|
+
snapshotVersion: snapshotArgs.snapshotVersion,
|
|
367
|
+
});
|
|
368
|
+
},
|
|
369
|
+
loadAggregateWithSnapshot: async <TState extends Record<string, unknown>>(
|
|
370
|
+
aggregateId: string,
|
|
371
|
+
reducer: SnapshotReducer<TState>,
|
|
372
|
+
initial: TState,
|
|
373
|
+
loadOptions?: Omit<LoadAggregateWithSnapshotOptions, "upcastEvent">,
|
|
374
|
+
): Promise<LoadAggregateWithSnapshotResult<TState>> => {
|
|
375
|
+
const dbSource = resolveDbSource(ctx, tx);
|
|
376
|
+
if (!dbSource) {
|
|
377
|
+
throw new InternalError({
|
|
378
|
+
message: `ctx.loadAggregateWithSnapshot("${aggregateId}") requires a database connection — none is configured.`,
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
// Upcaster-aware: pass an upcastEvent callback so loadAggregateWithSnapshot
|
|
382
|
+
// walks every delta through the registered chain before invoking the
|
|
383
|
+
// user's (sync) reducer. Async upcasters (DB-enrichment) are awaited
|
|
384
|
+
// inside loadAggregateWithSnapshot — feature authors never see legacy
|
|
385
|
+
// payload shapes regardless of which load path they chose.
|
|
386
|
+
const upcasters = registry.getEventUpcasters();
|
|
387
|
+
const upcastCtx = { db: dbSource, tenantId: user.tenantId };
|
|
388
|
+
return loadAggregateWithSnapshot<TState>(
|
|
389
|
+
dbSource,
|
|
390
|
+
aggregateId,
|
|
391
|
+
user.tenantId,
|
|
392
|
+
reducer,
|
|
393
|
+
initial,
|
|
394
|
+
{
|
|
395
|
+
...loadOptions,
|
|
396
|
+
upcastEvent: (event) => upcastStoredEvent(event, upcasters, upcastCtx), // @wrapper-known semantic-alias
|
|
397
|
+
},
|
|
398
|
+
);
|
|
399
|
+
},
|
|
400
|
+
queryProjection: async <T = Record<string, unknown>>(
|
|
401
|
+
qualifiedName: string,
|
|
402
|
+
queryOptions?: { readonly unsafeAllTenants?: boolean },
|
|
403
|
+
): Promise<readonly T[]> => {
|
|
404
|
+
// queryProjection works against both single-stream and multi-stream
|
|
405
|
+
// projections. MSPs without a table cannot be queried — those are
|
|
406
|
+
// side-effect-only consumers (no state to read back).
|
|
407
|
+
const singleProj = registry.getAllProjections().get(qualifiedName);
|
|
408
|
+
const mspProj = registry.getAllMultiStreamProjections().get(qualifiedName);
|
|
409
|
+
const projTable = singleProj?.table ?? mspProj?.table;
|
|
410
|
+
if (!projTable) {
|
|
411
|
+
const singleNames = [...registry.getAllProjections().keys()];
|
|
412
|
+
const mspNames = [...registry.getAllMultiStreamProjections().keys()].filter(
|
|
413
|
+
(n) => registry.getAllMultiStreamProjections().get(n)?.table,
|
|
414
|
+
);
|
|
415
|
+
const all = [...singleNames, ...mspNames];
|
|
416
|
+
throw new InternalError({
|
|
417
|
+
message:
|
|
418
|
+
`ctx.queryProjection("${qualifiedName}") — projection not registered, or it is a ` +
|
|
419
|
+
`table-less MSP (side-effect-only). Known queryable projections: ${all.join(", ") || "(none)"}`,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
const dbSource = resolveDbSource(ctx, tx);
|
|
423
|
+
if (!dbSource) {
|
|
424
|
+
throw new InternalError({
|
|
425
|
+
message: `ctx.queryProjection("${qualifiedName}") requires a database connection — none is configured.`,
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
// Introspect for a tenant_id column on the projection table. Auto-
|
|
429
|
+
// filter keeps cross-tenant leaks out unless the handler explicitly
|
|
430
|
+
// opts in. Works with any drizzle-table whose tenant column is named
|
|
431
|
+
// tenantId on the JS side.
|
|
432
|
+
const tenantCol = (projTable as Record<string, unknown>)["tenantId"];
|
|
433
|
+
const where =
|
|
434
|
+
tenantCol && !queryOptions?.unsafeAllTenants ? { tenantId: user.tenantId } : undefined;
|
|
435
|
+
const rows = await selectMany<Record<string, unknown>>(dbSource, projTable, where);
|
|
436
|
+
return rows as readonly T[]; // @cast-boundary engine-payload
|
|
437
|
+
},
|
|
438
|
+
// Thin pass-through: one resolve impl lives on the dispatcher, the
|
|
439
|
+
// handler surface just forwards the call so both entry points (login
|
|
440
|
+
// handler via ctx.resolveAuthClaims, switch-tenant route via
|
|
441
|
+
// dispatcher.resolveAuthClaims) cannot drift.
|
|
442
|
+
resolveAuthClaims: (claimsUser: SessionUser) => resolveAuthClaimsFn(ctx, claimsUser), // @wrapper-known semantic-alias
|
|
443
|
+
|
|
444
|
+
// Feature-effective check for in-handler opt-in logic. Scope:
|
|
445
|
+
// **current user's tenant** — for cross-tenant lookups (rare,
|
|
446
|
+
// SysAdmin operations) read effectiveFeatures(otherTenantId) directly.
|
|
447
|
+
// When the feature-toggles or tier-engine feature isn't wired (no
|
|
448
|
+
// effectiveFeatures callback), always returns true — apps without
|
|
449
|
+
// tier-cuts treat all features on.
|
|
450
|
+
hasFeature: (featureName: string): boolean =>
|
|
451
|
+
effectiveFeatures ? effectiveFeatures(user.tenantId).has(featureName) : true,
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
// Registry is always the dispatcher's registry — injecting it here lets
|
|
455
|
+
// tests/callers pass `context` without `registry` and still get a valid
|
|
456
|
+
// HandlerContext. The spread-then-assign order matters: anything in
|
|
457
|
+
// `context` can be overridden, but we want the authoritative registry
|
|
458
|
+
// from the dispatcher's own closure to win.
|
|
459
|
+
// ctx.tz ist immer da. Tenant + User-Defaults kommen aus dem
|
|
460
|
+
// SessionUser sobald die Felder existieren — bis dahin "UTC". Ein
|
|
461
|
+
// app-injizierter GeoTzProvider (context.geoTzProvider) speist
|
|
462
|
+
// ctx.tz.fromCoordinates / fromAddress.
|
|
463
|
+
const tz = createTzContext(
|
|
464
|
+
context.geoTzProvider !== undefined ? { geoTz: context.geoTzProvider } : {},
|
|
465
|
+
);
|
|
466
|
+
|
|
467
|
+
return {
|
|
468
|
+
...context,
|
|
469
|
+
registry,
|
|
470
|
+
db,
|
|
471
|
+
log,
|
|
472
|
+
notify,
|
|
473
|
+
...(config && { config }),
|
|
474
|
+
...(files && { files }),
|
|
475
|
+
tracer,
|
|
476
|
+
metrics,
|
|
477
|
+
tz,
|
|
478
|
+
// Cancellation signal flows from the HTTP middleware via
|
|
479
|
+
// requestContext. Conditional spread so non-HTTP entry-points
|
|
480
|
+
// (jobs, dispatcher MSP-applies) don't get a phantom signal that
|
|
481
|
+
// would always read aborted=false but feel meaningful.
|
|
482
|
+
...(reqCtx?.signal ? { signal: reqCtx.signal } : {}),
|
|
483
|
+
// Propagate the feature-toggle resolver so the lifecycle pipeline,
|
|
484
|
+
// MSP runner, and ctx.hasFeature all pull from the same source.
|
|
485
|
+
...(effectiveFeatures && { effectiveFeatures }),
|
|
486
|
+
// ctx.user als Convenience-Alias auf event.user. Der typisch-
|
|
487
|
+
// intuitive Pfad „der Context kennt seinen User" — ohne den
|
|
488
|
+
// schreiben Handler `event.user.tenantId` und brechen sich die
|
|
489
|
+
// Finger an typo-resistenten ctx.user-Patterns. Identisch zum
|
|
490
|
+
// event.user-Wert; Identity-Switches nutzen weiterhin queryAs/writeAs.
|
|
491
|
+
user,
|
|
492
|
+
_userId: user.id,
|
|
493
|
+
_tenantId: user.tenantId,
|
|
494
|
+
_handlerType: type,
|
|
495
|
+
...(includeDeleted && { includeDeleted: true }),
|
|
496
|
+
...bridge,
|
|
497
|
+
} as HandlerContext; // @cast-boundary engine-bridge
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Wrap handler execution in a dispatcher.handler span AND emit the standard
|
|
501
|
+
// dispatcher metrics (duration + error counter). Errors are re-thrown so
|
|
502
|
+
// control flow stays identical to the uninstrumented path.
|
|
503
|
+
//
|
|
504
|
+
// Writes are special-cased: executeWriteInner converts thrown handler errors
|
|
505
|
+
// into a WriteResult with isSuccess=false (rather than letting them bubble).
|
|
506
|
+
// We inspect the result to paint the dispatcher span + error counter on
|
|
507
|
+
// those structural failures too — otherwise "handler threw" would only show
|
|
508
|
+
// up when the caller forgot to use writeFailure().
|
|
509
|
+
export async function runHandlerInstrumented<T>(
|
|
510
|
+
ctx: DispatchContext,
|
|
511
|
+
type: string,
|
|
512
|
+
operation: "query" | "write",
|
|
513
|
+
user: SessionUser,
|
|
514
|
+
inner: () => Promise<T>,
|
|
515
|
+
): Promise<T> {
|
|
516
|
+
const { tracer: dispatcherTracer, meter: dispatcherMeter, registry } = ctx;
|
|
517
|
+
const start = performance.now();
|
|
518
|
+
// Outcome recorded inside the withSpan callback, emitted in finally so
|
|
519
|
+
// success/failure/throw all hit a single metric-emit path.
|
|
520
|
+
let success = true;
|
|
521
|
+
let errorClass: string | undefined;
|
|
522
|
+
|
|
523
|
+
try {
|
|
524
|
+
return await dispatcherTracer.withSpan(
|
|
525
|
+
"kumiko.dispatcher.handler",
|
|
526
|
+
{
|
|
527
|
+
attributes: dispatcherSpanAttributes(
|
|
528
|
+
type,
|
|
529
|
+
operation,
|
|
530
|
+
user,
|
|
531
|
+
registry.getHandlerFeature(type),
|
|
532
|
+
),
|
|
533
|
+
},
|
|
534
|
+
async (span) => {
|
|
535
|
+
try {
|
|
536
|
+
const result = await inner();
|
|
537
|
+
if (operation === "write" && isFailedWriteResult(result)) {
|
|
538
|
+
success = false;
|
|
539
|
+
errorClass = result.error?.code ?? "UnknownError";
|
|
540
|
+
span.setStatus("error", errorClass);
|
|
541
|
+
}
|
|
542
|
+
return result;
|
|
543
|
+
} catch (error) {
|
|
544
|
+
success = false;
|
|
545
|
+
errorClass = error instanceof Error && error.name ? error.name : "UnknownError";
|
|
546
|
+
throw error;
|
|
547
|
+
}
|
|
548
|
+
},
|
|
549
|
+
);
|
|
550
|
+
} finally {
|
|
551
|
+
if (!success && errorClass) {
|
|
552
|
+
emitDispatcherError(dispatcherMeter, { handler: type, errorClass });
|
|
553
|
+
}
|
|
554
|
+
emitDispatcherHandler(
|
|
555
|
+
dispatcherMeter,
|
|
556
|
+
{ handler: type, success },
|
|
557
|
+
(performance.now() - start) / 1000,
|
|
558
|
+
);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// L3 rate limit gate. Called by both query and write paths before
|
|
563
|
+
// access-check. Reasoning:
|
|
564
|
+
// - handler without rateLimit → no-op
|
|
565
|
+
// - app booted without rateLimit resolver → InternalError so the
|
|
566
|
+
// misconfig surfaces immediately, not on first 429
|
|
567
|
+
// - bucket builder returns "skip" (e.g. ip-based but no client IP):
|
|
568
|
+
// pass through. ip-modes are commonly used at L1/L2 middleware
|
|
569
|
+
// where the IP comes from Hono directly; falling back to "skip"
|
|
570
|
+
// here keeps non-HTTP entry-points (jobs, MSPs) functional.
|
|
571
|
+
// Feature-toggle gate. Returns the error to fold into a WriteFailure in the
|
|
572
|
+
// write path, or throws for the query path (where throws flow through the
|
|
573
|
+
// same outer instrumentation wrapper as other dispatcher errors).
|
|
574
|
+
//
|
|
575
|
+
// When `effectiveFeatures` is not wired (tests, apps without feature-toggles
|
|
576
|
+
// loaded), every handler is treated as enabled — the gate is a pure
|
|
577
|
+
// pass-through in that common case.
|
|
578
|
+
export async function checkFeatureEnabled(
|
|
579
|
+
ctx: DispatchContext,
|
|
580
|
+
qualifiedHandler: string,
|
|
581
|
+
tenantId: TenantId,
|
|
582
|
+
): Promise<import("../errors").FeatureDisabledError | undefined> {
|
|
583
|
+
const { effectiveFeatures, registry } = ctx;
|
|
584
|
+
if (!effectiveFeatures) return undefined;
|
|
585
|
+
const owner = registry.getHandlerFeature(qualifiedHandler);
|
|
586
|
+
// skip: handler without an owning feature cannot be toggled — shouldn't
|
|
587
|
+
// happen for registry-built handlers, but guards against edge-case
|
|
588
|
+
// runtime injections.
|
|
589
|
+
if (!owner) return undefined;
|
|
590
|
+
const set = effectiveFeatures(tenantId);
|
|
591
|
+
if (set.has(owner)) return undefined;
|
|
592
|
+
// Feature is off for the stored tier — give the live trial-gate a last
|
|
593
|
+
// chance. Time-derived (tenant.inserted_at + window), so it can't live in
|
|
594
|
+
// the boot-cached sync resolver; consulted only on this already-disabled
|
|
595
|
+
// cold path, never on the hot enabled path.
|
|
596
|
+
if (effectiveFeatures.trialGate && (await effectiveFeatures.trialGate(tenantId, owner))) {
|
|
597
|
+
return undefined;
|
|
598
|
+
}
|
|
599
|
+
return new FeatureDisabledError(owner, qualifiedHandler);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
export async function ensureFeatureEnabled(
|
|
603
|
+
ctx: DispatchContext,
|
|
604
|
+
qualifiedHandler: string,
|
|
605
|
+
tenantId: TenantId,
|
|
606
|
+
): Promise<void> {
|
|
607
|
+
const err = await checkFeatureEnabled(ctx, qualifiedHandler, tenantId);
|
|
608
|
+
if (err) throw err;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
export async function enforceRateLimit(
|
|
612
|
+
ctx: DispatchContext,
|
|
613
|
+
rateLimit: import("../engine/types").RateLimitOption | undefined,
|
|
614
|
+
handlerName: string,
|
|
615
|
+
user: SessionUser,
|
|
616
|
+
): Promise<void> {
|
|
617
|
+
const { appContext: context } = ctx;
|
|
618
|
+
// skip: defence-in-depth — both call-sites already gate on
|
|
619
|
+
// handler.rateLimit !== undefined, so this branch only fires
|
|
620
|
+
// if a future caller forgets the inline check.
|
|
621
|
+
if (!rateLimit) return;
|
|
622
|
+
const reqCtx = requestContext.get();
|
|
623
|
+
const bucket = buildBucketKey(rateLimit, {
|
|
624
|
+
handlerName,
|
|
625
|
+
user,
|
|
626
|
+
ip: reqCtx?.ip,
|
|
627
|
+
});
|
|
628
|
+
// skip: ip-bucket + no IP (non-HTTP entry point) — pass through before
|
|
629
|
+
// requiring a resolver; HTTP path always has an IP + L1/L2 middleware.
|
|
630
|
+
if (bucket.kind === "skip") return;
|
|
631
|
+
if (!context.rateLimit) {
|
|
632
|
+
throw new InternalError({
|
|
633
|
+
message: `Handler "${handlerName}" declares rateLimit but no RateLimitResolver is configured. Load the rate-limiting feature or remove the option.`,
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
await context.rateLimit.enforce(bucket.key, {
|
|
637
|
+
limit: rateLimit.limit,
|
|
638
|
+
windowSeconds: rateLimit.windowSeconds,
|
|
639
|
+
cost: rateLimit.cost,
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// Build the per-hook context every auth-claims invocation gets. Claims
|
|
644
|
+
// hooks run OUTSIDE any request transaction (login is itself the root
|
|
645
|
+
// operation, not a nested call) and read-only — so the TenantDb is
|
|
646
|
+
// scoped as "tenant" and no tx is threaded through. Hooks that need
|
|
647
|
+
// cross-tenant lookups opt in explicitly via queryAs(systemUser, ...).
|
|
648
|
+
function buildAuthClaimsContext(ctx: DispatchContext, user: SessionUser): AuthClaimsContext {
|
|
649
|
+
const { appContext: context } = ctx;
|
|
650
|
+
const dbSource = resolveDbSource(ctx, undefined);
|
|
651
|
+
if (!dbSource) {
|
|
652
|
+
throw new InternalError({
|
|
653
|
+
message: "dispatcher.resolveAuthClaims requires a database connection — none is configured.",
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
const db = createTenantDb(dbSource, user.tenantId, "tenant", context.tracer, context.meter);
|
|
657
|
+
const configAccessor = context._configAccessorFactory
|
|
658
|
+
? context._configAccessorFactory({
|
|
659
|
+
user: { id: user.id, tenantId: user.tenantId },
|
|
660
|
+
db,
|
|
661
|
+
secrets: context.secrets,
|
|
662
|
+
})
|
|
663
|
+
: undefined;
|
|
664
|
+
return {
|
|
665
|
+
db,
|
|
666
|
+
queryAs: (asUser: SessionUser, qn: string, payload: unknown) =>
|
|
667
|
+
executeQuery(ctx, qn, payload, asUser), // @wrapper-known semantic-alias
|
|
668
|
+
...(configAccessor && { config: configAccessor }),
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
export async function resolveAuthClaimsFn(
|
|
673
|
+
ctx: DispatchContext,
|
|
674
|
+
user: SessionUser,
|
|
675
|
+
): Promise<Record<string, unknown>> {
|
|
676
|
+
const { registry, appContext: context } = ctx;
|
|
677
|
+
const hooks = registry.getAuthClaimsHooks();
|
|
678
|
+
if (hooks.length === 0) return {};
|
|
679
|
+
return runAuthClaimsResolver({
|
|
680
|
+
user,
|
|
681
|
+
hooks,
|
|
682
|
+
contextFactory: (claimsUser: SessionUser) => buildAuthClaimsContext(ctx, claimsUser),
|
|
683
|
+
...(context.log && { log: context.log }),
|
|
684
|
+
});
|
|
685
|
+
}
|