@cosmicdrift/kumiko-testing 0.387.0 → 0.389.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.
@@ -0,0 +1,628 @@
1
+ // @runtime dev
2
+ import { createHash } from "node:crypto";
3
+ import { buildServer, createSseBroker } from "@cosmicdrift/kumiko-framework/api";
4
+ import type { DbConnection, EntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
5
+ import { createRegistry, validateBoot } from "@cosmicdrift/kumiko-framework/engine";
6
+ import type { FeatureDefinition } from "@cosmicdrift/kumiko-framework/engine/types";
7
+ import {
8
+ createArchivedStreamsTable,
9
+ createEventsTable,
10
+ } from "@cosmicdrift/kumiko-framework/event-store";
11
+ import {
12
+ createRateLimitResolver,
13
+ unsafePushTables,
14
+ validateNavBootSubset,
15
+ validateOwnershipBoot,
16
+ } from "@cosmicdrift/kumiko-framework/internal/stack-support";
17
+ import { createJobRunner, type JobRunner } from "@cosmicdrift/kumiko-framework/jobs";
18
+ import { createNoopProvider } from "@cosmicdrift/kumiko-framework/observability";
19
+ import type { EventDispatcher } from "@cosmicdrift/kumiko-framework/pipeline";
20
+ import {
21
+ createEntityCache,
22
+ createEventDedup,
23
+ createIdempotencyGuard,
24
+ dispatcherToWriteRef,
25
+ } from "@cosmicdrift/kumiko-framework/pipeline";
26
+ import {
27
+ type ClosableCacheSyncBus,
28
+ createLocalCacheSyncBus,
29
+ createRedisCacheSyncBus,
30
+ } from "@cosmicdrift/kumiko-framework/redis";
31
+ import type { SearchAdapter } from "@cosmicdrift/kumiko-framework/search";
32
+ import { createInMemorySearchAdapter } from "@cosmicdrift/kumiko-framework/search";
33
+ import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
34
+ import type { Clock } from "@cosmicdrift/kumiko-types/tz-context";
35
+ import {
36
+ createTestDb,
37
+ ensureTestDbTemplate,
38
+ TEST_DB_TEMPLATE_RUN_ENV,
39
+ testDbTemplatePrefix,
40
+ } from "./db.ts";
41
+ import { type createJobFailureTracker, drainJobs } from "./drain-jobs.ts";
42
+ import { createEventCollector } from "./event-collector.ts";
43
+ import { createTestRedis, queueNamePrefixForTestRedis, type TestRedis } from "./redis.ts";
44
+ import { createRequestHelper } from "./request-helper.ts";
45
+ import type { TestStack, TestStackOptions } from "./test-stack.ts";
46
+
47
+ type MfaTokenSecretBinder = (
48
+ masterSecret: string,
49
+ env: Readonly<Record<string, string | undefined>>,
50
+ ) => void;
51
+
52
+ // Mirrors bindMfaTokenSecretsFromFeature from bundled-features/auth-mfa,
53
+ // which this package cannot import: bundled-features is an optional peer.
54
+ function isMfaTokenSecretBinder(value: unknown): value is MfaTokenSecretBinder {
55
+ return typeof value === "function";
56
+ }
57
+
58
+ // The env is empty on purpose: test secrets derive from jwtSecret,
59
+ // independent of the host env.
60
+ function bindMfaTokenSecrets(features: readonly FeatureDefinition[], jwtSecret: string): void {
61
+ for (const { exports } of features) {
62
+ if (typeof exports !== "object" || exports === null || !("bindTokenSecrets" in exports)) {
63
+ continue;
64
+ }
65
+ const { bindTokenSecrets } = exports;
66
+ if (isMfaTokenSecretBinder(bindTokenSecrets)) bindTokenSecrets(jwtSecret, {});
67
+ }
68
+ }
69
+
70
+ // Same table list as `kumiko schema generate` (collectTableMetas) — divergence
71
+ // caused a production crash and left r.entity() tables missing.
72
+ async function collectStackTableMetas(
73
+ options: TestStackOptions,
74
+ ): Promise<readonly EntityTableMeta[]> {
75
+ const { collectTableMetas, enumerateFeatureTableSources, extractTableInfo } = await import(
76
+ "@cosmicdrift/kumiko-framework/db"
77
+ );
78
+ const metas = collectTableMetas(options.features);
79
+ if (options.entityTables !== false) return metas;
80
+ const nonEntityNames = new Set(
81
+ options.features.flatMap((f) =>
82
+ enumerateFeatureTableSources(f).map((s) => extractTableInfo(s.table).name),
83
+ ),
84
+ );
85
+ return metas.filter((m) => nonEntityNames.has(m.tableName));
86
+ }
87
+
88
+ async function pushStackSchema(
89
+ db: DbConnection,
90
+ metas: readonly EntityTableMeta[],
91
+ withFileTables: boolean,
92
+ ): Promise<void> {
93
+ // Every ES-entity writes events via createEventStoreExecutor in the
94
+ // feature's write handlers. Auto-create the events table so every
95
+ // setupTestStack call is ready for writes without needing a manual
96
+ // createEventsTable().
97
+ await createEventsTable(db);
98
+ // Archive-stream metadata — needed by ctx.appendEvent's archive guard and
99
+ // loadAggregate's default-skip. Idempotent, so production boot running
100
+ // the same call is fine.
101
+ await createArchivedStreamsTable(db);
102
+
103
+ // Framework state for projection rebuild/status + event-consumer cursors.
104
+ // Idempotent — production boot flows run the same calls.
105
+ const { createProjectionStateTable, createEventConsumerStateTable } = await import(
106
+ "@cosmicdrift/kumiko-framework/pipeline"
107
+ );
108
+ await createProjectionStateTable(db);
109
+ await createEventConsumerStateTable(db);
110
+
111
+ // Files support: when a provider is registered, the fileRefs table must
112
+ // exist before the first upload. Skipped when no provider — the table
113
+ // stays off tenant test DBs that never touch files.
114
+ if (withFileTables) {
115
+ const { fileRefsTable, fileUploadLedgerTable } = await import(
116
+ "@cosmicdrift/kumiko-framework/files"
117
+ );
118
+ await unsafePushTables(db, { fileRefsTable, fileUploadLedgerTable });
119
+ }
120
+
121
+ const { tableExists } = await import("@cosmicdrift/kumiko-framework/db");
122
+ const missing: Record<string, unknown> = {};
123
+ for (const meta of metas) {
124
+ if (await tableExists(db, `public.${meta.tableName}`)) continue;
125
+ missing[meta.tableName] = meta;
126
+ }
127
+ if (Object.keys(missing).length > 0) {
128
+ await unsafePushTables(db, missing);
129
+ }
130
+ }
131
+
132
+ // Only inside an integration-runner run: the framework tables above are not part
133
+ // of the fingerprint, which holds because one run never sees two framework versions.
134
+ async function resolveTestDbTemplateName(
135
+ options: TestStackOptions,
136
+ metas: readonly EntityTableMeta[],
137
+ withFileTables: boolean,
138
+ ): Promise<string | undefined> {
139
+ const runId = process.env[TEST_DB_TEMPLATE_RUN_ENV];
140
+ if (runId === undefined || options.persistentDb === true || options.dbName !== undefined) {
141
+ return undefined;
142
+ }
143
+ const { renderTablesDdl } = await import("@cosmicdrift/kumiko-framework/db");
144
+ const fingerprint = createHash("sha256")
145
+ .update(JSON.stringify({ withFileTables, ddl: renderTablesDdl(metas) }))
146
+ .digest("hex")
147
+ .slice(0, 16);
148
+ return `${testDbTemplatePrefix(runId)}${fingerprint}`;
149
+ }
150
+
151
+ // Best-effort and sequential: a broken cleanup must never mask the setup error the caller rethrows.
152
+ export async function runCleanupsIgnoringErrors(
153
+ cleanups: ReadonlyArray<() => Promise<void>>,
154
+ ): Promise<void> {
155
+ for (const cleanup of cleanups) {
156
+ try {
157
+ await cleanup();
158
+ } catch {
159
+ // ignore — the setup error is the one that matters
160
+ }
161
+ }
162
+ }
163
+
164
+ export async function validateStackFeatures(
165
+ options: TestStackOptions,
166
+ jwtSecret: string,
167
+ ): Promise<void> {
168
+ // Install the Temporal polyfill before feature code runs. Idempotent: the
169
+ // production server boot does the same. A no-op on runtimes with native Temporal.
170
+ const { ensureTemporalPolyfill } = await import("@cosmicdrift/kumiko-framework/time");
171
+ await ensureTemporalPolyfill();
172
+
173
+ // Ownership boot-guards (unqualified where-fragments, where-rules on the
174
+ // write path) otherwise run only in the prod boot via validateBoot, leaving
175
+ // every test stack blind to a broken access map.
176
+ // Runs after the polyfill (buildEntityTable needs it to derive the probe
177
+ // columns) and before the ephemeral DB/Redis exist, so a throw can't leak
178
+ // a database that nothing will clean up.
179
+ validateOwnershipBoot(options.features);
180
+ // Nav/workspace refs fail the prod boot too; the subset check tolerates refs into unmounted features.
181
+ validateNavBootSubset(options.features);
182
+ if (options.validateBoot === "full") validateBoot(options.features);
183
+ bindMfaTokenSecrets(options.features, jwtSecret);
184
+ }
185
+
186
+ export type StackStorage = {
187
+ readonly testDb: Awaited<ReturnType<typeof createTestDb>>;
188
+ readonly testRedis: TestRedis;
189
+ };
190
+
191
+ export async function provisionStackStorage(options: TestStackOptions): Promise<StackStorage> {
192
+ const stackTableMetas = await collectStackTableMetas(options);
193
+ const withFileTables = options.files !== undefined;
194
+ const buildStackSchema = (db: DbConnection): Promise<void> =>
195
+ pushStackSchema(db, stackTableMetas, withFileTables);
196
+ const templateDbName = await resolveTestDbTemplateName(options, stackTableMetas, withFileTables);
197
+ if (templateDbName !== undefined) await ensureTestDbTemplate(templateDbName, buildStackSchema);
198
+
199
+ // Forward db-name/persistent-flag through to createTestDb. The
200
+ // defaults (undefined dbName, persistent:false) keep the legacy
201
+ // test contract: fresh kumiko_test_<random> DB per setup, dropped
202
+ // on cleanup.
203
+ const [testDb, testRedis] = await Promise.all([
204
+ createTestDb({
205
+ ...(options.dbName !== undefined && { dbName: options.dbName }),
206
+ ...(options.persistentDb !== undefined && { persistent: options.persistentDb }),
207
+ ...(options.dbPoolMax !== undefined && { poolMax: options.dbPoolMax }),
208
+ ...(options.outsideTransactionPoolMax !== undefined && {
209
+ outsideTransactionPoolMax: options.outsideTransactionPoolMax,
210
+ }),
211
+ ...(templateDbName !== undefined && { templateDbName }),
212
+ }),
213
+ createTestRedis(
214
+ options.sharedRedisWith && { borrowKeyPrefix: options.sharedRedisWith.redis.keyPrefix },
215
+ ),
216
+ ]);
217
+
218
+ // Any throw in this DDL region must still drop the just-created ephemeral
219
+ // DB/Redis — the caller never gets the stack object back to call
220
+ // cleanup() itself (same "leaked ephemeral DB" failure class as the later
221
+ // try/catch in setupTestStack, but for setup that runs before that try starts).
222
+ if (templateDbName === undefined) {
223
+ try {
224
+ await buildStackSchema(testDb.db);
225
+ } catch (error) {
226
+ await runCleanupsIgnoringErrors([() => testDb.cleanup(), () => testRedis.cleanup()]);
227
+ throw error;
228
+ }
229
+ }
230
+ return { testDb, testRedis };
231
+ }
232
+
233
+ // Redis only where two stacks can actually disagree (avoids two extra
234
+ // Pub/Sub connections); otherwise a local bus so invalidation still works
235
+ // inside this stack. Passing it explicitly keeps buildServer from using its
236
+ // REDIS_URL default (an unnamespaced channel).
237
+ export function createStackCacheSync(
238
+ options: TestStackOptions,
239
+ testRedis: TestRedis,
240
+ ): ClosableCacheSyncBus {
241
+ return options.cacheSync || options.sharedRedisWith
242
+ ? createRedisCacheSyncBus({
243
+ redisUrl: testRedis.redisUrl,
244
+ channelPrefix: testRedis.keyPrefix,
245
+ })
246
+ : createLocalCacheSyncBus();
247
+ }
248
+
249
+ // A static `files.storageProvider` is wired as the per-tenant resolver — the
250
+ // framework test seam that doesn't require mounting config + file-foundation.
251
+ // (Bundled GDPR tests mount the real provider features instead.)
252
+ function isClock(value: unknown): value is Clock {
253
+ return (
254
+ typeof value === "object" && value !== null && "now" in value && typeof value.now === "function"
255
+ );
256
+ }
257
+
258
+ async function resolveStackFileProvider(
259
+ options: TestStackOptions,
260
+ testDb: StackStorage["testDb"],
261
+ readClock: () => unknown,
262
+ ): Promise<import("@cosmicdrift/kumiko-framework/files").FileProviderResolver | undefined> {
263
+ if (!options.files) return undefined;
264
+ const provider = options.files.storageProvider;
265
+ const { withUploadLedger } = await import("@cosmicdrift/kumiko-framework/files");
266
+ // Read per call: the clock arrives via extraContext, which is merged after this resolver is built.
267
+ const now = (): Temporal.Instant => {
268
+ const clock = readClock();
269
+ return isClock(clock) ? clock.now() : Temporal.Now.instant();
270
+ };
271
+ return withUploadLedger(() => Promise.resolve(provider), testDb.db, now);
272
+ }
273
+
274
+ export async function buildStackServices(
275
+ options: TestStackOptions,
276
+ testDb: StackStorage["testDb"],
277
+ testRedis: TestRedis,
278
+ ) {
279
+ const searchAdapter = createInMemorySearchAdapter();
280
+ const events = createEventCollector();
281
+ const registry = createRegistry([...options.features]);
282
+
283
+ // Wire SSE broker with event collector — built early (not just before
284
+ // buildServer) because extraContext + the job context below both need it.
285
+ const sseBroker = createSseBroker();
286
+ sseBroker.addClient(
287
+ "tenant:00000000-0000-4000-8000-000000000001",
288
+ (event) => events.sse.push(event),
289
+ () => {},
290
+ );
291
+
292
+ const entityCache = createEntityCache(testRedis.redis, { ttlSeconds: 60 });
293
+ const extraContextClock: { current: unknown } = { current: undefined };
294
+ const fileProviderResolver = await resolveStackFileProvider(
295
+ options,
296
+ testDb,
297
+ () => extraContextClock.current,
298
+ );
299
+ const observability = options.observability ?? createNoopProvider();
300
+
301
+ // Same AppContext buildServer() gets below (db/redis/searchAdapter/
302
+ // entityCache/masterKeyProvider/fileProviderResolver/extraContext) —
303
+ // shared so the job context can't drift from the request-path context:
304
+ // a reduced `{ db, registry }` literal let jobs pass in tests while
305
+ // reaching for fields only prod's context has.
306
+ //
307
+ // effectiveFeatures deliberately stays OUT of appContext — prod never puts
308
+ // it there either (only dispatcherOptions.effectiveFeatures, consumed by
309
+ // the command-dispatcher — see buildJobRunnerWithHook in entrypoint/
310
+ // index.ts). Putting it here would reintroduce the exact test-vs-prod
311
+ // drift fixed: a job/handler reading `ctx.effectiveFeatures` would pass in
312
+ // tests and break in prod.
313
+ const extraContext =
314
+ typeof options.extraContext === "function"
315
+ ? options.extraContext({ registry, db: testDb.db, sseBroker, redis: testRedis.redis })
316
+ : options.extraContext;
317
+ const appContext = {
318
+ db: testDb.db,
319
+ redis: testRedis.redis,
320
+ searchAdapter,
321
+ entityCache,
322
+ registry,
323
+ ...(options.masterKeyProvider ? { masterKeyProvider: options.masterKeyProvider } : {}),
324
+ ...(fileProviderResolver ? { _fileProviderResolver: fileProviderResolver } : {}),
325
+ ...(options.defaultLocale !== undefined && { defaultLocale: options.defaultLocale }),
326
+ ...extraContext,
327
+ };
328
+ extraContextClock.current = extraContext?.["clock"];
329
+ return { searchAdapter, events, registry, sseBroker, entityCache, observability, appContext };
330
+ }
331
+
332
+ export type StackServices = Awaited<ReturnType<typeof buildStackServices>>;
333
+
334
+ type JobFailureTracker = ReturnType<typeof createJobFailureTracker>;
335
+
336
+ // Built before buildServer() so it can be merged into dispatcherOptions
337
+ // (write handlers' `ctx.jobRunner.dispatch(...)` and the afterCommit
338
+ // event-trigger hook both need it present at dispatcher-construction
339
+ // time, same as the prod entrypoint's buildJobRunnerWithHook). Uses the
340
+ // test-stack's own ephemeral redis — no separate `redisUrl` seam needed.
341
+ //
342
+ // context = appContext + tracer/meter, mirroring the prod entrypoint's
343
+ // `contextWithObservability(options.context, observability)`.
344
+ export function createStackJobRunner(
345
+ options: TestStackOptions,
346
+ services: StackServices,
347
+ testDb: StackStorage["testDb"],
348
+ testRedis: TestRedis,
349
+ jobFailureTracker: JobFailureTracker,
350
+ ): JobRunner | undefined {
351
+ const { registry, appContext, observability } = services;
352
+ if (!options.jobs || registry.getAllJobs().size === 0) return undefined;
353
+ const runLogger = options.jobs.runLogger?.({ registry, db: testDb.db });
354
+ return createJobRunner({
355
+ registry,
356
+ context: { ...appContext, tracer: observability.tracer, meter: observability.meter },
357
+ // The real REDIS_URL, not one rebuilt from `.options` — rebuilding it lost
358
+ // password/username/tls/path.
359
+ redisUrl: testRedis.redisUrl,
360
+ ...(options.jobs.consumerLane !== undefined && { consumerLane: options.jobs.consumerLane }),
361
+ queueNamePrefix:
362
+ options.jobs.queueNamePrefix ?? queueNamePrefixForTestRedis(testRedis.keyPrefix),
363
+ ...(options.jobs.getActiveTenantIds !== undefined && {
364
+ getActiveTenantIds: options.jobs.getActiveTenantIds,
365
+ }),
366
+ ...(runLogger?.onJobStart !== undefined && { onJobStart: runLogger.onJobStart }),
367
+ ...(runLogger?.onJobQueued !== undefined && { onJobQueued: runLogger.onJobQueued }),
368
+ ...(runLogger?.onJobDropped !== undefined && { onJobDropped: runLogger.onJobDropped }),
369
+ // Tracker fires in finally: drainJobs wakes on it, so the log row must
370
+ // be written first, and a throwing logger must not swallow the signal.
371
+ onJobComplete: async (jobName, jobId, ...rest) => {
372
+ try {
373
+ await runLogger?.onJobComplete?.(jobName, jobId, ...rest);
374
+ } finally {
375
+ jobFailureTracker.onJobComplete(jobName, jobId);
376
+ }
377
+ },
378
+ onJobFailed: async (jobName, jobId, error, ...rest) => {
379
+ try {
380
+ await runLogger?.onJobFailed?.(jobName, jobId, error, ...rest);
381
+ } finally {
382
+ jobFailureTracker.onJobFailed(jobName, jobId, error);
383
+ }
384
+ },
385
+ });
386
+ }
387
+
388
+ function collectSearchableFields(features: TestStackOptions["features"]): string[] {
389
+ const searchableFields: string[] = [];
390
+ for (const feature of features) {
391
+ for (const [, entity] of Object.entries(feature.entities ?? {})) {
392
+ for (const [fieldName, field] of Object.entries(entity.fields)) {
393
+ if (field.type === "text" && field.searchable) {
394
+ searchableFields.push(fieldName);
395
+ }
396
+ if (field.type === "embedded") {
397
+ for (const [subName, subField] of Object.entries(field.schema)) {
398
+ if (subField.searchable) {
399
+ searchableFields.push(`${fieldName}_${subName}`);
400
+ }
401
+ }
402
+ }
403
+ }
404
+ }
405
+ }
406
+ return searchableFields;
407
+ }
408
+
409
+ // Auto-configure search for tenant 1 based on registry
410
+ async function configureStackSearch(
411
+ options: TestStackOptions,
412
+ enabledHooks: ReadonlyArray<string>,
413
+ searchAdapter: SearchAdapter,
414
+ ): Promise<void> {
415
+ if (enabledHooks.includes("search")) {
416
+ const searchableFields = collectSearchableFields(options.features);
417
+ if (options.searchConfig) {
418
+ await searchAdapter.configure(options.searchConfig.tenantId, {
419
+ searchableFields: options.searchConfig.searchableFields,
420
+ rankingFields: options.searchConfig.rankingFields,
421
+ });
422
+ } else if (searchableFields.length > 0) {
423
+ await searchAdapter.configure("00000000-0000-4000-8000-000000000001", {
424
+ searchableFields,
425
+ rankingFields: searchableFields,
426
+ });
427
+ }
428
+ }
429
+ }
430
+
431
+ // buildServer only auto-wires an L3 RateLimitResolver from
432
+ // `context.redis` — a degradation test that nulls `redis` via
433
+ // extraContext (to exercise a handler's own ctx.redis-down guard) also
434
+ // silently strips rate-limiting, even though every handler with a
435
+ // `rateLimit` declaration still requires a resolver at dispatch time.
436
+ // Since e7bf543ad, an IP-keyed bucket key is built unconditionally
437
+ // (falls back to a shared "unknown" IP rather than skipping), so the
438
+ // missing resolver now throws instead of merely skipping enforcement.
439
+ // Mirrors idempotency/eventDedup: built from the test-stack's own
440
+ // always-real `testRedis.redis`, independent of whatever extraContext
441
+ // did to `appContext.redis` — an explicit `context.rateLimit` from
442
+ // extraContext still wins (same precedence buildServer documents).
443
+ function resolveStackRateLimitFallback(
444
+ options: TestStackOptions,
445
+ appContext: StackServices["appContext"],
446
+ testRedis: TestRedis,
447
+ ): ReturnType<typeof createRateLimitResolver> | undefined {
448
+ const explicitRateLimit = propertyOf(appContext, "rateLimit");
449
+ const needsOwnRateLimitResolver =
450
+ explicitRateLimit === undefined &&
451
+ (appContext.redis === undefined || options.rateLimitNowMs !== undefined);
452
+ if (!needsOwnRateLimitResolver) return undefined;
453
+ return createRateLimitResolver({
454
+ // An app-supplied redis keeps buckets and app redis on the same instance.
455
+ redis: appContext.redis ?? testRedis.redis,
456
+ ...(options.rateLimitNowMs ? { nowMs: options.rateLimitNowMs } : {}),
457
+ });
458
+ }
459
+
460
+ async function resolveStackAnonymousAccess(
461
+ options: TestStackOptions,
462
+ testDb: StackStorage["testDb"],
463
+ testRedis: TestRedis,
464
+ services: StackServices,
465
+ ): Promise<Pick<ServerBuildOptions, "anonymousAccess">> {
466
+ const { registry, sseBroker } = services;
467
+ const baseAnon =
468
+ typeof options.anonymousAccess === "function"
469
+ ? options.anonymousAccess({ registry, db: testDb.db, sseBroker, redis: testRedis.redis })
470
+ : options.anonymousAccess;
471
+ const resolvedAnon = options.enrichAnonymousAccess
472
+ ? await options.enrichAnonymousAccess(baseAnon, { registry, db: testDb.db })
473
+ : baseAnon;
474
+ return resolvedAnon ? { anonymousAccess: resolvedAnon } : {};
475
+ }
476
+
477
+ type ServerBuildOptions = Parameters<typeof buildServer>[0];
478
+
479
+ // Default tests to no login rate-limiter so existing suites that loop
480
+ // over logins don't hit a 429 after 10 attempts. Suites specifically
481
+ // testing the limiter can override via authConfig.loginRateLimit.
482
+ function buildStackAuthConfig(
483
+ authConfig: TestStackOptions["authConfig"],
484
+ ): Pick<ServerBuildOptions, "auth"> {
485
+ if (!authConfig) return {};
486
+ return {
487
+ auth: {
488
+ ...authConfig,
489
+ ...(authConfig.loginRateLimit === undefined ? { loginRateLimit: null } : {}),
490
+ },
491
+ };
492
+ }
493
+
494
+ // Tests drive the dispatcher via stack.eventDispatcher.runOnce() for
495
+ // deterministic drains — no timer-induced flakiness. pollIntervalMs
496
+ // stays short anyway in case a test opts into `.start()`. pgClient
497
+ // plumbs through the LISTEN wake-up for tests that want to measure
498
+ // post-commit latency (Sprint E.4).
499
+ function stackEventDispatcherOptions(
500
+ options: TestStackOptions,
501
+ testDb: StackStorage["testDb"],
502
+ enabledHooks: ReadonlyArray<string>,
503
+ ): NonNullable<ServerBuildOptions["eventDispatcher"]> {
504
+ return {
505
+ pollIntervalMs: options.eventDispatcherPollIntervalMs ?? 50,
506
+ pgClient: testDb.client,
507
+ systemConsumers: {
508
+ sse: enabledHooks.includes("sse"),
509
+ search: enabledHooks.includes("search"),
510
+ accessInvalidation: enabledHooks.includes("sse"),
511
+ },
512
+ };
513
+ }
514
+
515
+ export type StackServerInput = {
516
+ readonly options: TestStackOptions;
517
+ readonly jwtSecret: string;
518
+ readonly enabledHooks: ReadonlyArray<string>;
519
+ readonly testDb: StackStorage["testDb"];
520
+ readonly testRedis: TestRedis;
521
+ readonly cacheSync: ClosableCacheSyncBus;
522
+ readonly services: StackServices;
523
+ readonly jobRunner: JobRunner | undefined;
524
+ readonly jobFailureTracker: JobFailureTracker;
525
+ };
526
+
527
+ type StackServer = ReturnType<typeof buildServer>;
528
+
529
+ function buildStackServerOptions(
530
+ input: StackServerInput,
531
+ anonymousAccess: Pick<ServerBuildOptions, "anonymousAccess">,
532
+ ): ServerBuildOptions {
533
+ const { options, jwtSecret, enabledHooks, testDb, testRedis, cacheSync, services, jobRunner } =
534
+ input;
535
+ const { registry, sseBroker, appContext } = services;
536
+ const idempotency = createIdempotencyGuard(testRedis.redis, { ttlSeconds: 60 });
537
+ const eventDedup = createEventDedup(testRedis.redis, { ttlSeconds: 60 });
538
+ const rateLimitFallback = resolveStackRateLimitFallback(options, appContext, testRedis);
539
+ return {
540
+ registry,
541
+ context: rateLimitFallback ? { ...appContext, rateLimit: rateLimitFallback } : appContext,
542
+ jwtSecret,
543
+ ...(options.observability ? { observability: options.observability } : {}),
544
+ ...(options.lifecycleSystemHooks && { systemHooks: options.lifecycleSystemHooks }),
545
+ dispatcherOptions: {
546
+ idempotency,
547
+ cacheSync,
548
+ outsideTransactionDb: testDb.outsideTransactionDb,
549
+ ...(options.effectiveFeatures && { effectiveFeatures: options.effectiveFeatures }),
550
+ ...(jobRunner && { jobRunner }),
551
+ },
552
+ eventDedup,
553
+ sseBroker,
554
+ ...(options.trustedProxyHops !== undefined && { trustedProxyHops: options.trustedProxyHops }),
555
+ ...(options.errorDocs && { errorDocs: options.errorDocs }),
556
+ ...(options.extraRoutes && { extraRoutes: options.extraRoutes }),
557
+ ...(options.metrics && { metrics: options.metrics }),
558
+ eventDispatcher: stackEventDispatcherOptions(options, testDb, enabledHooks),
559
+ ...buildStackAuthConfig(options.authConfig),
560
+ ...(options.lifecycle ? { lifecycle: options.lifecycle } : {}),
561
+ ...(options.rateLimit ? { rateLimit: options.rateLimit } : {}),
562
+ ...anonymousAccess,
563
+ };
564
+ }
565
+
566
+ function assembleTestStack(
567
+ input: StackServerInput,
568
+ server: StackServer,
569
+ eventDispatcher: EventDispatcher | undefined,
570
+ ): TestStack {
571
+ const { options, testDb, testRedis, cacheSync, services, jobRunner, jobFailureTracker } = input;
572
+ const http = createRequestHelper(server.app, server.jwt, {
573
+ ...(options.authConfig?.sessionCreator !== undefined && {
574
+ sessionCreator: options.authConfig.sessionCreator,
575
+ }),
576
+ });
577
+ return {
578
+ app: server.app,
579
+ jwt: server.jwt,
580
+ registry: services.registry,
581
+ db: testDb.db,
582
+ dbPoolMax: testDb.poolMax,
583
+ redis: testRedis,
584
+ cacheSync,
585
+ search: services.searchAdapter,
586
+ events: services.events,
587
+ http,
588
+ observability: server.observability,
589
+ sseBroker: services.sseBroker,
590
+ dispatcher: server.dispatcher,
591
+ context: server.context,
592
+ ...(eventDispatcher ? { eventDispatcher } : {}),
593
+ ...(server.lifecycle ? { lifecycle: server.lifecycle } : {}),
594
+ ...(jobRunner ? { jobRunner } : {}),
595
+ drainJobs: () => drainJobs({ db: testDb.db, eventDispatcher, jobRunner }, jobFailureTracker),
596
+ cleanup: async () => {
597
+ if (jobRunner) await jobRunner.stop();
598
+ if (eventDispatcher) await eventDispatcher.stop();
599
+ await server.observability.shutdown();
600
+ await cacheSync.close();
601
+ await Promise.all([testDb.cleanup(), testRedis.cleanup()]);
602
+ },
603
+ };
604
+ }
605
+
606
+ export async function startStackServer(input: StackServerInput): Promise<TestStack> {
607
+ const { options, enabledHooks, testDb, testRedis, services, jobRunner } = input;
608
+ if (jobRunner) await jobRunner.start();
609
+ await configureStackSearch(options, enabledHooks, services.searchAdapter);
610
+
611
+ const anonymousAccess = await resolveStackAnonymousAccess(options, testDb, testRedis, services);
612
+ const server = buildServer(buildStackServerOptions(input, anonymousAccess));
613
+
614
+ // Mirrors the prod entrypoints — without this, JobContext.write/writeAs/
615
+ // queryAs/queryAsMember throw on first use inside a test-stack job.
616
+ if (jobRunner) jobRunner.attachDispatcher(dispatcherToWriteRef(server.dispatcher));
617
+
618
+ const eventDispatcher: EventDispatcher | undefined = server.eventDispatcher;
619
+
620
+ // Pre-register consumer state rows so tests can call runOnce() directly
621
+ // without a preceding explicit start(). Timer fires at pollIntervalMs=50
622
+ // but each consumer runs at most one turn at a time — tests that drain via
623
+ // runOnce() remain deterministic. Tests that specifically exercise the
624
+ // timer loop call start() again (idempotent) after setup.
625
+ if (eventDispatcher) await eventDispatcher.ensureRegistered();
626
+
627
+ return assembleTestStack(input, server, eventDispatcher);
628
+ }