@cosmicdrift/kumiko-framework 0.182.0 → 0.183.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/package.json +3 -3
- package/src/api/server.ts +30 -12
- package/src/engine/__tests__/content-collection.test.ts +8 -6
- package/src/entrypoint/__tests__/split-deploy.integration.test.ts +54 -0
- package/src/entrypoint/index.ts +10 -11
- package/src/jobs/__tests__/jobs.integration.test.ts +3 -0
- package/src/jobs/job-runner.ts +27 -0
- package/src/stack/test-stack.ts +8 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.183.0",
|
|
4
4
|
"description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -182,7 +182,7 @@
|
|
|
182
182
|
"./package.json": "./package.json"
|
|
183
183
|
},
|
|
184
184
|
"dependencies": {
|
|
185
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
185
|
+
"@cosmicdrift/kumiko-types": "0.183.0",
|
|
186
186
|
"bullmq": "^5.76.7",
|
|
187
187
|
"bun-types": "^1.3.13",
|
|
188
188
|
"hono": "^4.12.27",
|
|
@@ -198,7 +198,7 @@
|
|
|
198
198
|
"zod": "^4.4.3"
|
|
199
199
|
},
|
|
200
200
|
"devDependencies": {
|
|
201
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
201
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.183.0",
|
|
202
202
|
"bun-types": "^1.3.13",
|
|
203
203
|
"pino-pretty": "^13.1.3"
|
|
204
204
|
},
|
package/src/api/server.ts
CHANGED
|
@@ -236,8 +236,34 @@ export type KumikoServer = {
|
|
|
236
236
|
// Echoed back so the caller has a single handle for both the app and the
|
|
237
237
|
// lifecycle. Only set when the caller passed one in.
|
|
238
238
|
lifecycle?: Lifecycle;
|
|
239
|
+
// The AppContext every handler on this server sees — options.context plus
|
|
240
|
+
// what buildServer wires onto it (_fileProviderResolver, rateLimit, the
|
|
241
|
+
// observability tracer/meter). Callers that build a second consumer of the
|
|
242
|
+
// same registry outside this server (a dev-server's job-runners) must pass
|
|
243
|
+
// THIS, not their own pre-buildServer literal, or that consumer reaches for
|
|
244
|
+
// fields only the request path has (#1232).
|
|
245
|
+
context: AppContext;
|
|
239
246
|
};
|
|
240
247
|
|
|
248
|
+
// The per-tenant file-provider resolver, built once for a registry+context so
|
|
249
|
+
// a job-runner and the server it runs beside share one instance (and one
|
|
250
|
+
// per-tenant provider cache). Mirrors buildServer's own resolution exactly —
|
|
251
|
+
// including NOT inventing a resolver when no `file-provider-*` plugin is
|
|
252
|
+
// mounted, which is what keeps buildServer's boot-guard below able to fire.
|
|
253
|
+
export function withFileProviderResolver(registry: Registry, context: AppContext): AppContext {
|
|
254
|
+
if (context._fileProviderResolver !== undefined) return context;
|
|
255
|
+
if (registry.getExtensionUsages(EXT_FILE_PROVIDER).length === 0) return context;
|
|
256
|
+
return {
|
|
257
|
+
...context,
|
|
258
|
+
_fileProviderResolver: makeFileProviderResolver({
|
|
259
|
+
registry,
|
|
260
|
+
_configAccessorFactory: context._configAccessorFactory,
|
|
261
|
+
secrets: context.secrets,
|
|
262
|
+
db: context.db,
|
|
263
|
+
}),
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
241
267
|
export function buildServer(options: ServerOptions): KumikoServer {
|
|
242
268
|
// File-storage is resolved per-tenant through file-foundation: a mounted
|
|
243
269
|
// `file-provider-*` plugin (inmemory/s3/s3-env) is the single source for
|
|
@@ -336,16 +362,8 @@ export function buildServer(options: ServerOptions): KumikoServer {
|
|
|
336
362
|
// resolver once (when a provider plugin is mounted) — the dispatcher uses it
|
|
337
363
|
// to materialise `ctx.files`, the upload routes + MSP-applies share it. The
|
|
338
364
|
// resolver reads config + the s3.secretAccessKey secret under SYSTEM identity.
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
(hasFileProvider
|
|
342
|
-
? makeFileProviderResolver({
|
|
343
|
-
registry: options.registry,
|
|
344
|
-
_configAccessorFactory: options.context._configAccessorFactory,
|
|
345
|
-
secrets: options.context.secrets,
|
|
346
|
-
db: options.context.db,
|
|
347
|
-
})
|
|
348
|
-
: undefined);
|
|
365
|
+
const contextWithFiles = withFileProviderResolver(options.registry, options.context);
|
|
366
|
+
const fileProviderResolver = contextWithFiles._fileProviderResolver;
|
|
349
367
|
// Auto-wire the rate-limit resolver, but ONLY when at least one
|
|
350
368
|
// handler actually declared a rateLimit option. Apps that don't use
|
|
351
369
|
// L3 pay zero cost: no resolver instance, no Lua-script registration
|
|
@@ -363,9 +381,8 @@ export function buildServer(options: ServerOptions): KumikoServer {
|
|
|
363
381
|
options.context.rateLimit ??
|
|
364
382
|
(wrappedRedis && wantsResolver ? createRateLimitResolver({ redis: wrappedRedis }) : undefined);
|
|
365
383
|
const contextWithObservability: AppContext = {
|
|
366
|
-
...
|
|
384
|
+
...contextWithFiles,
|
|
367
385
|
...(wrappedRedis ? { redis: wrappedRedis } : {}),
|
|
368
|
-
...(fileProviderResolver ? { _fileProviderResolver: fileProviderResolver } : {}),
|
|
369
386
|
...(rateLimitResolver ? { rateLimit: rateLimitResolver } : {}),
|
|
370
387
|
// Propagate the feature-toggle resolver to the context so the event-
|
|
371
388
|
// dispatcher (and any future context-reading consumer) sees the same
|
|
@@ -783,6 +800,7 @@ export function buildServer(options: ServerOptions): KumikoServer {
|
|
|
783
800
|
sseBroker,
|
|
784
801
|
observability,
|
|
785
802
|
dispatcher,
|
|
803
|
+
context: contextWithObservability,
|
|
786
804
|
...(eventDispatcher ? { eventDispatcher } : {}),
|
|
787
805
|
...(options.lifecycle ? { lifecycle: options.lifecycle } : {}),
|
|
788
806
|
};
|
|
@@ -155,14 +155,14 @@ describe("r.contentCollection() — registration", () => {
|
|
|
155
155
|
id: "prompts",
|
|
156
156
|
kind: "ai-prompt",
|
|
157
157
|
contentFormat: "plain",
|
|
158
|
-
variableSchema: { customerName:
|
|
158
|
+
variableSchema: { customerName: "Max Mustermann", orderId: "A-1042" },
|
|
159
159
|
nav: { label: "mail:nav.prompts" },
|
|
160
160
|
});
|
|
161
161
|
});
|
|
162
162
|
|
|
163
163
|
expect(feature.contentCollections?.["prompts"]?.variableSchema).toEqual({
|
|
164
|
-
customerName:
|
|
165
|
-
orderId:
|
|
164
|
+
customerName: "Max Mustermann",
|
|
165
|
+
orderId: "A-1042",
|
|
166
166
|
});
|
|
167
167
|
});
|
|
168
168
|
|
|
@@ -267,7 +267,7 @@ describe("buildAppSchema — content collections", () => {
|
|
|
267
267
|
id: "prompts",
|
|
268
268
|
kind: "ai-prompt",
|
|
269
269
|
contentFormat: "plain",
|
|
270
|
-
variableSchema: { customerName:
|
|
270
|
+
variableSchema: { customerName: "Max Mustermann" },
|
|
271
271
|
nav: { label: "mail:nav.prompts" },
|
|
272
272
|
});
|
|
273
273
|
}),
|
|
@@ -275,7 +275,9 @@ describe("buildAppSchema — content collections", () => {
|
|
|
275
275
|
|
|
276
276
|
const schema = buildAppSchema(registry);
|
|
277
277
|
const mail = schema.features.find((f) => f.featureName === "mail");
|
|
278
|
-
expect(mail?.contentCollections?.[0]?.variableSchema).toEqual({
|
|
278
|
+
expect(mail?.contentCollections?.[0]?.variableSchema).toEqual({
|
|
279
|
+
customerName: "Max Mustermann",
|
|
280
|
+
});
|
|
279
281
|
// buildAppSchema's JSON-safety check throws on undefined leaves — proves
|
|
280
282
|
// the new field survives that check instead of only the toEqual above.
|
|
281
283
|
expect(() =>
|
|
@@ -284,7 +286,7 @@ describe("buildAppSchema — content collections", () => {
|
|
|
284
286
|
r.contentCollection({
|
|
285
287
|
id: "prompts",
|
|
286
288
|
kind: "ai-prompt",
|
|
287
|
-
variableSchema: { customerName:
|
|
289
|
+
variableSchema: { customerName: "Max Mustermann" },
|
|
288
290
|
nav: { label: "mail2:nav.prompts" },
|
|
289
291
|
});
|
|
290
292
|
}),
|
|
@@ -63,6 +63,34 @@ const workerWriteFeature = defineFeature("workerWrite", (r) => {
|
|
|
63
63
|
});
|
|
64
64
|
});
|
|
65
65
|
|
|
66
|
+
// The job-runner is built BEFORE the server, so it used to capture the raw
|
|
67
|
+
// caller context — without the per-tenant file-provider resolver buildServer
|
|
68
|
+
// wires onto it. An event-triggered job reaching for ctx.files then died in
|
|
69
|
+
// the worker while the identical code worked on the request path.
|
|
70
|
+
const jobSawFileResolver: string[] = [];
|
|
71
|
+
|
|
72
|
+
const fileJobFeature = defineFeature("fileJob", (r) => {
|
|
73
|
+
const requested = r.defineEvent("bytes-requested", z.object({ storageKey: z.string() }), {
|
|
74
|
+
version: 1,
|
|
75
|
+
});
|
|
76
|
+
// Stands in for file-foundation, which this package cannot import.
|
|
77
|
+
r.extendsRegistrar("fileProvider", { onRegister: () => undefined });
|
|
78
|
+
r.useExtension("fileProvider", "spy", {
|
|
79
|
+
build: async () => {
|
|
80
|
+
throw new Error("no provider is built in this test — presence of the resolver is the point");
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
r.job(
|
|
84
|
+
"read-bytes",
|
|
85
|
+
{ trigger: { on: requested.name }, runIn: "worker" },
|
|
86
|
+
async (_payload, ctx) => {
|
|
87
|
+
jobSawFileResolver.push(typeof ctx._fileProviderResolver);
|
|
88
|
+
},
|
|
89
|
+
);
|
|
90
|
+
// Worker mode refuses to boot without a consumer to drain.
|
|
91
|
+
r.multiStreamProjection({ name: "noop", apply: { [requested.name]: async () => {} } });
|
|
92
|
+
});
|
|
93
|
+
|
|
66
94
|
async function waitForCondition(check: () => boolean, timeoutMs = 5000): Promise<void> {
|
|
67
95
|
const deadline = Date.now() + timeoutMs;
|
|
68
96
|
while (!check()) {
|
|
@@ -137,6 +165,32 @@ describe("entrypoint factories", () => {
|
|
|
137
165
|
await worker.stop();
|
|
138
166
|
});
|
|
139
167
|
|
|
168
|
+
test("Worker job-context carries the file-provider resolver, not just the request path", async () => {
|
|
169
|
+
const registry = createRegistry([fileJobFeature]);
|
|
170
|
+
const redisUrl = `redis://${testRedis.redis.options.host}:${testRedis.redis.options.port}/${testRedis.redis.options.db}`;
|
|
171
|
+
const worker = createWorkerEntrypoint({
|
|
172
|
+
registry,
|
|
173
|
+
context: { db: testDb.db, redis: testRedis.redis },
|
|
174
|
+
jwtSecret: JWT,
|
|
175
|
+
redisUrl,
|
|
176
|
+
queueNamePrefix: uniquePrefix("split-filejob"),
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
jobSawFileResolver.length = 0;
|
|
180
|
+
await worker.start();
|
|
181
|
+
try {
|
|
182
|
+
await worker.jobRunner.handleEvent(
|
|
183
|
+
"file-job:event:bytes-requested",
|
|
184
|
+
{ storageKey: "some/key.pdf" },
|
|
185
|
+
TestUsers.admin,
|
|
186
|
+
);
|
|
187
|
+
await waitForCondition(() => jobSawFileResolver.length > 0);
|
|
188
|
+
expect(jobSawFileResolver[0]).toBe("function");
|
|
189
|
+
} finally {
|
|
190
|
+
await worker.stop();
|
|
191
|
+
}
|
|
192
|
+
});
|
|
193
|
+
|
|
140
194
|
// An app-wired component running in the worker (analysis service, IMAP
|
|
141
195
|
// supervisor) has to persist its result, and persisting goes through the
|
|
142
196
|
// write-path — JobContext has no write/query. The dispatcher is the only
|
package/src/entrypoint/index.ts
CHANGED
|
@@ -34,7 +34,7 @@ import type { Hono } from "hono";
|
|
|
34
34
|
import type { AuthRoutesConfig } from "../api/auth-routes";
|
|
35
35
|
import type { JwtHelper, JwtKeyring } from "../api/jwt";
|
|
36
36
|
import type { KumikoServer, ServerOptions } from "../api/server";
|
|
37
|
-
import { buildServer } from "../api/server";
|
|
37
|
+
import { buildServer, withFileProviderResolver } from "../api/server";
|
|
38
38
|
import type { SseBroker } from "../api/sse-broker";
|
|
39
39
|
import type { PgClient } from "../db/connection";
|
|
40
40
|
import type { EffectiveFeaturesResolver } from "../engine/tier-resolver-extension";
|
|
@@ -322,6 +322,7 @@ function requireDispatcher(server: KumikoServer, mode: string): EventDispatcher
|
|
|
322
322
|
export function createApiEntrypoint(options: ApiEntrypointOptions): ApiEntrypoint {
|
|
323
323
|
const lifecycle = options.lifecycle ?? createLifecycle({ startReady: true });
|
|
324
324
|
const observability = resolveObservability(options.observability);
|
|
325
|
+
const context = withFileProviderResolver(options.registry, options.context);
|
|
325
326
|
|
|
326
327
|
// Boot-validation (Welle 2.6.c) — fail loud before traffic arrives:
|
|
327
328
|
// (a) Any jobs declared + no jobs-block → command-dispatcher would
|
|
@@ -356,7 +357,7 @@ export function createApiEntrypoint(options: ApiEntrypointOptions): ApiEntrypoin
|
|
|
356
357
|
? buildJobRunnerWithHook(
|
|
357
358
|
options.registry,
|
|
358
359
|
contextWithObservability(
|
|
359
|
-
|
|
360
|
+
context,
|
|
360
361
|
observability,
|
|
361
362
|
options.dispatcherOptions?.effectiveFeatures,
|
|
362
363
|
),
|
|
@@ -374,7 +375,7 @@ export function createApiEntrypoint(options: ApiEntrypointOptions): ApiEntrypoin
|
|
|
374
375
|
// apply anywhere.
|
|
375
376
|
const { runLocal: runLocalDispatcher, ...dispatcherTunables } = options.eventDispatcher ?? {};
|
|
376
377
|
const server = buildApiServer(
|
|
377
|
-
options,
|
|
378
|
+
{ ...options, context },
|
|
378
379
|
lifecycle,
|
|
379
380
|
runLocalDispatcher ? dispatcherTunables : { disabled: true },
|
|
380
381
|
apiJobRunner,
|
|
@@ -411,19 +412,16 @@ export function createApiEntrypoint(options: ApiEntrypointOptions): ApiEntrypoin
|
|
|
411
412
|
export function createWorkerEntrypoint(options: WorkerEntrypointOptions): WorkerEntrypoint {
|
|
412
413
|
const lifecycle = options.lifecycle ?? createLifecycle({ startReady: true });
|
|
413
414
|
const observability = resolveObservability(options.observability);
|
|
415
|
+
const context = withFileProviderResolver(options.registry, options.context);
|
|
414
416
|
const jobRunner = buildJobRunnerWithHook(
|
|
415
417
|
options.registry,
|
|
416
|
-
contextWithObservability(
|
|
417
|
-
options.context,
|
|
418
|
-
observability,
|
|
419
|
-
options.dispatcherOptions?.effectiveFeatures,
|
|
420
|
-
),
|
|
418
|
+
contextWithObservability(context, observability, options.dispatcherOptions?.effectiveFeatures),
|
|
421
419
|
options,
|
|
422
420
|
"worker",
|
|
423
421
|
lifecycle,
|
|
424
422
|
"jobRunner",
|
|
425
423
|
);
|
|
426
|
-
const server = buildWorkerServer(options, lifecycle, jobRunner);
|
|
424
|
+
const server = buildWorkerServer({ ...options, context }, lifecycle, jobRunner);
|
|
427
425
|
const eventDispatcher = requireDispatcher(server, "worker");
|
|
428
426
|
|
|
429
427
|
return {
|
|
@@ -448,8 +446,9 @@ export function createWorkerEntrypoint(options: WorkerEntrypointOptions): Worker
|
|
|
448
446
|
export function createAllInOneEntrypoint(options: AllInOneEntrypointOptions): AllInOneEntrypoint {
|
|
449
447
|
const lifecycle = options.lifecycle ?? createLifecycle({ startReady: true });
|
|
450
448
|
const observability = resolveObservability(options.observability);
|
|
449
|
+
const context = withFileProviderResolver(options.registry, options.context);
|
|
451
450
|
const jobRunnerContext = contextWithObservability(
|
|
452
|
-
|
|
451
|
+
context,
|
|
453
452
|
observability,
|
|
454
453
|
options.dispatcherOptions?.effectiveFeatures,
|
|
455
454
|
);
|
|
@@ -486,7 +485,7 @@ export function createAllInOneEntrypoint(options: AllInOneEntrypointOptions): Al
|
|
|
486
485
|
// the API-mode flag — all-in-one is always local, strip it.
|
|
487
486
|
const { runLocal: _runLocal, ...allInOneDispatcherTunables } = options.eventDispatcher ?? {};
|
|
488
487
|
const server = buildApiServer(
|
|
489
|
-
options,
|
|
488
|
+
{ ...options, context },
|
|
490
489
|
lifecycle,
|
|
491
490
|
allInOneDispatcherTunables,
|
|
492
491
|
workerJobRunner,
|
|
@@ -582,6 +582,9 @@ describe("concurrency: replace", () => {
|
|
|
582
582
|
const queue = new Queue(`${queueNamePrefix}-worker`, {
|
|
583
583
|
connection: { host: testRedis.redis.options.host, port: testRedis.redis.options.port },
|
|
584
584
|
});
|
|
585
|
+
// A post-close 'error' here is otherwise unhandled and bun:test
|
|
586
|
+
// attributes it to whichever test runs next (fw#1805).
|
|
587
|
+
queue.on("error", () => {});
|
|
585
588
|
try {
|
|
586
589
|
const waiting = (await queue.getWaiting()).filter(
|
|
587
590
|
(job) => job.name === "test:job:replace-job",
|
package/src/jobs/job-runner.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
type TenantId,
|
|
14
14
|
} from "../engine/types";
|
|
15
15
|
import { createFileContext } from "../files/file-handle";
|
|
16
|
+
import { createFallbackLogger } from "../logging";
|
|
16
17
|
import type { Logger } from "../logging/types";
|
|
17
18
|
import {
|
|
18
19
|
emitJobQueueDepth,
|
|
@@ -197,6 +198,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
197
198
|
// Use the context's tracer when present (observability-provider injected at
|
|
198
199
|
// boot); otherwise noop so dispatch/handleJob stay zero-cost without config.
|
|
199
200
|
const tracer: Tracer = context.tracer ?? getFallbackTracer();
|
|
201
|
+
const errorLogger = createFallbackLogger("job-runner", context.log);
|
|
200
202
|
|
|
201
203
|
const allJobs = registry.getAllJobs();
|
|
202
204
|
|
|
@@ -218,6 +220,13 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
218
220
|
let sequentialLock: DistributedLock | null = null;
|
|
219
221
|
if (hasSequential) {
|
|
220
222
|
lockRedis = new Redis(redisOpts);
|
|
223
|
+
// Without a listener, a post-close 'error' (e.g. a teardown-race
|
|
224
|
+
// "Connection is closed") is unhandled and crashes the process — in
|
|
225
|
+
// bun:test it gets attributed to whichever test happens to run next
|
|
226
|
+
// (fw#1805).
|
|
227
|
+
lockRedis.on("error", (err) =>
|
|
228
|
+
errorLogger.error("lock redis connection error", { error: err.message }),
|
|
229
|
+
);
|
|
221
230
|
const lockScope = consumerLane ?? "enqueue";
|
|
222
231
|
sequentialLock = createDistributedLock(lockRedis, `${RedisKeys.lock}seq:${lockScope}:`);
|
|
223
232
|
}
|
|
@@ -239,6 +248,13 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
239
248
|
api: new Queue(queueNameFor(queueNamePrefix, "api"), { connection: redisOpts }),
|
|
240
249
|
worker: new Queue(queueNameFor(queueNamePrefix, "worker"), { connection: redisOpts }),
|
|
241
250
|
};
|
|
251
|
+
// Same unhandled-'error'-crash hazard as lockRedis above, just via
|
|
252
|
+
// BullMQ's internal ioredis client (fw#1805).
|
|
253
|
+
for (const queue of Object.values(queues)) {
|
|
254
|
+
queue.on("error", (err) =>
|
|
255
|
+
errorLogger.error("queue redis connection error", { error: err.message }),
|
|
256
|
+
);
|
|
257
|
+
}
|
|
242
258
|
let worker: Worker | null = null;
|
|
243
259
|
let queueDepthTimer: ReturnType<typeof setInterval> | null = null;
|
|
244
260
|
// Forward reference to the runner's own API, exposed on the job-handler ctx
|
|
@@ -488,6 +504,17 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
488
504
|
connection: redisOpts,
|
|
489
505
|
concurrency: 5,
|
|
490
506
|
});
|
|
507
|
+
worker.on("error", (err) =>
|
|
508
|
+
errorLogger.error("worker redis connection error", { error: err.message }),
|
|
509
|
+
);
|
|
510
|
+
// A caller that calls stop() right after start() otherwise races the
|
|
511
|
+
// still-settling blocking connection: it rejects in-flight commands
|
|
512
|
+
// via ioredis's flushQueue() during close(), which isn't a listenable
|
|
513
|
+
// 'error' event — the only fix is to not return until both of the
|
|
514
|
+
// worker's connections (main + blocking) are ready (fw#1805). This
|
|
515
|
+
// mirrors the wait BullMQ already does internally for
|
|
516
|
+
// upsertJobScheduler()/add() below when the lane has a cron/boot job.
|
|
517
|
+
await worker.waitUntilReady();
|
|
491
518
|
|
|
492
519
|
// Only schedule cron + boot for jobs that belong to this lane. Jobs
|
|
493
520
|
// assigned to the other lane get their cron/boot wiring from the
|
package/src/stack/test-stack.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { createSseBroker, type SseBroker } from "../api/sse-broker";
|
|
|
6
6
|
import type { PgClient } from "../db/connection";
|
|
7
7
|
import { extractTableInfo } from "../db/query";
|
|
8
8
|
import { createRegistry } from "../engine/registry";
|
|
9
|
-
import type { FeatureDefinition, JobRunIn, Registry, TenantId } from "../engine/types";
|
|
9
|
+
import type { AppContext, FeatureDefinition, JobRunIn, Registry, TenantId } from "../engine/types";
|
|
10
10
|
import { createArchivedStreamsTable, createEventsTable } from "../event-store";
|
|
11
11
|
import { createJobRunner, type JobRunner } from "../jobs";
|
|
12
12
|
import type { Lifecycle } from "../lifecycle";
|
|
@@ -39,6 +39,12 @@ export type TestStack = {
|
|
|
39
39
|
// Command-dispatcher behind the HTTP routes — for direct system-writes
|
|
40
40
|
// in tests and dev-server extraRoutes (provider-webhook wiring).
|
|
41
41
|
dispatcher: Dispatcher;
|
|
42
|
+
// The AppContext buildServer handed the request path, incl. the fields it
|
|
43
|
+
// wires itself (_fileProviderResolver). A dev-server that starts its own
|
|
44
|
+
// lane job-runners beside this stack must hand them THIS, not a
|
|
45
|
+
// `{ db, registry }` literal — that's the #1232 drift, and it makes an
|
|
46
|
+
// event-triggered job reaching for ctx.files die where the request path works.
|
|
47
|
+
context: AppContext;
|
|
42
48
|
// Present whenever a system consumer (SSE, Search) or
|
|
43
49
|
// r.multiStreamProjection is wired. Tests drain it via runOnce() for
|
|
44
50
|
// deterministic assertion — no timer-induced flakiness.
|
|
@@ -441,6 +447,7 @@ export async function setupTestStack(options: TestStackOptions): Promise<TestSta
|
|
|
441
447
|
observability: server.observability,
|
|
442
448
|
sseBroker,
|
|
443
449
|
dispatcher: server.dispatcher,
|
|
450
|
+
context: server.context,
|
|
444
451
|
...(eventDispatcher ? { eventDispatcher } : {}),
|
|
445
452
|
...(server.lifecycle ? { lifecycle: server.lifecycle } : {}),
|
|
446
453
|
...(jobRunner ? { jobRunner } : {}),
|