@cosmicdrift/kumiko-framework 0.197.1 → 0.199.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.
Files changed (47) hide show
  1. package/package.json +3 -3
  2. package/src/api/__tests__/batch.integration.test.ts +1 -1
  3. package/src/api/__tests__/server-boot-guards.test.ts +128 -1
  4. package/src/api/__tests__/sse-route.test.ts +129 -0
  5. package/src/api/auth-routes.ts +21 -6
  6. package/src/api/server.ts +44 -0
  7. package/src/api/sse-route.ts +13 -1
  8. package/src/bun-db/__tests__/sql-expr-brand.test.ts +83 -0
  9. package/src/bun-db/query.ts +5 -1
  10. package/src/db/__tests__/compound-types.test.ts +12 -2
  11. package/src/db/__tests__/event-store-executor-list.integration.test.ts +13 -3
  12. package/src/db/__tests__/event-store-executor-money-rehydrate.integration.test.ts +12 -2
  13. package/src/db/__tests__/money.test.ts +49 -18
  14. package/src/db/__tests__/unchecked-system-db.test.ts +117 -0
  15. package/src/db/dialect.ts +13 -2
  16. package/src/db/event-store-executor-read.ts +12 -1
  17. package/src/db/index.ts +7 -2
  18. package/src/db/money.ts +35 -15
  19. package/src/db/table-builder.ts +7 -1
  20. package/src/db/tenant-db.ts +54 -2
  21. package/src/derivatives/derivatives-context.ts +9 -0
  22. package/src/engine/__tests__/build-app-schema.test.ts +50 -0
  23. package/src/engine/__tests__/nav.test.ts +12 -4
  24. package/src/engine/__tests__/soft-delete-cleanup.test.ts +5 -5
  25. package/src/engine/build-app-schema.ts +6 -0
  26. package/src/engine/build-config-feature-schema.ts +2 -2
  27. package/src/engine/index.ts +2 -1
  28. package/src/engine/registry-facade.ts +6 -0
  29. package/src/engine/registry-ingest.ts +1 -0
  30. package/src/engine/registry-state.ts +2 -0
  31. package/src/engine/types/index.ts +7 -1
  32. package/src/entrypoint/__tests__/entrypoint-attach-dispatcher.integration.test.ts +138 -0
  33. package/src/entrypoint/index.ts +20 -3
  34. package/src/files/__tests__/files.integration.test.ts +16 -0
  35. package/src/files/file-routes.ts +12 -1
  36. package/src/jobs/__tests__/job-systemdb.integration.test.ts +152 -0
  37. package/src/jobs/__tests__/jobs.integration.test.ts +28 -0
  38. package/src/jobs/job-runner.ts +42 -3
  39. package/src/migrations/__tests__/pending-rebuilds.integration.test.ts +1 -0
  40. package/src/pipeline/__tests__/ctx-systemdb.integration.test.ts +67 -0
  41. package/src/pipeline/__tests__/dispatcher.test.ts +4 -4
  42. package/src/pipeline/__tests__/redis-pipeline.integration.test.ts +40 -11
  43. package/src/pipeline/dispatch-batch.ts +2 -2
  44. package/src/pipeline/dispatch-shared.ts +3 -1
  45. package/src/pipeline/idempotency.ts +11 -6
  46. package/src/ui-types/app-schema.ts +10 -0
  47. package/src/ui-types/index.ts +1 -1
@@ -9,7 +9,7 @@ import {
9
9
  softDeleteCleanupJob,
10
10
  softDeleteCleanupSystemJob,
11
11
  } from "../soft-delete-cleanup";
12
- import type { AppContext } from "../types/handlers";
12
+ import type { JobContext } from "../types/handlers";
13
13
 
14
14
  function featureWith(softDelete: boolean | undefined) {
15
15
  return defineFeature("probe-sd", (r) => {
@@ -53,7 +53,7 @@ describe("registry soft-delete auto-wiring", () => {
53
53
 
54
54
  type DeleteCall = { table: unknown; where: Record<string, unknown> };
55
55
 
56
- function makeCtx(opts: { graceDays?: number; calls: DeleteCall[] }): AppContext {
56
+ function makeCtx(opts: { graceDays?: number; calls: DeleteCall[] }): JobContext {
57
57
  // Shaped to satisfy bun-db's tenantDbDelegate() probe so deleteMany() routes
58
58
  // to this recorder instead of trying to extract real table metadata.
59
59
  const fakeDb = {
@@ -96,7 +96,7 @@ function makeCtx(opts: { graceDays?: number; calls: DeleteCall[] }): AppContext
96
96
  ...(opts.graceDays !== undefined && {
97
97
  configResolver: { get: async () => opts.graceDays },
98
98
  }),
99
- } as unknown as AppContext;
99
+ } as unknown as JobContext;
100
100
  }
101
101
 
102
102
  describe("softDeleteCleanupJob handler", () => {
@@ -134,7 +134,7 @@ describe("softDeleteCleanupJob handler", () => {
134
134
  });
135
135
 
136
136
  test("throws when the job context is missing db/registry", async () => {
137
- await expect(softDeleteCleanupJob({}, {} as AppContext)).rejects.toThrow(
137
+ await expect(softDeleteCleanupJob({}, {} as JobContext)).rejects.toThrow(
138
138
  /ctx.db \+ ctx.registry/,
139
139
  );
140
140
  });
@@ -161,7 +161,7 @@ describe("softDeleteCleanupSystemJob handler", () => {
161
161
  });
162
162
 
163
163
  test("throws when the job context is missing db/registry", async () => {
164
- await expect(softDeleteCleanupSystemJob({}, {} as AppContext)).rejects.toThrow(
164
+ await expect(softDeleteCleanupSystemJob({}, {} as JobContext)).rejects.toThrow(
165
165
  /ctx.db \+ ctx.registry/,
166
166
  );
167
167
  });
@@ -40,6 +40,11 @@ export type BuildAppSchemaOptions = {
40
40
  /** Dev-server authoring hints (Settings-Hub placement). Default off — only
41
41
  * `createKumikoServer` opts in; prod boot + unit tests stay silent. */
42
42
  readonly authoringWarnings?: boolean;
43
+ /** Forwarded onto every FeatureSchema.searchAdapterMissing. Set by the boot
44
+ * entrypoint (createKumikoServer, runProdApp) from its own
45
+ * context.searchAdapter presence check — buildAppSchema itself has no
46
+ * context, only the registry. Omit/false when a SearchAdapter is wired. */
47
+ readonly searchAdapterMissing?: boolean;
43
48
  };
44
49
 
45
50
  export function buildAppSchema(registry: Registry, options: BuildAppSchemaOptions = {}): AppSchema {
@@ -67,6 +72,7 @@ export function buildAppSchema(registry: Registry, options: BuildAppSchemaOption
67
72
  ...(Object.keys(feature.translations ?? {}).length > 0 && {
68
73
  translations: feature.translations,
69
74
  }),
75
+ ...(options.searchAdapterMissing === true && { searchAdapterMissing: true }),
70
76
  };
71
77
  features.push(featureSchema);
72
78
  }
@@ -28,7 +28,7 @@ import type { ConfigKeyDefinition } from "./types/config";
28
28
  import type { Registry } from "./types/feature";
29
29
  import type { FieldDefinition } from "./types/fields";
30
30
  import type { AccessRule } from "./types/handlers";
31
- import type { NavDefinition } from "./types/nav";
31
+ import type { NavDefinition, NavIconKey } from "./types/nav";
32
32
  import type {
33
33
  ConfigEditScreenDefinition,
34
34
  EditFieldsSection,
@@ -57,7 +57,7 @@ export type ConfigFeatureSchema = {
57
57
 
58
58
  // Audience-Reihenfolge im Sidebar: Plattform vor Tenant vor Benutzer.
59
59
  const SCOPE_ORDER: Record<ConfigScope, number> = { system: 10, tenant: 20, user: 30 };
60
- const SCOPE_ICON: Record<ConfigScope, string> = {
60
+ const SCOPE_ICON: Record<ConfigScope, NavIconKey> = {
61
61
  system: "shield",
62
62
  tenant: "building",
63
63
  user: "user",
@@ -353,6 +353,7 @@ export type {
353
353
  MultiStreamProjectionDefinition,
354
354
  NameOrRef,
355
355
  NavDefinition,
356
+ NavIconKey,
356
357
  NotificationDataFn,
357
358
  NotificationDefinition,
358
359
  NotificationRecipientFn,
@@ -412,7 +413,7 @@ export type {
412
413
  WriteResult,
413
414
  } from "./types";
414
415
  export { DEFAULT_CURRENCIES, HookPhases } from "./types";
415
- export { isSystemTenant, parseTenantId, SYSTEM_TENANT_ID } from "./types/identifiers";
416
+ export { isSystemTenant, isUuid, parseTenantId, SYSTEM_TENANT_ID } from "./types/identifiers";
416
417
  export type {
417
418
  PipelineBuildCtx,
418
419
  PipelineCtx,
@@ -203,6 +203,12 @@ export function buildRegistryFacade(state: RegistryState): Registry {
203
203
  return state.featureMap.get(featureName)?.systemScope ?? false;
204
204
  },
205
205
 
206
+ isJobSystemScoped(qualifiedJobName: string): boolean {
207
+ const featureName = state.jobFeatureMap.get(qualifiedJobName);
208
+ if (!featureName) return false;
209
+ return state.featureMap.get(featureName)?.systemScope ?? false;
210
+ },
211
+
206
212
  getHandlerFeature(qualifiedHandler: string): string | undefined {
207
213
  return state.handlerFeatureMap.get(qualifiedHandler);
208
214
  },
@@ -129,6 +129,7 @@ export function populateJobsAndNotifications(
129
129
  );
130
130
  }
131
131
  state.jobMap.set(qualifiedName, { ...jobDef, name: qualifiedName });
132
+ state.jobFeatureMap.set(qualifiedName, feature.name);
132
133
  }
133
134
 
134
135
  // Notifications: scope:notify:name
@@ -180,6 +180,7 @@ export type RegistryState = {
180
180
  searchPayloadExtensions: Map<string, OwnedFn<SearchPayloadContributorFn>[]>;
181
181
  configKeyMap: Map<string, ConfigKeyDefinition>;
182
182
  jobMap: Map<string, JobDefinition>;
183
+ jobFeatureMap: Map<string, string>;
183
184
  notificationMap: Map<string, NotificationDefinition>;
184
185
  notificationFeatureMap: Map<string, string>;
185
186
  eventMap: Map<string, EventDef>;
@@ -248,6 +249,7 @@ export function createInitialState(): RegistryState {
248
249
  searchPayloadExtensions: new Map(),
249
250
  configKeyMap: new Map(),
250
251
  jobMap: new Map(),
252
+ jobFeatureMap: new Map(),
251
253
  notificationMap: new Map(),
252
254
  notificationFeatureMap: new Map(),
253
255
  eventMap: new Map(),
@@ -138,6 +138,7 @@ export type {
138
138
  ClaimKeyJsType,
139
139
  ClaimKeyType,
140
140
  DeclarativeEventMigration,
141
+ DispatchWriteRef,
141
142
  EntityRef,
142
143
  EventDef,
143
144
  EventMigrationDef,
@@ -204,10 +205,15 @@ export type {
204
205
  export type { EntityId, TenantId } from "@cosmicdrift/kumiko-types/identifiers";
205
206
  export {
206
207
  isSystemTenant,
208
+ isUuid,
207
209
  parseTenantId,
208
210
  SYSTEM_TENANT_ID,
209
211
  } from "@cosmicdrift/kumiko-types/identifiers";
210
- export type { ContentCollectionDefinition, NavDefinition } from "@cosmicdrift/kumiko-types/nav";
212
+ export type {
213
+ ContentCollectionDefinition,
214
+ NavDefinition,
215
+ NavIconKey,
216
+ } from "@cosmicdrift/kumiko-types/nav";
211
217
  export type {
212
218
  FromRule,
213
219
  FromRuleKind,
@@ -0,0 +1,138 @@
1
+ // Regression test for framework#2044 — attachDispatcher() wiring.
2
+ //
3
+ // #2043 added JobContext.write/queryAs plus JobRunner.attachDispatcher(), but
4
+ // left the entrypoint factories unwired: nothing ever called
5
+ // attachDispatcher() on the JobRunners they build, so ctx.write inside a job
6
+ // always hit the throwing stub in production too. This test proves the
7
+ // wiring closes that gap (a job's ctx.write actually commits when run
8
+ // through a real entrypoint) and that the gap is real (the same job, run
9
+ // against a bare createJobRunner() with no attachDispatcher() call, still
10
+ // throws the #2043 stub).
11
+
12
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
13
+ import { z } from "zod";
14
+ import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
15
+ import { asRawClient } from "../../db/query";
16
+ import { createRegistry, defineFeature } from "../../engine";
17
+ import { createArchivedStreamsTable, createEventsTable } from "../../event-store";
18
+ import { createJobRunner } from "../../jobs/job-runner";
19
+ import { createEventConsumerStateTable } from "../../pipeline";
20
+ import { createTestRedis, type TestRedis } from "../../stack";
21
+ import { waitFor } from "../../testing";
22
+ import { createWorkerEntrypoint } from "../index";
23
+
24
+ const writeProbeResults: Array<{ isSuccess: boolean }> = [];
25
+ const writeProbeFailures: string[] = [];
26
+
27
+ const writeProbeFeature = defineFeature("writeProbe", (r) => {
28
+ const noted = r.defineEvent("noted", z.object({ note: z.string() }), { version: 1 });
29
+ r.writeHandler(
30
+ "note",
31
+ z.object({ note: z.string() }),
32
+ async (event, ctx) => {
33
+ await ctx.unsafeAppendEvent({
34
+ aggregateId: crypto.randomUUID(),
35
+ aggregateType: "write-probe-note",
36
+ type: noted.name,
37
+ payload: { note: event.payload.note },
38
+ });
39
+ return { isSuccess: true as const, data: { note: event.payload.note } };
40
+ },
41
+ { access: { openToAll: true } },
42
+ );
43
+ r.job("write-via-job", { trigger: { manual: true }, retries: 0 }, async (payload, ctx) => {
44
+ try {
45
+ const result = await ctx.write("write-probe:write:note", {
46
+ note: payload["note"] as string,
47
+ });
48
+ writeProbeResults.push({ isSuccess: result.isSuccess });
49
+ } catch (error) {
50
+ writeProbeFailures.push(error instanceof Error ? error.message : String(error));
51
+ throw error;
52
+ }
53
+ });
54
+ });
55
+
56
+ const JWT = "attach-dispatcher-test-secret-must-be-32-chars!";
57
+
58
+ let testDb: BunTestDb;
59
+ let testRedis: TestRedis;
60
+
61
+ beforeAll(async () => {
62
+ [testDb, testRedis] = await Promise.all([createTestDb(), createTestRedis()]);
63
+ await createEventsTable(testDb.db);
64
+ await createArchivedStreamsTable(testDb.db);
65
+ await createEventConsumerStateTable(testDb.db);
66
+ });
67
+
68
+ afterAll(async () => {
69
+ await Promise.all([testDb.cleanup(), testRedis.cleanup()]);
70
+ });
71
+
72
+ function uniquePrefix(label: string): string {
73
+ return `${label}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
74
+ }
75
+
76
+ describe("createWorkerEntrypoint auto-wires attachDispatcher() (framework#2044)", () => {
77
+ test("ctx.write inside a job commits end-to-end through a real entrypoint", async () => {
78
+ writeProbeResults.length = 0;
79
+ const registry = createRegistry([writeProbeFeature]);
80
+ const redisUrl = `redis://${testRedis.redis.options.host}:${testRedis.redis.options.port}/${testRedis.redis.options.db}`;
81
+ const worker = createWorkerEntrypoint({
82
+ registry,
83
+ context: { db: testDb.db, redis: testRedis.redis },
84
+ jwtSecret: JWT,
85
+ redisUrl,
86
+ queueNamePrefix: uniquePrefix("attach-dispatcher"),
87
+ });
88
+
89
+ await worker.start();
90
+ try {
91
+ await worker.jobRunner.dispatch("write-probe:job:write-via-job", {
92
+ note: "written from the job",
93
+ });
94
+
95
+ await waitFor(() => {
96
+ expect(writeProbeResults.length).toBe(1);
97
+ expect(writeProbeResults[0]?.isSuccess).toBe(true);
98
+ });
99
+
100
+ const rows = await asRawClient(testDb.db).unsafe(
101
+ `SELECT payload FROM kumiko_events WHERE type = 'write-probe:event:noted'`,
102
+ );
103
+ expect(rows).toHaveLength(1);
104
+ expect((rows[0] as { payload: { note: string } }).payload.note).toBe("written from the job");
105
+ } finally {
106
+ await worker.stop();
107
+ }
108
+ });
109
+ });
110
+
111
+ describe("createJobRunner without attachDispatcher() still hits the #2043 stub", () => {
112
+ test("ctx.write throws — proves the entrypoint's attachDispatcher() call is the thing that makes writes work", async () => {
113
+ writeProbeFailures.length = 0;
114
+ const registry = createRegistry([writeProbeFeature]);
115
+ const redisUrl = `redis://${testRedis.redis.options.host}:${testRedis.redis.options.port}/${testRedis.redis.options.db}`;
116
+ const runner = createJobRunner({
117
+ registry,
118
+ context: { db: testDb.db, redis: testRedis.redis },
119
+ redisUrl,
120
+ consumerLane: "worker",
121
+ queueNamePrefix: uniquePrefix("attach-dispatcher-bare"),
122
+ });
123
+
124
+ await runner.start();
125
+ try {
126
+ await runner.dispatch("write-probe:job:write-via-job", { note: "should never land" });
127
+
128
+ await waitFor(() => {
129
+ expect(writeProbeFailures.length).toBe(1);
130
+ });
131
+ expect(writeProbeFailures[0]).toContain(
132
+ "JobContext.write called before dispatcher attached — call attachDispatcher() first",
133
+ );
134
+ } finally {
135
+ await runner.stop();
136
+ }
137
+ });
138
+ });
@@ -38,7 +38,7 @@ 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";
41
- import type { AppContext, JobRunIn, Registry, RunIn } from "../engine/types";
41
+ import type { AppContext, DispatchWriteRef, JobRunIn, Registry, RunIn } from "../engine/types";
42
42
  import type { JobRunner, JobRunnerOptions } from "../jobs/job-runner";
43
43
  import { createJobRunner } from "../jobs/job-runner";
44
44
  import type { Lifecycle } from "../lifecycle";
@@ -142,8 +142,9 @@ export type WorkerEntrypoint = {
142
142
  readonly eventDispatcher: EventDispatcher;
143
143
  readonly jobRunner: JobRunner;
144
144
  readonly observability: ObservabilityProvider;
145
- // Same dispatcher the API process exposes. Background components in the
146
- // worker persist through the write-path JobContext has no write/query.
145
+ // Same dispatcher the API process exposes. App-wired background
146
+ // components that need the dispatcher directly (not JobContext.write)
147
+ // still persist through it.
147
148
  readonly dispatcher: Dispatcher;
148
149
  readonly mode: "worker";
149
150
  // Starts event-dispatcher poll + BullMQ worker. SIGTERM triggers
@@ -205,6 +206,18 @@ function contextWithObservability(
205
206
  };
206
207
  }
207
208
 
209
+ // Adapts the command-dispatcher's positional (type, payload, user) calls to
210
+ // DispatchWriteRef's (user, qn, payload) shape — JobContext.write/queryAs
211
+ // pass identity explicitly per call (boot-time singleton), while Dispatcher
212
+ // takes it last (request-scoped closure caller). Same underlying pipeline,
213
+ // different argument order.
214
+ function dispatcherToWriteRef(dispatcher: Dispatcher): DispatchWriteRef {
215
+ return {
216
+ write: (user, qn, payload) => dispatcher.write(qn, payload, user),
217
+ queryAs: (user, qn, payload) => dispatcher.query(qn, payload, user),
218
+ };
219
+ }
220
+
208
221
  // buildApiServer shapes ServerOptions from API-mode caller-options.
209
222
  // AllInOneEntrypointOptions extends ApiEntrypointOptions, so structural
210
223
  // subtyping makes the all-in-one path a valid caller without an explicit
@@ -381,6 +394,7 @@ export function createApiEntrypoint(options: ApiEntrypointOptions): ApiEntrypoin
381
394
  apiJobRunner,
382
395
  runLocalDispatcher ? "both" : "api",
383
396
  );
397
+ apiJobRunner?.attachDispatcher(dispatcherToWriteRef(server.dispatcher));
384
398
 
385
399
  return {
386
400
  app: server.app,
@@ -422,6 +436,7 @@ export function createWorkerEntrypoint(options: WorkerEntrypointOptions): Worker
422
436
  "jobRunner",
423
437
  );
424
438
  const server = buildWorkerServer({ ...options, context }, lifecycle, jobRunner);
439
+ jobRunner.attachDispatcher(dispatcherToWriteRef(server.dispatcher));
425
440
  const eventDispatcher = requireDispatcher(server, "worker");
426
441
 
427
442
  return {
@@ -491,6 +506,8 @@ export function createAllInOneEntrypoint(options: AllInOneEntrypointOptions): Al
491
506
  workerJobRunner,
492
507
  "both",
493
508
  );
509
+ workerJobRunner.attachDispatcher(dispatcherToWriteRef(server.dispatcher));
510
+ apiJobRunner.attachDispatcher(dispatcherToWriteRef(server.dispatcher));
494
511
  const eventDispatcher = requireDispatcher(server, "all-in-one");
495
512
 
496
513
  return {
@@ -581,6 +581,22 @@ describe("error handling", () => {
581
581
  expect(res.status).toBe(404);
582
582
  });
583
583
 
584
+ // #1950: a malformed (non-UUID) id used to reach `selectMany` unchecked —
585
+ // Postgres rejects it with 22P02, which the pooled Bun.SQL connection
586
+ // treated as poisoned (see the NONEXISTENT_UUID comment above, added as a
587
+ // workaround before this guard existed). 404, same as a real-but-absent
588
+ // id, not the raw DB error.
589
+ test("download with a malformed (non-UUID) id returns 404, not a DB error", async () => {
590
+ const res = await getFile(adminUser, "not-a-uuid");
591
+ expect(res.status).toBe(404);
592
+ });
593
+
594
+ test("a malformed id doesn't poison the pooled connection for the next request", async () => {
595
+ await getFile(adminUser, "not-a-uuid");
596
+ const res = await getFile(adminUser, NONEXISTENT_UUID);
597
+ expect(res.status).toBe(404);
598
+ });
599
+
584
600
  test("upload wrong file type for entity field is rejected", async () => {
585
601
  const pdfContent = new TextEncoder().encode("fake-pdf-content");
586
602
  const res = await uploadFile(adminUser, "document.pdf", pdfContent, "application/pdf", {
@@ -5,7 +5,13 @@ import type { DbConnection } from "../db/connection";
5
5
  import { createEventStoreExecutor } from "../db/event-store-executor";
6
6
  import { createTenantDb } from "../db/tenant-db";
7
7
  import { createDerivativesContext, resolveFieldVariant, resolveRenderer } from "../derivatives";
8
- import { isFileField, type Registry, type SessionUser, type TenantId } from "../engine/types";
8
+ import {
9
+ isFileField,
10
+ isUuid,
11
+ type Registry,
12
+ type SessionUser,
13
+ type TenantId,
14
+ } from "../engine/types";
9
15
  import { generateId } from "../utils";
10
16
  import { buildContentDispositionHeader } from "./content-disposition";
11
17
  import { createFileContext } from "./file-handle";
@@ -396,6 +402,11 @@ export function createFileRoutes(options: FileRoutesOptions): Hono {
396
402
  });
397
403
 
398
404
  async function loadFileForTenant(id: string, tenantId: TenantId): Promise<FileRef | null> {
405
+ // fileRefsTable.id is a UUID column — a malformed id must fail here, not
406
+ // at Postgres (22P02 poisons the pooled Bun.SQL connection). 404, not
407
+ // 400: an authenticated caller sending a bad id must see the same
408
+ // response as a nonexistent one, no existence-oracle.
409
+ if (!isUuid(id)) return null;
399
410
  // isDeleted:false — soft-deleted (trashed) rows stay recoverable but must
400
411
  // never surface to reads/guards.
401
412
  const [row] = await selectMany(db, fileRefsTable, { id, tenantId, isDeleted: false });
@@ -0,0 +1,152 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
3
+ import { createRegistry, defineFeature, type Registry } from "../../engine";
4
+ import { createTestRedis, type TestRedis, testTenantId } from "../../stack";
5
+ import { waitFor } from "../../testing";
6
+ import { createJobRunner, type JobRunner } from "../job-runner";
7
+
8
+ // r.systemScope() is feature-level (define-feature.ts), not per-job — so two
9
+ // features prove both sides, mirroring pipeline/__tests__/ctx-systemdb.integration.test.ts
10
+ // (the handler-dispatch counterpart from framework#2069/PR#2091).
11
+
12
+ type JobRunResult = {
13
+ readonly name: "system" | "tenant" | "system-per-tenant";
14
+ readonly present: boolean;
15
+ // assertTenantMatch() must return a TenantDb whose `.raw` is the SAME
16
+ // underlying DbConnection ctx.db carries — proves systemDb is bound to
17
+ // the job's own tenant-scoped db, not a separate instance.
18
+ readonly boundToRawDb: boolean | undefined;
19
+ // A foreign tenantId must throw fail-closed (AccessDeniedError), same as
20
+ // the HandlerContext.systemDb self-check.
21
+ readonly foreignTenantThrew: boolean | undefined;
22
+ };
23
+
24
+ const results: JobRunResult[] = [];
25
+ const ownTenant = testTenantId(1);
26
+ const foreignTenant = testTenantId(2);
27
+
28
+ const systemScopedFeature = defineFeature("jobsystemdb-system", (r) => {
29
+ r.systemScope();
30
+
31
+ r.job("check", { trigger: { manual: true } }, async (_payload, ctx) => {
32
+ if (!ctx.systemDb) {
33
+ results.push({
34
+ name: "system",
35
+ present: false,
36
+ boundToRawDb: undefined,
37
+ foreignTenantThrew: undefined,
38
+ });
39
+ return;
40
+ }
41
+ const checked = ctx.systemDb.assertTenantMatch(ctx.systemUser.tenantId);
42
+ let foreignTenantThrew = false;
43
+ try {
44
+ ctx.systemDb.assertTenantMatch(foreignTenant);
45
+ } catch {
46
+ foreignTenantThrew = true;
47
+ }
48
+ results.push({
49
+ name: "system",
50
+ present: true,
51
+ boundToRawDb: checked.raw === ctx.db,
52
+ foreignTenantThrew,
53
+ });
54
+ });
55
+
56
+ // perTenant jobs go through a separate dispatch path (_perTenant: wrapper
57
+ // fans out into one child job per tenant, job-runner.ts ~331-352) that
58
+ // re-enqueues under the bare qualified name before handleJob rebuilds the
59
+ // context — proves isJobSystemScoped() sees the same jobName fan-out
60
+ // children get, not the "_perTenant:"-prefixed wrapper name.
61
+ r.job(
62
+ "check-per-tenant",
63
+ { trigger: { manual: true }, perTenant: true },
64
+ async (_payload, ctx) => {
65
+ results.push({
66
+ name: "system-per-tenant",
67
+ present: ctx.systemDb !== undefined,
68
+ boundToRawDb: undefined,
69
+ foreignTenantThrew: undefined,
70
+ });
71
+ },
72
+ );
73
+ });
74
+
75
+ const tenantScopedFeature = defineFeature("jobsystemdb-tenant", (r) => {
76
+ r.job("check", { trigger: { manual: true } }, async (_payload, ctx) => {
77
+ results.push({
78
+ name: "tenant",
79
+ present: ctx.systemDb !== undefined,
80
+ boundToRawDb: undefined,
81
+ foreignTenantThrew: undefined,
82
+ });
83
+ });
84
+ });
85
+
86
+ let testDb: BunTestDb;
87
+ let testRedis: TestRedis;
88
+ let registry: Registry;
89
+ let jobRunner: JobRunner;
90
+
91
+ beforeAll(async () => {
92
+ testDb = await createTestDb();
93
+ testRedis = await createTestRedis();
94
+
95
+ registry = createRegistry([systemScopedFeature, tenantScopedFeature]);
96
+
97
+ const redisUrl = `redis://${testRedis.redis.options.host}:${testRedis.redis.options.port}/${testRedis.redis.options.db}`;
98
+
99
+ jobRunner = createJobRunner({
100
+ registry,
101
+ context: { db: testDb.db },
102
+ redisUrl,
103
+ consumerLane: "worker",
104
+ queueNamePrefix: `kumiko-job-systemdb-test-${Date.now()}`,
105
+ getActiveTenantIds: async () => [ownTenant],
106
+ });
107
+
108
+ await jobRunner.start();
109
+ });
110
+
111
+ afterAll(async () => {
112
+ await jobRunner.stop();
113
+ await testDb.cleanup();
114
+ await testRedis.cleanup();
115
+ });
116
+
117
+ describe("JobContext.systemDb", () => {
118
+ test("is present, tenant-bound and fail-closed for r.systemScope() jobs", async () => {
119
+ results.length = 0;
120
+ await jobRunner.dispatch("jobsystemdb-system:job:check", { tenantId: ownTenant });
121
+
122
+ await waitFor(() => {
123
+ const result = results.find((r) => r.name === "system");
124
+ expect(result).toBeDefined();
125
+ expect(result?.present).toBe(true);
126
+ expect(result?.boundToRawDb).toBe(true);
127
+ expect(result?.foreignTenantThrew).toBe(true);
128
+ });
129
+ });
130
+
131
+ test("is absent for non-system-scoped jobs", async () => {
132
+ results.length = 0;
133
+ await jobRunner.dispatch("jobsystemdb-tenant:job:check", { tenantId: ownTenant });
134
+
135
+ await waitFor(() => {
136
+ const result = results.find((r) => r.name === "tenant");
137
+ expect(result).toBeDefined();
138
+ expect(result?.present).toBe(false);
139
+ });
140
+ });
141
+
142
+ test("is present on the per-tenant fan-out child, not just the direct dispatch path", async () => {
143
+ results.length = 0;
144
+ await jobRunner.dispatch("jobsystemdb-system:job:check-per-tenant", {});
145
+
146
+ await waitFor(() => {
147
+ const result = results.find((r) => r.name === "system-per-tenant");
148
+ expect(result).toBeDefined();
149
+ expect(result?.present).toBe(true);
150
+ });
151
+ });
152
+ });
@@ -201,6 +201,13 @@ const testFeature = defineFeature("test", (r) => {
201
201
  const value = await ctx.config!("test:config:probe-key");
202
202
  jobLog.push({ name: "test:job:config-probe", payload: { value }, timestamp: Date.now() });
203
203
  });
204
+
205
+ // ctx.write/queryAs throw until JobRunner.attachDispatcher() has run —
206
+ // regression guard for framework#2043. No test runner here ever calls
207
+ // attachDispatcher(), so this always hits the stub.
208
+ r.job("writeProbe", { trigger: { manual: true }, retries: 0 }, async (_payload, ctx) => {
209
+ await ctx.write("test:write:probe", {});
210
+ });
204
211
  });
205
212
 
206
213
  beforeAll(async () => {
@@ -803,6 +810,27 @@ describe("error handling", () => {
803
810
  );
804
811
  });
805
812
 
813
+ test("ctx.write throws before attachDispatcher() has run (framework#2043)", async () => {
814
+ clearLog();
815
+ const failures: string[] = [];
816
+ await withRunner(
817
+ async (runner) => {
818
+ await runner.dispatch("test:job:write-probe");
819
+ await waitFor(() => {
820
+ expect(failures.length).toBeGreaterThanOrEqual(1);
821
+ });
822
+ expect(failures[0]).toContain(
823
+ "JobContext.write called before dispatcher attached — call attachDispatcher() first",
824
+ );
825
+ },
826
+ {
827
+ onJobFailed: (jobName, _id, error) => {
828
+ if (jobName === "test:job:write-probe") failures.push(error);
829
+ },
830
+ },
831
+ );
832
+ });
833
+
806
834
  test("perTenant without getActiveTenantIds fails wrapper; worker stays alive", async () => {
807
835
  clearLog();
808
836
  await withRunner(async (runner) => {