@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 +3 -3
- package/src/api/__tests__/server-boot-guards.test.ts +128 -1
- package/src/api/server.ts +44 -0
- package/src/db/__tests__/unchecked-system-db.test.ts +117 -0
- package/src/db/index.ts +7 -2
- package/src/db/tenant-db.ts +54 -2
- package/src/engine/__tests__/build-app-schema.test.ts +30 -0
- package/src/engine/build-app-schema.ts +6 -0
- package/src/engine/registry-facade.ts +6 -0
- package/src/engine/registry-ingest.ts +1 -0
- package/src/engine/registry-state.ts +2 -0
- package/src/jobs/__tests__/job-systemdb.integration.test.ts +152 -0
- package/src/jobs/job-runner.ts +10 -2
- package/src/pipeline/__tests__/ctx-systemdb.integration.test.ts +67 -0
- package/src/pipeline/dispatch-shared.ts +3 -1
- package/src/ui-types/app-schema.ts +10 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "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.
|
|
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.
|
|
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 {
|
|
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";
|
package/src/db/tenant-db.ts
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import { KUMIKO_NAME_SYMBOL, type SchemaTable } from "@cosmicdrift/kumiko-types/schema-table-types";
|
|
2
|
-
import
|
|
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
|
|
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
|
},
|
|
@@ -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
|
+
});
|
package/src/jobs/job-runner.ts
CHANGED
|
@@ -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
|