@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.
- package/package.json +3 -3
- package/src/changes.json +28 -7
- package/src/db/__tests__/build-filter-where.test.ts +8 -0
- package/src/db/__tests__/event-store-executor-list.integration.test.ts +69 -0
- package/src/db/__tests__/event-store-executor-reference-search.integration.test.ts +266 -2
- package/src/db/entity-field-encryption.ts +18 -0
- package/src/db/event-store-executor-context.ts +7 -3
- package/src/db/event-store-executor-read.ts +120 -34
- package/src/engine/__tests__/boot-validator.test.ts +20 -1
- package/src/engine/__tests__/dedupe-features.test.ts +85 -0
- package/src/engine/__tests__/engine.test.ts +86 -0
- package/src/engine/__tests__/screen-filter-ops.test.ts +1 -1
- package/src/engine/__tests__/soft-delete-cleanup-job-tenant-db.integration.test.ts +82 -0
- package/src/engine/boot-validator/index.ts +3 -1
- package/src/engine/boot-validator/projection-list-screens.ts +2 -2
- package/src/engine/create-app.ts +6 -4
- package/src/engine/dedupe-features.ts +46 -0
- package/src/engine/define-feature.ts +6 -0
- package/src/engine/entity-handlers.ts +3 -3
- package/src/engine/feature-ast/__tests__/parse-real-features.test.ts +2 -2
- package/src/engine/feature-config-events-jobs.ts +11 -2
- package/src/engine/index.ts +1 -0
- package/src/engine/registry-validate.ts +24 -2
- package/src/engine/registry.ts +3 -1
- package/src/engine/screen-filter-ops.ts +10 -2
- package/src/engine/soft-delete-cleanup.ts +4 -3
- package/src/jobs/__tests__/job-systemdb.integration.test.ts +1 -1
- package/src/jobs/__tests__/job-tenant-db.integration.test.ts +162 -0
- package/src/jobs/__tests__/jobs.integration.test.ts +3 -2
- 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"
|
|
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 [
|
|
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.
|
package/src/engine/index.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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));
|
package/src/engine/registry.ts
CHANGED
|
@@ -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(
|
|
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
|
|
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 = [
|
|
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
|
|
46
|
-
//
|
|
47
|
-
// would purge another tenant's still-within-grace
|
|
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
|
});
|
package/src/jobs/job-runner.ts
CHANGED
|
@@ -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
|
-
//
|
|
593
|
-
|
|
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 }),
|