@cosmicdrift/kumiko-framework 0.182.0 → 0.182.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.182.0",
3
+ "version": "0.182.1",
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.182.0",
185
+ "@cosmicdrift/kumiko-types": "0.182.1",
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.182.0",
201
+ "@cosmicdrift/kumiko-dispatcher-live": "0.182.1",
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 fileProviderResolver =
340
- options.context._fileProviderResolver ??
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
- ...options.context,
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: {}, orderId: {} },
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({ customerName: {} });
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
@@ -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
- options.context,
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
- options.context,
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,
@@ -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 } : {}),