@cosmicdrift/kumiko-framework 0.198.0 → 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.198.0",
3
+ "version": "0.199.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -186,7 +186,7 @@
186
186
  "./package.json": "./package.json"
187
187
  },
188
188
  "dependencies": {
189
- "@cosmicdrift/kumiko-types": "0.198.0",
189
+ "@cosmicdrift/kumiko-types": "0.199.0",
190
190
  "bullmq": "^5.76.7",
191
191
  "bun-types": "^1.3.13",
192
192
  "hono": "^4.13.1",
@@ -202,7 +202,7 @@
202
202
  "zod": "^4.4.3"
203
203
  },
204
204
  "devDependencies": {
205
- "@cosmicdrift/kumiko-dispatcher-live": "0.198.0",
205
+ "@cosmicdrift/kumiko-dispatcher-live": "0.199.0",
206
206
  "bun-types": "^1.3.13",
207
207
  "pino-pretty": "^13.1.3"
208
208
  },
@@ -1,6 +1,6 @@
1
1
  // buildServer boot-time guards + httpRoute verb wiring (PUT branch).
2
2
 
3
- import { describe, expect, test } from "bun:test";
3
+ import { describe, expect, spyOn, test } from "bun:test";
4
4
  import {
5
5
  createEntity,
6
6
  createFileField,
@@ -8,10 +8,22 @@ import {
8
8
  createTextField,
9
9
  defineFeature,
10
10
  } from "../../engine";
11
+ import { createInMemorySearchAdapter } from "../../search";
11
12
  import { buildServer } from "../server";
12
13
 
13
14
  const JWT_SECRET = "server-boot-guards-test-secret-min-32-chars";
14
15
 
16
+ // Find the "[kumiko:boot] ... SearchAdapter" line among all console.warn calls
17
+ // so the unrelated instanceIdWasRandom warning (fires whenever
18
+ // KUMIKO_INSTANCE_ID is unset, as it is in this test run) can't false-fire
19
+ // or hide the assertion.
20
+ function searchAdapterWarning(calls: unknown[][]): string | undefined {
21
+ const hit = calls.find(
22
+ (args) => typeof args[0] === "string" && args[0].includes("SearchAdapter is wired"),
23
+ );
24
+ return hit ? String(hit[0]) : undefined;
25
+ }
26
+
15
27
  describe("buildServer — file-storage provider guard", () => {
16
28
  const fileFieldFeature = defineFeature("needs-files", (r) => {
17
29
  r.entity(
@@ -69,3 +81,118 @@ describe("buildServer — feature httpRoute PUT mounting", () => {
69
81
  expect(await res.json()).toEqual({ method: "PUT", ok: true });
70
82
  });
71
83
  });
84
+
85
+ describe("buildServer — search-adapter boot warning (#2051)", () => {
86
+ const searchableFeature = defineFeature("has-search", (r) => {
87
+ r.entity(
88
+ "note",
89
+ createEntity({
90
+ table: "boot_guard_notes",
91
+ fields: { title: createTextField({ searchable: true }) },
92
+ }),
93
+ );
94
+ r.screen({ id: "note-list", type: "entityList", entity: "note", columns: ["title"] });
95
+ });
96
+
97
+ const nonSearchableFeature = defineFeature("no-search", (r) => {
98
+ r.entity(
99
+ "note",
100
+ createEntity({
101
+ table: "boot_guard_plain_notes",
102
+ fields: { title: createTextField() },
103
+ }),
104
+ );
105
+ r.screen({ id: "note-list", type: "entityList", entity: "note", columns: ["title"] });
106
+ });
107
+
108
+ // Pins the `screen.searchable === false` exclusion specifically: without
109
+ // it, this would false-positive purely off the entity having a searchable
110
+ // field, ignoring that the screen (whitelisted per entity-list-screens.ts
111
+ // SEARCHABLE_FALSE_WHITELIST) never renders the search box.
112
+ const explicitlyNonSearchableScreenFeature = defineFeature("opted-out-search", (r) => {
113
+ r.entity(
114
+ "download-attempt",
115
+ createEntity({
116
+ table: "boot_guard_download_attempts",
117
+ fields: { title: createTextField({ searchable: true }) },
118
+ }),
119
+ );
120
+ r.screen({
121
+ id: "download-attempt-list",
122
+ type: "entityList",
123
+ entity: "download-attempt",
124
+ columns: ["title"],
125
+ searchable: false,
126
+ });
127
+ });
128
+
129
+ test("warns naming the entity when a searchable screen has no context.searchAdapter", () => {
130
+ const calls: unknown[][] = [];
131
+ const spy = spyOn(console, "warn").mockImplementation((...args) => {
132
+ calls.push(args);
133
+ });
134
+ try {
135
+ buildServer({
136
+ registry: createRegistry([searchableFeature]),
137
+ context: {},
138
+ jwtSecret: JWT_SECRET,
139
+ });
140
+ const logged = searchAdapterWarning(calls);
141
+ expect(logged).toBeDefined();
142
+ expect(logged).toContain("note");
143
+ } finally {
144
+ spy.mockRestore();
145
+ }
146
+ });
147
+
148
+ test("stays silent when context.searchAdapter is wired", () => {
149
+ const calls: unknown[][] = [];
150
+ const spy = spyOn(console, "warn").mockImplementation((...args) => {
151
+ calls.push(args);
152
+ });
153
+ try {
154
+ buildServer({
155
+ registry: createRegistry([searchableFeature]),
156
+ context: { searchAdapter: createInMemorySearchAdapter() },
157
+ jwtSecret: JWT_SECRET,
158
+ });
159
+ expect(searchAdapterWarning(calls)).toBeUndefined();
160
+ } finally {
161
+ spy.mockRestore();
162
+ }
163
+ });
164
+
165
+ test("stays silent when no screen has a searchable field", () => {
166
+ const calls: unknown[][] = [];
167
+ const spy = spyOn(console, "warn").mockImplementation((...args) => {
168
+ calls.push(args);
169
+ });
170
+ try {
171
+ buildServer({
172
+ registry: createRegistry([nonSearchableFeature]),
173
+ context: {},
174
+ jwtSecret: JWT_SECRET,
175
+ });
176
+ expect(searchAdapterWarning(calls)).toBeUndefined();
177
+ } finally {
178
+ spy.mockRestore();
179
+ }
180
+ });
181
+
182
+ test("stays silent when the entity has a searchable field but the screen opts out (searchable: false)", () => {
183
+ const calls: unknown[][] = [];
184
+ const spy = spyOn(console, "warn").mockImplementation((...args) => {
185
+ calls.push(args);
186
+ });
187
+ try {
188
+ buildServer({
189
+ registry: createRegistry([explicitlyNonSearchableScreenFeature]),
190
+ context: {},
191
+ jwtSecret: JWT_SECRET,
192
+ });
193
+ expect(searchAdapterWarning(calls)).toBeUndefined();
194
+ } finally {
195
+ spy.mockRestore();
196
+ }
197
+ });
198
+ });
package/src/api/server.ts CHANGED
@@ -286,6 +286,27 @@ export function buildServer(options: ServerOptions): KumikoServer {
286
286
  );
287
287
  }
288
288
 
289
+ // #2051 — a screen whose search box renders (kumiko-screen.tsx gates it on
290
+ // `screen.searchable ?? entity-has-searchable-field`, the same condition
291
+ // used below) sends `payload.search` on every query. Without a
292
+ // context.searchAdapter that throws UnprocessableError at request time
293
+ // (#2032) — surface the misconfig at boot instead of on the first search.
294
+ // Warn, not throw: unlike missing file-storage (uploads always fail),
295
+ // list screens still work without search: only the search box is broken.
296
+ // Several deployed apps (offlot-app, publicstatus, kumiko-studio,
297
+ // kumiko-enterprise) currently run in exactly this state.
298
+ if (!options.context.searchAdapter) {
299
+ const unwiredEntities = entitiesWithSearchableScreen(options.registry);
300
+ if (unwiredEntities.length > 0) {
301
+ console.warn(
302
+ `[kumiko:boot] ${unwiredEntities.length} entit${unwiredEntities.length === 1 ? "y" : "ies"} ` +
303
+ `have a searchable list screen but no SearchAdapter is wired on context.searchAdapter: ` +
304
+ `${unwiredEntities.join(", ")}. Search requests against ${unwiredEntities.length === 1 ? "it" : "them"} will fail with a 422 (search_adapter_not_wired) at runtime. ` +
305
+ "Wire a SearchAdapter (e.g. createMeilisearchAdapter) on context.searchAdapter, or remove `searchable: true` from the affected fields.",
306
+ );
307
+ }
308
+ }
309
+
289
310
  // Stateless JWTs (no sessionChecker → no revocation) default to a shorter
290
311
  // TTL than session-backed ones, since a leaked stateless token can't be
291
312
  // revoked and stays valid until it expires. Explicit jwtTtl always wins.
@@ -841,6 +862,29 @@ function registryDeclaresFileFields(registry: Registry): boolean {
841
862
  return false;
842
863
  }
843
864
 
865
+ // Entities whose entityList screen WOULD render a search box based on
866
+ // screen/field config alone: `screen.searchable` wins when set, otherwise
867
+ // the box shows iff the entity has ≥1 searchable field. `screen.searchable
868
+ // === false` is excluded — the boot-validator (entity-list-screens.ts) only
869
+ // allows that on the whitelisted download-attempt-list-style screens, which
870
+ // never render the box. This is the config-only half of the rule; the
871
+ // client additionally gates on FeatureSchema.searchAdapterMissing (#2062,
872
+ // set from the same `!options.context.searchAdapter` check below) so the
873
+ // box is actually suppressed once this function finds a hit.
874
+ function entitiesWithSearchableScreen(registry: Registry): readonly string[] {
875
+ const entities = new Set<string>();
876
+ for (const feature of registry.features.values()) {
877
+ for (const screen of Object.values(feature.screens)) {
878
+ if (screen.type !== "entityList") continue;
879
+ const isSearchable =
880
+ screen.searchable === true ||
881
+ (screen.searchable === undefined && registry.getSearchableFields(screen.entity).length > 0);
882
+ if (isSearchable) entities.add(screen.entity);
883
+ }
884
+ }
885
+ return [...entities];
886
+ }
887
+
844
888
  // Upload-route policy carried by createFilesFeature(opts?) — read from the
845
889
  // feature's exports so buildServer applies it without a parallel ServerOptions
846
890
  // surface. Absent feature / opts → defaults in createFileRoutes.
@@ -0,0 +1,117 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { SYSTEM_TENANT_ID } from "../../engine";
3
+ import { testTenantId } from "../../stack";
4
+ import type { DbRunner } from "../connection";
5
+ import { createTenantDb, createUncheckedSystemDb, SYSTEM_SCOPE_CHECK_BRAND } from "../tenant-db";
6
+
7
+ // createUncheckedSystemDb wraps a "system"-mode TenantDb (r.systemScope())
8
+ // so a handler must explicitly clear a self-check before using it — none of
9
+ // these checks execute a query, so a runner that always throws is enough to
10
+ // prove the wrapper never falls through to the DB on a mismatch.
11
+ function unusedRunner(): DbRunner {
12
+ return {
13
+ unsafe: async () => {
14
+ throw new Error("unchecked-system-db tests must not reach the DB");
15
+ },
16
+ begin: async () => {
17
+ throw new Error("unchecked-system-db tests must not reach the DB");
18
+ },
19
+ } as DbRunner;
20
+ }
21
+
22
+ const own = testTenantId(1);
23
+ const foreign = testTenantId(2);
24
+
25
+ describe("createUncheckedSystemDb", () => {
26
+ test("carries the SYSTEM_SCOPE_CHECK_BRAND", () => {
27
+ const systemDb = createTenantDb(unusedRunner(), own, "system");
28
+ const unchecked = createUncheckedSystemDb(systemDb);
29
+
30
+ expect(unchecked[SYSTEM_SCOPE_CHECK_BRAND]).toBe(true);
31
+ });
32
+
33
+ describe("assertTenantMatch", () => {
34
+ test("returns the underlying TenantDb when the tenantId matches", () => {
35
+ const systemDb = createTenantDb(unusedRunner(), own, "system");
36
+ const unchecked = createUncheckedSystemDb(systemDb);
37
+
38
+ expect(unchecked.assertTenantMatch(own)).toBe(systemDb);
39
+ });
40
+
41
+ test("throws AccessDeniedError when the tenantId doesn't match", () => {
42
+ const systemDb = createTenantDb(unusedRunner(), own, "system");
43
+ const unchecked = createUncheckedSystemDb(systemDb);
44
+
45
+ expect(() => unchecked.assertTenantMatch(foreign)).toThrow(/tenant self-check failed/);
46
+ });
47
+ });
48
+
49
+ describe("assertRowsTenant", () => {
50
+ test("returns the rows unchanged when every row matches", () => {
51
+ const systemDb = createTenantDb(unusedRunner(), own, "system");
52
+ const unchecked = createUncheckedSystemDb(systemDb);
53
+ const rows = [
54
+ { tenantId: own, name: "a" },
55
+ { tenantId: own, name: "b" },
56
+ ];
57
+
58
+ expect(unchecked.assertRowsTenant(rows, "tenantId")).toBe(rows);
59
+ });
60
+
61
+ test("throws AccessDeniedError on the first mismatched row instead of filtering", () => {
62
+ const systemDb = createTenantDb(unusedRunner(), own, "system");
63
+ const unchecked = createUncheckedSystemDb(systemDb);
64
+ const rows = [
65
+ { tenantId: own, name: "a" },
66
+ { tenantId: foreign, name: "b" },
67
+ ];
68
+
69
+ expect(() => unchecked.assertRowsTenant(rows, "tenantId")).toThrow(
70
+ /row tenant self-check failed/,
71
+ );
72
+ });
73
+
74
+ test("an empty row array trivially passes", () => {
75
+ const systemDb = createTenantDb(unusedRunner(), own, "system");
76
+ const unchecked = createUncheckedSystemDb(systemDb);
77
+
78
+ expect(unchecked.assertRowsTenant([], "tenantId")).toEqual([]);
79
+ });
80
+
81
+ test("accepts SYSTEM_TENANT_ID rows as reference data, mirroring tenant-mode readWhere", () => {
82
+ const systemDb = createTenantDb(unusedRunner(), own, "system");
83
+ const unchecked = createUncheckedSystemDb(systemDb);
84
+ const rows = [
85
+ { tenantId: own, name: "a" },
86
+ { tenantId: SYSTEM_TENANT_ID, name: "global-default" },
87
+ ];
88
+
89
+ expect(unchecked.assertRowsTenant(rows, "tenantId")).toBe(rows);
90
+ });
91
+ });
92
+
93
+ describe("acknowledgeCrossTenant", () => {
94
+ test("returns the underlying TenantDb without comparing tenants when given a reason", () => {
95
+ const systemDb = createTenantDb(unusedRunner(), own, "system");
96
+ const unchecked = createUncheckedSystemDb(systemDb);
97
+
98
+ expect(unchecked.acknowledgeCrossTenant("user feature is cross-tenant by design")).toBe(
99
+ systemDb,
100
+ );
101
+ });
102
+
103
+ test("throws on an empty reason", () => {
104
+ const systemDb = createTenantDb(unusedRunner(), own, "system");
105
+ const unchecked = createUncheckedSystemDb(systemDb);
106
+
107
+ expect(() => unchecked.acknowledgeCrossTenant("")).toThrow(/non-empty reason/);
108
+ });
109
+
110
+ test("throws on a whitespace-only reason", () => {
111
+ const systemDb = createTenantDb(unusedRunner(), own, "system");
112
+ const unchecked = createUncheckedSystemDb(systemDb);
113
+
114
+ expect(() => unchecked.acknowledgeCrossTenant(" ")).toThrow(/non-empty reason/);
115
+ });
116
+ });
117
+ });
package/src/db/index.ts CHANGED
@@ -143,5 +143,10 @@ export {
143
143
  toSnakeCase,
144
144
  toTableName,
145
145
  } from "./table-builder";
146
- export type { TenantDb, TenantDbMode } from "./tenant-db";
147
- export { castTenantRows, createTenantDb } from "./tenant-db";
146
+ export type { TenantDb, TenantDbMode, UncheckedSystemDb } from "./tenant-db";
147
+ export {
148
+ castTenantRows,
149
+ createTenantDb,
150
+ createUncheckedSystemDb,
151
+ SYSTEM_SCOPE_CHECK_BRAND,
152
+ } from "./tenant-db";
@@ -1,5 +1,10 @@
1
1
  import { KUMIKO_NAME_SYMBOL, type SchemaTable } from "@cosmicdrift/kumiko-types/schema-table-types";
2
- import type { TenantDb, TenantDbMode } from "@cosmicdrift/kumiko-types/tenant-db-types";
2
+ import {
3
+ SYSTEM_SCOPE_CHECK_BRAND,
4
+ type TenantDb,
5
+ type TenantDbMode,
6
+ type UncheckedSystemDb,
7
+ } from "@cosmicdrift/kumiko-types/tenant-db-types";
3
8
  import {
4
9
  asEntityTableMeta,
5
10
  asRawClient,
@@ -12,12 +17,59 @@ import {
12
17
  type WhereObject,
13
18
  } from "../db/query";
14
19
  import { SYSTEM_TENANT_ID, type TenantId } from "../engine/types/identifiers";
20
+ import { AccessDeniedError } from "../errors";
15
21
  import { emitDbQuery, type Meter, registerStandardMetrics, type Tracer } from "../observability";
16
22
  import type { DbRunner } from "./connection";
17
23
 
18
24
  type Table = SchemaTable;
19
25
 
20
- export type { TenantDb, TenantDbMode } from "@cosmicdrift/kumiko-types/tenant-db-types";
26
+ export {
27
+ SYSTEM_SCOPE_CHECK_BRAND,
28
+ type TenantDb,
29
+ type TenantDbMode,
30
+ type UncheckedSystemDb,
31
+ } from "@cosmicdrift/kumiko-types/tenant-db-types";
32
+
33
+ // buildHandlerContext (pipeline/dispatch-shared.ts) always builds "system"
34
+ // mode from the caller's own tenantId, never a foreign one.
35
+ export function createUncheckedSystemDb(db: TenantDb): UncheckedSystemDb {
36
+ const allowedTenantIds: readonly TenantId[] = [db.tenantId, SYSTEM_TENANT_ID];
37
+
38
+ return {
39
+ [SYSTEM_SCOPE_CHECK_BRAND]: true,
40
+
41
+ assertTenantMatch(tenantId) {
42
+ if (tenantId !== db.tenantId) {
43
+ throw new AccessDeniedError({
44
+ message: `systemScope() tenant self-check failed: expected "${db.tenantId}", got "${tenantId}"`,
45
+ });
46
+ }
47
+ return db;
48
+ },
49
+
50
+ // Fails closed on any mismatch rather than silently dropping rows.
51
+ // Reference rows (tenantId === SYSTEM_TENANT_ID) are allowed, mirroring
52
+ // "tenant"-mode readWhere's own [tenantId, SYSTEM_TENANT_ID] allowlist.
53
+ assertRowsTenant<T>(rows: readonly T[], tenantField: keyof T): readonly T[] {
54
+ const hasOffender = rows.some(
55
+ (row) => !allowedTenantIds.includes(row[tenantField] as TenantId),
56
+ );
57
+ if (hasOffender) {
58
+ throw new AccessDeniedError({
59
+ message: `systemScope() row tenant self-check failed on field "${String(tenantField)}"`,
60
+ });
61
+ }
62
+ return rows;
63
+ },
64
+
65
+ acknowledgeCrossTenant(reason) {
66
+ if (reason.trim().length === 0) {
67
+ throw new Error("acknowledgeCrossTenant requires a non-empty reason");
68
+ }
69
+ return db;
70
+ },
71
+ };
72
+ }
21
73
 
22
74
  // @cast-boundary tenant-db-row
23
75
  export function castTenantRows<T>(rows: readonly Record<string, unknown>[]): readonly T[] {
@@ -99,6 +99,36 @@ describe("buildAppSchema", () => {
99
99
  expect(app.features[0]?.translations).toBeUndefined();
100
100
  });
101
101
 
102
+ // #2062: buildAppSchema itself has no context, so the boot entrypoint
103
+ // (createKumikoServer, runProdApp) forwards its own context.searchAdapter
104
+ // presence check in via options.searchAdapterMissing.
105
+ test("options.searchAdapterMissing: true landet auf jeder FeatureSchema", () => {
106
+ const orderFeature = defineFeature("orders", (r) => {
107
+ r.nav({ id: "list", label: "List" });
108
+ });
109
+ const fleetFeature = defineFeature("fleet", (r) => {
110
+ r.nav({ id: "list", label: "List" });
111
+ });
112
+ const app = buildAppSchema(createRegistry([orderFeature, fleetFeature]), {
113
+ searchAdapterMissing: true,
114
+ });
115
+
116
+ expect(app.features.length).toBeGreaterThan(0);
117
+ expect(app.features.every((f) => f.searchAdapterMissing === true)).toBe(true);
118
+ });
119
+
120
+ test("options.searchAdapterMissing ohne/false lässt das Feld weg (omit-undefined-Pattern)", () => {
121
+ const f = defineFeature("bare", (r) => {
122
+ r.nav({ id: "x", label: "X" });
123
+ });
124
+
125
+ expect(buildAppSchema(createRegistry([f])).features[0]?.searchAdapterMissing).toBeUndefined();
126
+ expect(
127
+ buildAppSchema(createRegistry([f]), { searchAdapterMissing: false }).features[0]
128
+ ?.searchAdapterMissing,
129
+ ).toBeUndefined();
130
+ });
131
+
102
132
  test("Workspaces — definition + aufgelöste navMembers landen auf AppSchema-Ebene", () => {
103
133
  const ordersFeature = defineFeature("orders", (r) => {
104
134
  r.nav({ id: "list", label: "List" });
@@ -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
  }
@@ -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(),
@@ -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
+ });
@@ -2,7 +2,7 @@ import { type Job, 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 } from "../db/tenant-db";
5
+ import { createTenantDb, createUncheckedSystemDb } from "../db/tenant-db";
6
6
  import { createDerivativesContext } from "../derivatives/derivatives-context";
7
7
  import { createSystemUser } from "../engine/system-user";
8
8
  import {
@@ -427,8 +427,15 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
427
427
  const configDb = context.db as DbConnection | undefined; // @cast-boundary db-operator
428
428
  // Shared by the config accessor and ctx.derivatives below — both need the
429
429
  // same tenant-scoped db, and building it twice would let the two calls
430
- // drift apart.
430
+ // drift apart. Always "system" mode regardless of the job's own
431
+ // systemScope() status (pre-existing, not something this change alters)
432
+ // — isSystemJob below is what actually keeps ctx.systemDb off a
433
+ // non-system job; it is the only thing standing between this db and an
434
+ // unchecked cross-tenant escape hatch for such a job.
431
435
  const tenantScopedDb = configDb ? createTenantDb(configDb, tenantId, "system") : undefined;
436
+ const isSystemJob = registry.isJobSystemScoped(jobName);
437
+ const systemDb =
438
+ isSystemJob && tenantScopedDb ? createUncheckedSystemDb(tenantScopedDb) : undefined;
432
439
  const config =
433
440
  context._configAccessorFactory && tenantScopedDb
434
441
  ? context._configAccessorFactory({
@@ -457,6 +464,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
457
464
  derivatives,
458
465
  ...(notify !== undefined && { notify }),
459
466
  ...(config !== undefined && { config }),
467
+ ...(systemDb && { systemDb }),
460
468
  // The runner owns the registry it resolved this job from — expose it so
461
469
  // workers can reach projections/jobs without the app author duplicating
462
470
  // it into `context` (the JobContext contract guarantees `registry`).
@@ -0,0 +1,67 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { z } from "zod";
3
+ import { defineFeature } from "../../engine";
4
+ import { setupTestStack, type TestStack, TestUsers } from "../../stack";
5
+
6
+ // r.systemScope() is feature-level (define-feature.ts), not per-handler — so
7
+ // two features prove both sides: one system-scoped, one not.
8
+
9
+ const systemScopedFeature = defineFeature("ctxsystemdb-system", (r) => {
10
+ r.systemScope();
11
+
12
+ r.queryHandler(
13
+ "check",
14
+ z.object({}),
15
+ async (query, ctx) => {
16
+ if (!ctx.systemDb) return { present: false as const };
17
+ // assertTenantMatch returns the underlying TenantDb — proves systemDb
18
+ // is bound to the SAME internally-built TenantDb as ctx.db, not a
19
+ // separate instance (dispatch-shared.ts builds `as HandlerContext`,
20
+ // so a mis-wired property wouldn't be caught by tsc).
21
+ const checked = ctx.systemDb.assertTenantMatch(query.user.tenantId);
22
+ return { present: true as const, boundToDb: checked === ctx.db };
23
+ },
24
+ { access: { roles: ["Admin"] } },
25
+ );
26
+ });
27
+
28
+ const tenantScopedFeature = defineFeature("ctxsystemdb-tenant", (r) => {
29
+ r.queryHandler(
30
+ "check",
31
+ z.object({}),
32
+ async (_query, ctx) => ({ present: ctx.systemDb !== undefined }),
33
+ { access: { roles: ["Admin"] } },
34
+ );
35
+ });
36
+
37
+ let stack: TestStack;
38
+ const admin = TestUsers.admin;
39
+
40
+ beforeAll(async () => {
41
+ stack = await setupTestStack({ features: [systemScopedFeature, tenantScopedFeature] });
42
+ });
43
+
44
+ afterAll(async () => {
45
+ await stack.cleanup();
46
+ });
47
+
48
+ describe("ctx.systemDb", () => {
49
+ test("is present and bound to ctx.db for r.systemScope() handlers", async () => {
50
+ const result = await stack.http.queryOk<{ present: boolean; boundToDb: boolean }>(
51
+ "ctxsystemdb-system:query:check",
52
+ {},
53
+ admin,
54
+ );
55
+ expect(result.present).toBe(true);
56
+ expect(result.boundToDb).toBe(true);
57
+ });
58
+
59
+ test("is absent for non-system-scoped handlers", async () => {
60
+ const result = await stack.http.queryOk<{ present: boolean }>(
61
+ "ctxsystemdb-tenant:query:check",
62
+ {},
63
+ admin,
64
+ );
65
+ expect(result.present).toBe(false);
66
+ });
67
+ });
@@ -3,7 +3,7 @@ import type { SseBroker } from "../api/sse-broker";
3
3
  import type { DbConnection, DbRunner, DbTx } from "../db/connection";
4
4
  import { runInSavepoint, selectMany } from "../db/query";
5
5
  import type { buildEntityTable } from "../db/table-builder";
6
- import { createTenantDb } from "../db/tenant-db";
6
+ import { createTenantDb, createUncheckedSystemDb } from "../db/tenant-db";
7
7
  import { createDerivativesContext } from "../derivatives/derivatives-context";
8
8
  import type { defineTransitions } from "../engine/state-machine";
9
9
  import type { EffectiveFeaturesResolver } from "../engine/tier-resolver-extension";
@@ -183,6 +183,7 @@ export async function buildHandlerContext(
183
183
  // the client has disconnected — handlers with many sequential queries skip
184
184
  // the rest of the chain instead of burning DB-CPU for results no one reads.
185
185
  const db = dbSource ? buildTenantScopedDb(dbSource, reqCtx?.signal) : undefined;
186
+ const systemDb = isSystem && db ? createUncheckedSystemDb(db) : undefined;
186
187
  // Unbound pool, tenant-scoped like `db` but never tx-bound — writes
187
188
  // through it survive a rollback of the handler's own transaction. No
188
189
  // AbortSignal here: a client disconnect must not abort a durability write
@@ -573,6 +574,7 @@ export async function buildHandlerContext(
573
574
  registry,
574
575
  db,
575
576
  dbOutsideTransaction,
577
+ ...(systemDb && { systemDb }),
576
578
  log,
577
579
  notify,
578
580
  ...(config && { config }),
@@ -43,6 +43,16 @@ export type FeatureSchema = {
43
43
  // Fallback erhalten damit alte clientSchema-Files (vor AppSchema)
44
44
  // ohne Migration weiter laufen — toAppSchema() hebt die Liste hoch.
45
45
  readonly workspaces?: readonly WorkspaceSchema[];
46
+ // True only when the server confirmed at boot that no SearchAdapter is
47
+ // wired on context.searchAdapter — mirrors the global (not per-entity)
48
+ // check behind api/server.ts's boot warning (#2051). Duplicated
49
+ // identically across every feature purely so it threads through the
50
+ // existing per-feature prop chain into screen renderers (kumiko-screen.tsx)
51
+ // without a separate app-level plumbing path. Omitted for schemas that
52
+ // don't flow through buildAppSchema() (hand-authored fixtures, legacy
53
+ // toAppSchema()) — treated as "not missing" so search bars keep rendering
54
+ // exactly as before this flag existed (#2062).
55
+ readonly searchAdapterMissing?: boolean;
46
56
  };
47
57
 
48
58
  // A content collection as it reaches the client: the declaration plus the