@cosmicdrift/kumiko-framework 0.273.0 → 0.275.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 (30) hide show
  1. package/package.json +3 -3
  2. package/src/changes.json +28 -7
  3. package/src/db/__tests__/build-filter-where.test.ts +8 -0
  4. package/src/db/__tests__/event-store-executor-list.integration.test.ts +69 -0
  5. package/src/db/__tests__/event-store-executor-reference-search.integration.test.ts +266 -2
  6. package/src/db/entity-field-encryption.ts +18 -0
  7. package/src/db/event-store-executor-context.ts +7 -3
  8. package/src/db/event-store-executor-read.ts +120 -34
  9. package/src/engine/__tests__/boot-validator.test.ts +20 -1
  10. package/src/engine/__tests__/dedupe-features.test.ts +85 -0
  11. package/src/engine/__tests__/engine.test.ts +86 -0
  12. package/src/engine/__tests__/screen-filter-ops.test.ts +1 -1
  13. package/src/engine/__tests__/soft-delete-cleanup-job-tenant-db.integration.test.ts +82 -0
  14. package/src/engine/boot-validator/index.ts +3 -1
  15. package/src/engine/boot-validator/projection-list-screens.ts +2 -2
  16. package/src/engine/create-app.ts +6 -4
  17. package/src/engine/dedupe-features.ts +46 -0
  18. package/src/engine/define-feature.ts +6 -0
  19. package/src/engine/entity-handlers.ts +3 -3
  20. package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +2 -2
  21. package/src/engine/feature-config-events-jobs.ts +11 -2
  22. package/src/engine/index.ts +1 -0
  23. package/src/engine/registry-validate.ts +24 -2
  24. package/src/engine/registry.ts +3 -1
  25. package/src/engine/screen-filter-ops.ts +10 -2
  26. package/src/engine/soft-delete-cleanup.ts +4 -3
  27. package/src/jobs/__tests__/job-systemdb.integration.test.ts +1 -1
  28. package/src/jobs/__tests__/job-tenant-db.integration.test.ts +162 -0
  29. package/src/jobs/__tests__/jobs.integration.test.ts +3 -2
  30. package/src/jobs/job-runner.ts +18 -15
@@ -29,9 +29,14 @@ function resolveFeatureNamesArgs(
29
29
  return unwrapArrayForm(args, "features");
30
30
  }
31
31
 
32
+ export type DefineFeatureSettings = {
33
+ readonly dedupeOptions?: Readonly<Record<string, unknown>>;
34
+ };
35
+
32
36
  export function defineFeature<const TName extends string, TExports = undefined>(
33
37
  name: TName,
34
38
  setup: (r: FeatureRegistrar<TName>) => TExports,
39
+ settings?: DefineFeatureSettings,
35
40
  ): FeatureDefinition & { readonly exports: TExports } {
36
41
  const state = createInitialFeatureBuilderState();
37
42
 
@@ -158,5 +163,6 @@ export function defineFeature<const TName extends string, TExports = undefined>(
158
163
  storeTables: state.storeTables,
159
164
  ...(state.treeActions !== undefined && { treeActions: state.treeActions }),
160
165
  ...(state.envSchema !== undefined && { envSchema: state.envSchema }),
166
+ ...(settings?.dedupeOptions !== undefined && { dedupeOptions: settings.dedupeOptions }),
161
167
  };
162
168
  }
@@ -102,7 +102,7 @@ type ListPayload = {
102
102
  // den gleichen Query-Handler.
103
103
  filter?: {
104
104
  readonly field: string;
105
- readonly op: "eq" | "ne" | "lt" | "gt" | "in";
105
+ readonly op: "eq" | "ne" | "lt" | "gt" | "lte" | "gte" | "in";
106
106
  readonly value: unknown;
107
107
  };
108
108
  };
@@ -157,7 +157,7 @@ export const entityListSchema = z.object({
157
157
  filter: z
158
158
  .object({
159
159
  field: z.string(),
160
- op: z.enum(["eq", "ne", "lt", "gt", "in"]),
160
+ op: z.enum(["eq", "ne", "lt", "gt", "lte", "gte", "in"]),
161
161
  // Value ist `unknown` zur Compile-Zeit; Server-Side prüft beim
162
162
  // Build der WHERE-Clause ob der Type zum Field passt. z.unknown()
163
163
  // lässt alles durch; Type-Check kommt im executor.list.
@@ -170,7 +170,7 @@ export const entityListSchema = z.object({
170
170
  .array(
171
171
  z.object({
172
172
  field: z.string(),
173
- op: z.enum(["eq", "ne", "lt", "gt", "in"]),
173
+ op: z.enum(["eq", "ne", "lt", "gt", "lte", "gte", "in"]),
174
174
  value: z.unknown(),
175
175
  }),
176
176
  )
@@ -68,8 +68,8 @@ const FEATURES: readonly RealFeature[] = [
68
68
  {
69
69
  path: "packages/bundled-features/src/sessions/feature.ts",
70
70
  expectedFeatureName: "sessions",
71
- recognisedKinds: ["hook", "writeHandler", "queryHandler"],
72
- errorMethodNames: ["storeTable", "job"],
71
+ recognisedKinds: ["hook", "writeHandler", "queryHandler", "job"],
72
+ errorMethodNames: ["storeTable"],
73
73
  },
74
74
  {
75
75
  path: "packages/bundled-features/src/auth-email-password/feature.ts",
@@ -181,9 +181,18 @@ export function buildConfigEventsJobsMethods<TName extends string>(
181
181
  handler as JobHandlerFn,
182
182
  ]
183
183
  : (() => {
184
- const { name, handler: h, ...rest } = jobNameOrDefinition;
185
- return [name, rest, h] as const;
184
+ const { name: definedJobName, handler: h, ...rest } = jobNameOrDefinition;
185
+ return [definedJobName, rest, h] as const;
186
186
  })();
187
+ if (
188
+ jobOptions.escapeHatch !== undefined &&
189
+ jobOptions.escapeHatch.reason.trim().length === 0
190
+ ) {
191
+ throw new Error(
192
+ `[Feature ${name}] r.job("${jobName}") declares { escapeHatch: { reason: "" } } — ` +
193
+ "the reason must be a non-empty string explaining why this job needs unfiltered database access.",
194
+ );
195
+ }
187
196
  // Resolve NameOrRef(s) in trigger.on. Multi-Trigger-Form: Array
188
197
  // wird zu Array von resolved strings, Single bleibt single string —
189
198
  // job-runner unterscheidet anhand Array.isArray.
@@ -49,6 +49,7 @@ export {
49
49
  export type { App, AppConfig } from "./create-app";
50
50
  export { createApp } from "./create-app";
51
51
  export { crossTenantOverrideDenied } from "./cross-tenant";
52
+ export { dedupeFeatures } from "./dedupe-features";
52
53
  export { defineFeature } from "./define-feature";
53
54
  export type {
54
55
  PagedQueryHandlerDefinition,
@@ -1,4 +1,5 @@
1
1
  import { configureEventPiiCatalog } from "../crypto/event-pii";
2
+ import { hasSearchablePlaintext, isSensitiveLabelField } from "../db/entity-field-encryption";
2
3
  import { bindHookEscapeHatchGrant } from "../pipeline/system-identity-switch";
3
4
  import { resolveName } from "./handler-helpers";
4
5
  import type {
@@ -16,6 +17,7 @@ import {
16
17
  softDeleteGraceDaysConfig,
17
18
  } from "./soft-delete-cleanup";
18
19
  import type {
20
+ EntityDefinition,
19
21
  EventPiiFields,
20
22
  EventUpcastFn,
21
23
  FeatureDefinition,
@@ -206,6 +208,7 @@ function buildSearchableReferenceField(
206
208
  entityName: string,
207
209
  fieldName: string,
208
210
  field: ReferenceFieldDef,
211
+ entityMap: ReadonlyMap<string, EntityDefinition>,
209
212
  ): SearchableReferenceField {
210
213
  if (field.labelField === undefined || field.labelField === "id") {
211
214
  throw new Error(
@@ -220,9 +223,26 @@ function buildSearchableReferenceField(
220
223
  `(array) reference fields — remove "multiple" or "searchable".`,
221
224
  );
222
225
  }
226
+ const targetEntityName = parseReferenceTargetEntityName(field.entity);
227
+ const targetEntity = entityMap.get(targetEntityName);
228
+ if (targetEntity !== undefined) {
229
+ if (
230
+ isSensitiveLabelField(targetEntity, field.labelField) &&
231
+ !hasSearchablePlaintext(targetEntity, field.labelField)
232
+ ) {
233
+ throw new Error(
234
+ `[Entity ${entityName}] field "${fieldName}": searchable reference targets ` +
235
+ `"${targetEntityName}.${field.labelField}", which is encrypted/PII and not itself ` +
236
+ `searchable — there is no plaintext anywhere to match a search term against. Mark ` +
237
+ `"${targetEntityName}.${field.labelField}" searchable: true (add find: "fuzzy" if it ` +
238
+ `also carries a personal annotation, so it lands in the derived search index) or ` +
239
+ `remove searchable from "${fieldName}".`,
240
+ );
241
+ }
242
+ }
223
243
  return {
224
244
  fieldName,
225
- targetEntityName: parseReferenceTargetEntityName(field.entity),
245
+ targetEntityName,
226
246
  labelField: field.labelField,
227
247
  };
228
248
  }
@@ -273,7 +293,9 @@ export function buildSearchableSortableCaches(state: RegistryState): void {
273
293
  }
274
294
  }
275
295
  if (field.type === "reference" && field.searchable === true) {
276
- searchableReferences.push(buildSearchableReferenceField(name, fieldName, field));
296
+ searchableReferences.push(
297
+ buildSearchableReferenceField(name, fieldName, field, state.entityMap),
298
+ );
277
299
  }
278
300
  if (field.type === "reference" && field.sortable === true) {
279
301
  sortableReferences.push(buildSortableReferenceField(name, fieldName, field));
@@ -1,4 +1,5 @@
1
1
  import { validateExtensionPreSaveWiring } from "./boot-validator/entity-handler";
2
+ import { dedupeFeatures } from "./dedupe-features";
2
3
  import { buildRegistryFacade } from "./registry-facade";
3
4
  import {
4
5
  populateClaimsAndAuth,
@@ -44,7 +45,8 @@ import type { FeatureDefinition, Registry } from "./types";
44
45
  // This is where the magic happens. By "magic" I mean: precomputed maps.
45
46
  // I build everything once at boot (hooks, relations, searchable fields, ...)
46
47
  // so nothing has to iterate over objects at runtime. O(1) instead of O(n*m).
47
- export function createRegistry(features: readonly FeatureDefinition[]): Registry {
48
+ export function createRegistry(rawFeatures: readonly FeatureDefinition[]): Registry {
49
+ const features = dedupeFeatures(rawFeatures);
48
50
  const state = createInitialState();
49
51
 
50
52
  for (const feature of features) {
@@ -6,7 +6,7 @@
6
6
  // compare nutzt der Author selten bewusst); Author kann den
7
7
  // Filter dann einfach nicht setzen.
8
8
  // - boolean: eq, ne (in/lt/gt sinnlos für 2-Werte-Type).
9
- // - number/money/decimal/date/timestamp/locatedTimestamp: alle 5
9
+ // - number/money/decimal/date/timestamp/locatedTimestamp: alle 7
10
10
  // Ops — die Felder sind natürlich vergleichbar.
11
11
  //
12
12
  // Boot-Validator nutzt diese Map um Author-Fehler früh zu fangen
@@ -17,7 +17,15 @@
17
17
  import type { FieldDefinition, ScreenFilterOp } from "./types";
18
18
 
19
19
  const EQUALITY_ONLY = ["eq", "ne", "in"] as const satisfies readonly ScreenFilterOp[];
20
- const COMPARABLE = ["eq", "ne", "lt", "gt", "in"] as const satisfies readonly ScreenFilterOp[];
20
+ const COMPARABLE = [
21
+ "eq",
22
+ "ne",
23
+ "lt",
24
+ "gt",
25
+ "lte",
26
+ "gte",
27
+ "in",
28
+ ] as const satisfies readonly ScreenFilterOp[];
21
29
  const BOOL_OPS = ["eq", "ne"] as const satisfies readonly ScreenFilterOp[];
22
30
 
23
31
  export function getAllowedFilterOps(field: FieldDefinition): readonly ScreenFilterOp[] {
@@ -42,9 +42,10 @@ export const softDeleteCleanupJob: JobHandlerFn = async (_payload, ctx) => {
42
42
  throw new Error("soft-delete cleanup: ctx.db + ctx.registry required (JobContext incomplete)");
43
43
  }
44
44
  // perTenant fan-out → one run per active tenant, systemUser scoped to it.
45
- // The job's db is the boot DbConnection (NOT tenant-scoped), so every delete
46
- // is explicitly tenant-filtered below — otherwise a tenant with a short grace
47
- // would purge another tenant's still-within-grace rows.
45
+ // The job's db is a tenant-filtered TenantDb, but every delete is still
46
+ // explicitly tenant-filtered below as defense-in-depth — otherwise a
47
+ // tenant with a short grace would purge another tenant's still-within-grace
48
+ // rows.
48
49
  const tenantId = ctx.systemUser?.tenantId ?? ctx._tenantId;
49
50
  if (tenantId === undefined) {
50
51
  // skip: cron fired without a perTenant fan-out tenant — nothing scoped to purge
@@ -49,7 +49,7 @@ const systemScopedFeature = defineFeature("jobsystemdb-system", (r) => {
49
49
  results.push({
50
50
  name: "system",
51
51
  present: true,
52
- boundToRawDb: tenantDbRunner(checked) === ctx.db,
52
+ boundToRawDb: tenantDbRunner(checked) === tenantDbRunner(ctx.db),
53
53
  foreignTenantThrew,
54
54
  });
55
55
  });
@@ -0,0 +1,162 @@
1
+ // fw#2914 — JobContext.db is a tenant-filtered TenantDb; unfiltered access needs r.job({ escapeHatch }).
2
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
3
+ import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
4
+ import type { DbRunner } from "../../db/connection";
5
+ import { table, text, uuid } from "../../db/dialect";
6
+ import { insertOne, selectMany } from "../../db/query";
7
+ import { createRegistry, defineFeature, type Registry } from "../../engine";
8
+ import type { EscapeHatchUseEvent, JobContext } from "../../engine/types";
9
+ import { AccessDeniedError } from "../../errors";
10
+ import { createTestRedis, type TestRedis, testTenantId, unsafePushTables } from "../../stack";
11
+ import { waitFor } from "../../testing";
12
+ import { createJobRunner, type JobRunner } from "../job-runner";
13
+
14
+ const itemsTable = table("fw2914_job_items", {
15
+ id: uuid("id").primaryKey().defaultRandom(),
16
+ tenantId: uuid("tenant_id").notNull(),
17
+ label: text("label").notNull(),
18
+ });
19
+
20
+ const ownTenant = testTenantId(1);
21
+ const foreignTenant = testTenantId(2);
22
+ const DECLARED_REASON = "fw#2914 integration test — job counts items of every tenant";
23
+
24
+ type Outcome = {
25
+ readonly job: "undeclared" | "declared";
26
+ readonly mode?: string;
27
+ readonly tenantId?: string;
28
+ readonly labels?: readonly string[];
29
+ readonly foreignWhereLabels?: readonly string[];
30
+ readonly unsafeRawDenied?: boolean;
31
+ readonly rawLabels?: readonly string[];
32
+ };
33
+
34
+ const outcomes: Outcome[] = [];
35
+ const auditEvents: EscapeHatchUseEvent[] = [];
36
+
37
+ function labelsOf(rows: readonly { label: string }[]): string[] {
38
+ return rows.map((row) => row.label).sort();
39
+ }
40
+
41
+ const jobsFeature = defineFeature("jobtenantdb", (r) => {
42
+ r.job("undeclared", { trigger: { manual: true } }, async (_payload, ctx) => {
43
+ const own = await selectMany<{ label: string }>(ctx.db, itemsTable);
44
+ const foreignWhere = await selectMany<{ label: string }>(ctx.db, itemsTable, {
45
+ tenantId: foreignTenant,
46
+ });
47
+ let unsafeRawDenied = false;
48
+ try {
49
+ ctx.db.unsafeRaw("fw#2914 integration test — undeclared job reaching for raw");
50
+ } catch (err) {
51
+ unsafeRawDenied = err instanceof AccessDeniedError;
52
+ }
53
+ outcomes.push({
54
+ job: "undeclared",
55
+ mode: ctx.db.mode,
56
+ tenantId: ctx.db.tenantId,
57
+ labels: labelsOf(own),
58
+ foreignWhereLabels: labelsOf(foreignWhere),
59
+ unsafeRawDenied,
60
+ });
61
+ });
62
+
63
+ r.job({
64
+ name: "declared",
65
+ trigger: { manual: true },
66
+ escapeHatch: { reason: DECLARED_REASON },
67
+ handler: async (_payload, ctx) => {
68
+ const raw = ctx.db.unsafeRaw(DECLARED_REASON);
69
+ const rows = await selectMany<{ label: string }>(raw, itemsTable);
70
+ outcomes.push({ job: "declared", rawLabels: labelsOf(rows) });
71
+ },
72
+ });
73
+ });
74
+
75
+ let testDb: BunTestDb;
76
+ let testRedis: TestRedis;
77
+ let registry: Registry;
78
+ let jobRunner: JobRunner;
79
+
80
+ beforeAll(async () => {
81
+ testDb = await createTestDb();
82
+ testRedis = await createTestRedis();
83
+ await unsafePushTables(testDb.db, { fw2914_job_items: itemsTable });
84
+ await insertOne(testDb.db, itemsTable, { tenantId: ownTenant, label: "own" });
85
+ await insertOne(testDb.db, itemsTable, { tenantId: foreignTenant, label: "foreign" });
86
+
87
+ registry = createRegistry([jobsFeature]);
88
+ const redisUrl = `redis://${testRedis.redis.options.host}:${testRedis.redis.options.port}/${testRedis.redis.options.db}`;
89
+ jobRunner = createJobRunner({
90
+ registry,
91
+ context: {
92
+ db: testDb.db,
93
+ _escapeHatchAuditSink: async (event) => {
94
+ auditEvents.push(event);
95
+ },
96
+ },
97
+ redisUrl,
98
+ consumerLane: "worker",
99
+ queueNamePrefix: `kumiko-job-tenant-db-test-${Date.now()}`,
100
+ getActiveTenantIds: async () => [ownTenant],
101
+ });
102
+ await jobRunner.start();
103
+ });
104
+
105
+ afterAll(async () => {
106
+ await jobRunner.stop();
107
+ await testDb.cleanup();
108
+ await testRedis.cleanup();
109
+ });
110
+
111
+ describe("JobContext.db (fw#2914)", () => {
112
+ test("is tenant-filtered to the job's tenant and unsafeRaw is denied without escapeHatch", async () => {
113
+ outcomes.length = 0;
114
+ auditEvents.length = 0;
115
+ await jobRunner.dispatch("jobtenantdb:job:undeclared", { tenantId: ownTenant });
116
+
117
+ await waitFor(() => {
118
+ const outcome = outcomes.find((o) => o.job === "undeclared");
119
+ expect(outcome).toBeDefined();
120
+ expect(outcome?.mode).toBe("tenant");
121
+ expect(outcome?.tenantId).toBe(ownTenant);
122
+ expect(outcome?.labels).toEqual(["own"]);
123
+ expect(outcome?.foreignWhereLabels).toEqual(["own"]);
124
+ expect(outcome?.unsafeRawDenied).toBe(true);
125
+ });
126
+ expect(auditEvents).toHaveLength(0);
127
+ });
128
+
129
+ test("escapeHatch on r.job grants unfiltered access and reports one unsafe-raw audit event", async () => {
130
+ outcomes.length = 0;
131
+ auditEvents.length = 0;
132
+ await jobRunner.dispatch("jobtenantdb:job:declared", { tenantId: ownTenant });
133
+
134
+ await waitFor(() => {
135
+ const outcome = outcomes.find((o) => o.job === "declared");
136
+ expect(outcome?.rawLabels).toEqual(["foreign", "own"]);
137
+ expect(auditEvents).toHaveLength(1);
138
+ });
139
+ expect(auditEvents[0]).toMatchObject({
140
+ handler: "jobtenantdb:job:declared",
141
+ kind: "unsafe-raw",
142
+ reason: DECLARED_REASON,
143
+ tenantId: ownTenant,
144
+ });
145
+ });
146
+
147
+ test("r.job rejects an escapeHatch with an empty reason at registration", () => {
148
+ const register = () =>
149
+ defineFeature("jobtenantdb-empty", (r) => {
150
+ r.job("bad", { trigger: { manual: true }, escapeHatch: { reason: " " } }, async () => {});
151
+ });
152
+ expect(register).toThrow(/r\.job\("bad"\) declares \{ escapeHatch: \{ reason: "" \} \}/);
153
+ });
154
+
155
+ test("JobContext.db is not assignable to a raw DbRunner", () => {
156
+ const acceptsRunner = (runner: DbRunner): DbRunner => runner;
157
+ const ctx = { db: undefined } as unknown as JobContext;
158
+ // @ts-expect-error — JobContext.db is a TenantDb, not a raw DbRunner (fw#2914)
159
+ const leaked = () => acceptsRunner(ctx.db);
160
+ expect(typeof leaked).toBe("function");
161
+ });
162
+ });
@@ -2,6 +2,7 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
2
  import { Queue } from "bullmq";
3
3
  import { z } from "zod";
4
4
  import { requestContext } from "../../api/request-context";
5
+ import { tenantDbRunner } from "../../db/tenant-db-runner";
5
6
  import { createRegistry, defineFeature } from "../../engine";
6
7
  import type {
7
8
  AppContext,
@@ -910,7 +911,7 @@ describe("perTenant across multiple runner instances", () => {
910
911
  async (_payload, ctx) => {
911
912
  log.push({
912
913
  tenantId: String(ctx.systemUser.tenantId),
913
- runnerTag: ctx.db as unknown as string, // @cast-boundary test fixture
914
+ runnerTag: tenantDbRunner(ctx.db) as unknown as string, // @cast-boundary test fixture — runner context db is a tag string
914
915
  });
915
916
  },
916
917
  );
@@ -1012,7 +1013,7 @@ describe("perTenant across multiple runner instances", () => {
1012
1013
  await sleep(80);
1013
1014
  log.push({
1014
1015
  tenantId: String(ctx.systemUser.tenantId),
1015
- runnerTag: ctx.db as unknown as string, // @cast-boundary test fixture
1016
+ runnerTag: tenantDbRunner(ctx.db) as unknown as string, // @cast-boundary test fixture — runner context db is a tag string
1016
1017
  });
1017
1018
  });
1018
1019
  });
@@ -2,7 +2,7 @@ import { Queue, Worker } from "bullmq";
2
2
  import { Redis } from "ioredis";
3
3
  import { requestContext } from "../api/request-context";
4
4
  import type { DbConnection, DbRow } from "../db/connection";
5
- import { createTenantDb, createUncheckedSystemDb } from "../db/tenant-db";
5
+ import { createTenantDb, createUncheckedSystemDb, type TenantDb } from "../db/tenant-db";
6
6
  import { createDerivativesContext } from "../derivatives/derivatives-context";
7
7
  import { createSystemUser } from "../engine/system-user";
8
8
  import {
@@ -551,20 +551,24 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
551
551
  // unchecked cross-tenant escape hatch for such a job.
552
552
  const tenantScopedDb = configDb ? createTenantDb(configDb, tenantId, "system") : undefined;
553
553
  const isSystemJob = registry.isJobSystemScoped(jobName);
554
+ // One reporter for ctx.systemDb and ctx.db.unsafeRaw() so both dedupe in the same window.
555
+ const reportEscapeHatch = createEscapeHatchReporter({
556
+ handler: jobName,
557
+ tenantId,
558
+ actor: jobSystemUser.id,
559
+ sink: context._escapeHatchAuditSink,
560
+ log: context.log,
561
+ });
554
562
  const systemDb =
555
563
  isSystemJob && tenantScopedDb
556
- ? createUncheckedSystemDb(
557
- tenantScopedDb,
558
- undefined,
559
- createEscapeHatchReporter({
560
- handler: jobName,
561
- tenantId,
562
- actor: jobSystemUser.id,
563
- sink: context._escapeHatchAuditSink,
564
- log: context.log,
565
- }),
566
- )
564
+ ? createUncheckedSystemDb(tenantScopedDb, undefined, reportEscapeHatch)
567
565
  : undefined;
566
+ const jobDb = configDb
567
+ ? createTenantDb(configDb, tenantId, "tenant", context.tracer, context.meter, undefined, {
568
+ unsafeRaw: jobDef.escapeHatch,
569
+ report: reportEscapeHatch,
570
+ })
571
+ : undefined;
568
572
  const config =
569
573
  context._configAccessorFactory && tenantScopedDb
570
574
  ? context._configAccessorFactory({
@@ -589,9 +593,8 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
589
593
  let memberReader: MemberReader | undefined;
590
594
  const jobContext: JobContext = {
591
595
  ...context,
592
- // Same union as configDb above — job runners are always constructed
593
- // with a real DbConnection; JobContext requires it non-optional.
594
- db: configDb as DbConnection, // @cast-boundary db-operator
596
+ // Undefined only for a runner built without a db; JobContext requires db non-optional.
597
+ db: jobDb as TenantDb, // @cast-boundary db-operator
595
598
  files,
596
599
  derivatives,
597
600
  ...(notify !== undefined && { notify }),