@cosmicdrift/kumiko-framework 0.294.1 → 0.296.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 +4 -4
- package/src/__tests__/schema-cli-kms-slots.integration.test.ts +172 -0
- package/src/__tests__/schema-cli-temporal-polyfill.test.ts +1 -1
- package/src/changes.json +12 -0
- package/src/engine/__tests__/boot-validator-ref-entity.test.ts +196 -0
- package/src/engine/__tests__/projection-detail-tabs.test.ts +79 -2
- package/src/engine/boot-validator/__tests__/transfer-graph.test.ts +126 -0
- package/src/engine/boot-validator/index.ts +3 -0
- package/src/engine/boot-validator/screens.ts +125 -59
- package/src/engine/boot-validator/transfer-graph.ts +134 -0
- package/src/engine/boot-validator.ts +1 -0
- package/src/engine/index.ts +1 -0
- package/src/schema-cli.ts +7 -0
- package/src/search/__tests__/meilisearch-adapter.integration.test.ts +2 -3
- package/src/stack/db.ts +1 -1
- package/src/testing/__tests__/real-providers.test.ts +54 -0
- package/src/testing/index.ts +1 -0
- package/src/testing/real-providers.ts +20 -0
- package/src/ui-types/index.ts +2 -0
- package/src/ui-types/list-row-meta.ts +28 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.296.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>",
|
|
@@ -198,8 +198,8 @@
|
|
|
198
198
|
"./package.json": "./package.json"
|
|
199
199
|
},
|
|
200
200
|
"dependencies": {
|
|
201
|
-
"@cosmicdrift/kumiko-http": "0.
|
|
202
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
201
|
+
"@cosmicdrift/kumiko-http": "0.296.0",
|
|
202
|
+
"@cosmicdrift/kumiko-types": "0.296.0",
|
|
203
203
|
"bullmq": "^5.76.7",
|
|
204
204
|
"bun-types": "^1.3.13",
|
|
205
205
|
"hono": "^4.13.1",
|
|
@@ -215,7 +215,7 @@
|
|
|
215
215
|
"zod": "^4.4.3"
|
|
216
216
|
},
|
|
217
217
|
"devDependencies": {
|
|
218
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
218
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.296.0",
|
|
219
219
|
"bun-types": "^1.3.13",
|
|
220
220
|
"pino-pretty": "^13.1.3"
|
|
221
221
|
},
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
// `schema apply`'s KMS wiring resolves the schema-declared slots
|
|
2
|
+
// (RunSchemaCliOptions["kmsSlots"]) instead of resolvePlatformKeks's
|
|
3
|
+
// LEGACY_SLOTS default. Real PgKmsAdapter + real blind-index decode, only
|
|
4
|
+
// globalThis.fetch is mocked (the Key Manager decrypt call).
|
|
5
|
+
|
|
6
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
7
|
+
import { mkdirSync, writeFileSync } from "node:fs";
|
|
8
|
+
import { tmpdir } from "node:os";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { resetBlindIndexKeyForTests } from "../crypto/blind-index";
|
|
11
|
+
import { resetPiiSubjectKmsForTests } from "../crypto/pii-field-encryption";
|
|
12
|
+
import { defineFeature } from "../engine";
|
|
13
|
+
import { runSchemaCli, type SchemaCliOut } from "../schema-cli";
|
|
14
|
+
import { createTestDb, type TestDb } from "../stack";
|
|
15
|
+
import { ensureTemporalPolyfill } from "../time/polyfill";
|
|
16
|
+
|
|
17
|
+
const feature = defineFeature("kmsslotstest", () => {});
|
|
18
|
+
|
|
19
|
+
const PLATFORM_KEK_PLAINTEXT = Buffer.alloc(32, 1).toString("base64");
|
|
20
|
+
const BLIND_INDEX_PLAINTEXT = Buffer.alloc(32, 2).toString("base64");
|
|
21
|
+
const PLATFORM_KEK_CIPHERTEXT = "ct-platform-kek";
|
|
22
|
+
const BLIND_INDEX_CIPHERTEXT = "ct-blind-index";
|
|
23
|
+
const PLATFORM_KEK_PREVIOUS_CIPHERTEXT = "ct-platform-kek-previous";
|
|
24
|
+
|
|
25
|
+
function captureOut(): { out: SchemaCliOut; log: string[]; err: string[] } {
|
|
26
|
+
const log: string[] = [];
|
|
27
|
+
const err: string[] = [];
|
|
28
|
+
return { out: { log: (l) => log.push(l), err: (l) => err.push(l) }, log, err };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function writeAppWithTrivialMigration(migrationId: string): string {
|
|
32
|
+
const appCwd = join(
|
|
33
|
+
tmpdir(),
|
|
34
|
+
`kumiko-kms-slots-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
|
35
|
+
);
|
|
36
|
+
const migrationsDir = join(appCwd, "kumiko/migrations");
|
|
37
|
+
mkdirSync(migrationsDir, { recursive: true });
|
|
38
|
+
writeFileSync(join(migrationsDir, `${migrationId}.sql`), "SELECT 1;\n");
|
|
39
|
+
return appCwd;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function mockDecryptFetch(
|
|
43
|
+
ciphertextToPlaintext: Readonly<Record<string, string>>,
|
|
44
|
+
requestedCiphertexts?: string[],
|
|
45
|
+
): typeof fetch {
|
|
46
|
+
return (async (_url: string, init?: RequestInit) => {
|
|
47
|
+
const body = JSON.parse(String(init?.body)) as { ciphertext: string };
|
|
48
|
+
requestedCiphertexts?.push(body.ciphertext);
|
|
49
|
+
const plaintext = ciphertextToPlaintext[body.ciphertext];
|
|
50
|
+
if (plaintext === undefined) {
|
|
51
|
+
throw new Error(`mockDecryptFetch: no plaintext mapped for ciphertext "${body.ciphertext}"`);
|
|
52
|
+
}
|
|
53
|
+
return new Response(JSON.stringify({ plaintext }), { status: 200 });
|
|
54
|
+
}) as typeof fetch;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const KMS_ENV_KEYS = [
|
|
58
|
+
"DATABASE_URL",
|
|
59
|
+
"SUBJECT_KEYS_DATABASE_URL",
|
|
60
|
+
"PLATFORM_KEK",
|
|
61
|
+
"PLATFORM_KEK_CIPHERTEXT",
|
|
62
|
+
"PLATFORM_KEK_PREVIOUS",
|
|
63
|
+
"PLATFORM_KEK_PREVIOUS_CIPHERTEXT",
|
|
64
|
+
"PLATFORM_KEK_PREVIOUS_VERSION",
|
|
65
|
+
"PLATFORM_KEK_KMS_KEY_ID",
|
|
66
|
+
"PLATFORM_KEK_KMS_TOKEN",
|
|
67
|
+
"PLATFORM_KEK_KMS_REGION",
|
|
68
|
+
"KUMIKO_BLIND_INDEX_KEY",
|
|
69
|
+
"KUMIKO_BLIND_INDEX_KEY_CIPHERTEXT",
|
|
70
|
+
] as const;
|
|
71
|
+
|
|
72
|
+
let testDb: TestDb;
|
|
73
|
+
let testDbUrl: string;
|
|
74
|
+
let prevEnv: Record<string, string | undefined>;
|
|
75
|
+
let prevFetch: typeof fetch;
|
|
76
|
+
|
|
77
|
+
beforeAll(async () => {
|
|
78
|
+
await ensureTemporalPolyfill();
|
|
79
|
+
testDb = await createTestDb();
|
|
80
|
+
const baseUrl =
|
|
81
|
+
process.env["TEST_DATABASE_URL"] ??
|
|
82
|
+
process.env["DATABASE_URL"] ??
|
|
83
|
+
"postgresql://kumiko:kumiko@localhost:15432/kumiko_test";
|
|
84
|
+
testDbUrl = baseUrl.replace(/\/[^/]+$/, `/${testDb.dbName}`);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
afterAll(async () => {
|
|
88
|
+
await testDb?.cleanup();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
beforeEach(() => {
|
|
92
|
+
prevEnv = {};
|
|
93
|
+
for (const key of KMS_ENV_KEYS) prevEnv[key] = process.env[key];
|
|
94
|
+
for (const key of KMS_ENV_KEYS) delete process.env[key];
|
|
95
|
+
process.env["DATABASE_URL"] = testDbUrl;
|
|
96
|
+
prevFetch = globalThis.fetch;
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
afterEach(() => {
|
|
100
|
+
for (const key of KMS_ENV_KEYS) {
|
|
101
|
+
if (prevEnv[key] === undefined) delete process.env[key];
|
|
102
|
+
else process.env[key] = prevEnv[key];
|
|
103
|
+
}
|
|
104
|
+
globalThis.fetch = prevFetch;
|
|
105
|
+
resetBlindIndexKeyForTests();
|
|
106
|
+
resetPiiSubjectKmsForTests();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("runSchemaCli apply — kmsSlots", () => {
|
|
110
|
+
test("resolves every declared slot from its Key Manager ciphertext", async () => {
|
|
111
|
+
process.env["SUBJECT_KEYS_DATABASE_URL"] = testDbUrl;
|
|
112
|
+
process.env["PLATFORM_KEK_CIPHERTEXT"] = PLATFORM_KEK_CIPHERTEXT;
|
|
113
|
+
process.env["KUMIKO_BLIND_INDEX_KEY_CIPHERTEXT"] = BLIND_INDEX_CIPHERTEXT;
|
|
114
|
+
process.env["PLATFORM_KEK_KMS_KEY_ID"] = "key-1";
|
|
115
|
+
process.env["PLATFORM_KEK_KMS_TOKEN"] = "token-1";
|
|
116
|
+
globalThis.fetch = mockDecryptFetch({
|
|
117
|
+
[PLATFORM_KEK_CIPHERTEXT]: PLATFORM_KEK_PLAINTEXT,
|
|
118
|
+
[BLIND_INDEX_CIPHERTEXT]: BLIND_INDEX_PLAINTEXT,
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const appCwd = writeAppWithTrivialMigration("0001_init");
|
|
122
|
+
const cap = captureOut();
|
|
123
|
+
const code = await runSchemaCli(["apply"], appCwd, cap.out, {
|
|
124
|
+
features: [feature],
|
|
125
|
+
kmsSlots: ["PLATFORM_KEK", "KUMIKO_BLIND_INDEX_KEY"],
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
expect(code).toBe(0);
|
|
129
|
+
expect(cap.log.join("\n")).toContain("PLATFORM_KEK source=key-manager");
|
|
130
|
+
expect(cap.log.join("\n")).toContain("KUMIKO_BLIND_INDEX_KEY source=key-manager");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("only resolves the declared slots — an undeclared _PREVIOUS ciphertext is left untouched", async () => {
|
|
134
|
+
process.env["SUBJECT_KEYS_DATABASE_URL"] = testDbUrl;
|
|
135
|
+
process.env["PLATFORM_KEK_CIPHERTEXT"] = PLATFORM_KEK_CIPHERTEXT;
|
|
136
|
+
process.env["PLATFORM_KEK_PREVIOUS_CIPHERTEXT"] = PLATFORM_KEK_PREVIOUS_CIPHERTEXT;
|
|
137
|
+
process.env["PLATFORM_KEK_KMS_KEY_ID"] = "key-1";
|
|
138
|
+
process.env["PLATFORM_KEK_KMS_TOKEN"] = "token-1";
|
|
139
|
+
process.env["KUMIKO_BLIND_INDEX_KEY"] = BLIND_INDEX_PLAINTEXT;
|
|
140
|
+
const fetchCalls: string[] = [];
|
|
141
|
+
globalThis.fetch = mockDecryptFetch(
|
|
142
|
+
{ [PLATFORM_KEK_CIPHERTEXT]: PLATFORM_KEK_PLAINTEXT },
|
|
143
|
+
fetchCalls,
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
const appCwd = writeAppWithTrivialMigration("0001_init");
|
|
147
|
+
const cap = captureOut();
|
|
148
|
+
const code = await runSchemaCli(["apply"], appCwd, cap.out, {
|
|
149
|
+
features: [feature],
|
|
150
|
+
kmsSlots: ["PLATFORM_KEK"],
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
expect(code).toBe(0);
|
|
154
|
+
expect(fetchCalls).toEqual([PLATFORM_KEK_CIPHERTEXT]);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("without kmsSlots, apply still resolves PLATFORM_KEK from its ciphertext (legacy default)", async () => {
|
|
158
|
+
process.env["SUBJECT_KEYS_DATABASE_URL"] = testDbUrl;
|
|
159
|
+
process.env["PLATFORM_KEK_CIPHERTEXT"] = PLATFORM_KEK_CIPHERTEXT;
|
|
160
|
+
process.env["KUMIKO_BLIND_INDEX_KEY"] = BLIND_INDEX_PLAINTEXT;
|
|
161
|
+
process.env["PLATFORM_KEK_KMS_KEY_ID"] = "key-1";
|
|
162
|
+
process.env["PLATFORM_KEK_KMS_TOKEN"] = "token-1";
|
|
163
|
+
globalThis.fetch = mockDecryptFetch({ [PLATFORM_KEK_CIPHERTEXT]: PLATFORM_KEK_PLAINTEXT });
|
|
164
|
+
|
|
165
|
+
const appCwd = writeAppWithTrivialMigration("0001_init");
|
|
166
|
+
const cap = captureOut();
|
|
167
|
+
const code = await runSchemaCli(["apply"], appCwd, cap.out, { features: [feature] });
|
|
168
|
+
|
|
169
|
+
expect(code).toBe(0);
|
|
170
|
+
expect(cap.log.join("\n")).toContain("PLATFORM_KEK source=key-manager");
|
|
171
|
+
});
|
|
172
|
+
});
|
|
@@ -28,7 +28,7 @@ describe("runSchemaCli — Temporal polyfill", () => {
|
|
|
28
28
|
// never retried on the next run.
|
|
29
29
|
//
|
|
30
30
|
// Asserting on globalThis.Temporal directly doesn't work here: the test
|
|
31
|
-
// harness's own preload (
|
|
31
|
+
// harness's own preload (@cosmicdrift/kumiko-testing/preload/temporal) already calls
|
|
32
32
|
// ensureTemporalPolyfill() once per process, and its idempotency cache
|
|
33
33
|
// (a module-level flag, not re-derived from globalThis) short-circuits
|
|
34
34
|
// any later call regardless of what a test does to globalThis.Temporal in
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,16 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.296.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "refEntity on projectionList/relatedList columns and projectionDetail fields is boot-checked against registered entities",
|
|
6
|
+
"migration": "A refEntity that does not resolve to a registered entity now fails boot with the target and the known entities of the target feature (same message as a reference facet). Fix the typo, or mount and r.requires() the target feature; test stacks booting a feature without its refEntity target feature must add it."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.296.0",
|
|
10
|
+
"type": "improvement",
|
|
11
|
+
"title": "kumiko schema apply can resolve the schema-declared Key Manager slots",
|
|
12
|
+
"detail": "`runSchemaCli`'s `apply` accepts a new `kmsSlots` option, passed straight through to `resolveKmsWiringAsync`'s `slots` for the rebuild-triggering KMS wiring. Given `kmsSlots`, `apply` resolves exactly those slots from their Key-Manager ciphertext (via `resolvePlatformKeks`); omitted, behavior is unchanged except that the source line it already logged now goes through the CLI's own `out.log` instead of `console.info`. `resolvePlatformKeks` will lose its three-platform-slot default in a later release, and only a caller that already passes its own slots stays unaffected when that happens. An app whose env schema declares its slots with `.meta({ kumiko: { kms: true } })` should pass `kmsSlots: kmsSlotsOf(<app>ComposedEnv.schema)` from its `bin/kumiko.ts` now. The framework core env schema does not declare `PLATFORM_KEK` / `PLATFORM_KEK_PREVIOUS` / `KUMIKO_BLIND_INDEX_KEY` as `kms` slots, so the scaffolded `bin/kumiko.ts` keeps relying on the default for now."
|
|
13
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"version": "0.294.1",
|
|
4
16
|
"type": "fix",
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { withBootValidatorFixture } from "../../testing/boot-validator-fixture";
|
|
4
|
+
import { validateBoot as validateBootRaw } from "../boot-validator";
|
|
5
|
+
import { defineFeature } from "../define-feature";
|
|
6
|
+
import { createEntity, createTextField } from "../factories";
|
|
7
|
+
|
|
8
|
+
function validateBoot(features: Parameters<typeof validateBootRaw>[0]): void {
|
|
9
|
+
validateBootRaw(withBootValidatorFixture(features));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const openToAllAccess = {
|
|
13
|
+
access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// fw#3108: refEntity on projectionList/relatedList columns and projectionDetail
|
|
17
|
+
// fields must resolve to a registered entity, same as a reference facet
|
|
18
|
+
// (fw#2224) — a typo or an unmounted target feature is otherwise a silent
|
|
19
|
+
// render-time break instead of a boot error.
|
|
20
|
+
describe("validateBoot — refEntity targets (fw#3108)", () => {
|
|
21
|
+
test("projectionList column with a typo'd refEntity throws", () => {
|
|
22
|
+
const feature = defineFeature("ledger", (r) => {
|
|
23
|
+
r.entity(
|
|
24
|
+
"tenant",
|
|
25
|
+
createEntity({
|
|
26
|
+
table: "Tenants",
|
|
27
|
+
fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
|
|
28
|
+
}),
|
|
29
|
+
);
|
|
30
|
+
r.queryHandler(
|
|
31
|
+
"schedule:list",
|
|
32
|
+
z.object({}),
|
|
33
|
+
async () => ({ rows: [], nextCursor: null }),
|
|
34
|
+
openToAllAccess,
|
|
35
|
+
);
|
|
36
|
+
r.screen({
|
|
37
|
+
id: "schedule-list",
|
|
38
|
+
type: "projectionList",
|
|
39
|
+
query: "ledger:query:schedule:list",
|
|
40
|
+
columns: [{ field: "tenantId", refEntity: "tenat" }],
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
expect(() => validateBoot([feature])).toThrow(
|
|
44
|
+
/column "tenantId" \(refEntity\) targets entity "tenat", which does not resolve to a registered entity\. Known entities in feature "ledger": tenant\./,
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("projectionList column refEntity targeting an unmounted feature throws with (none)", () => {
|
|
49
|
+
const feature = defineFeature("ledger", (r) => {
|
|
50
|
+
r.queryHandler(
|
|
51
|
+
"schedule:list",
|
|
52
|
+
z.object({}),
|
|
53
|
+
async () => ({ rows: [], nextCursor: null }),
|
|
54
|
+
openToAllAccess,
|
|
55
|
+
);
|
|
56
|
+
r.screen({
|
|
57
|
+
id: "schedule-list",
|
|
58
|
+
type: "projectionList",
|
|
59
|
+
query: "ledger:query:schedule:list",
|
|
60
|
+
columns: [{ field: "ownerId", refEntity: "owners:owner" }],
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
expect(() => validateBoot([feature])).toThrow(
|
|
64
|
+
/column "ownerId" \(refEntity\) targets entity "owners:owner".*Known entities in feature "owners": \(none\)\./,
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("projectionList column refEntity typo on a mounted feature lists that feature's entities", () => {
|
|
69
|
+
const owners = defineFeature("owners", (r) => {
|
|
70
|
+
r.entity(
|
|
71
|
+
"owner",
|
|
72
|
+
createEntity({
|
|
73
|
+
table: "Owners",
|
|
74
|
+
fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
|
|
75
|
+
}),
|
|
76
|
+
);
|
|
77
|
+
});
|
|
78
|
+
const feature = defineFeature("ledger", (r) => {
|
|
79
|
+
r.queryHandler(
|
|
80
|
+
"schedule:list",
|
|
81
|
+
z.object({}),
|
|
82
|
+
async () => ({ rows: [], nextCursor: null }),
|
|
83
|
+
openToAllAccess,
|
|
84
|
+
);
|
|
85
|
+
r.screen({
|
|
86
|
+
id: "schedule-list",
|
|
87
|
+
type: "projectionList",
|
|
88
|
+
query: "ledger:query:schedule:list",
|
|
89
|
+
columns: [{ field: "ownerId", refEntity: "owners:ownr" }],
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
expect(() => validateBoot([feature, owners])).toThrow(
|
|
93
|
+
/targets entity "owners:ownr".*Known entities in feature "owners": owner\./,
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("projectionDetail relatedList column with an unknown refEntity throws", () => {
|
|
98
|
+
const feature = defineFeature("app", (r) => {
|
|
99
|
+
r.queryHandler(
|
|
100
|
+
"rent:detail",
|
|
101
|
+
z.object({}),
|
|
102
|
+
async () => ({ description: "x" }),
|
|
103
|
+
openToAllAccess,
|
|
104
|
+
);
|
|
105
|
+
r.queryHandler(
|
|
106
|
+
"rent:payments",
|
|
107
|
+
z.object({}),
|
|
108
|
+
async () => ({ rows: [], nextCursor: null }),
|
|
109
|
+
openToAllAccess,
|
|
110
|
+
);
|
|
111
|
+
r.screen({
|
|
112
|
+
id: "rent-detail",
|
|
113
|
+
type: "projectionDetail",
|
|
114
|
+
query: "app:query:rent:detail",
|
|
115
|
+
layout: {
|
|
116
|
+
sections: [
|
|
117
|
+
{
|
|
118
|
+
kind: "relatedList",
|
|
119
|
+
title: "Payments",
|
|
120
|
+
query: "app:query:rent:payments",
|
|
121
|
+
columns: [{ field: "payerId", refEntity: "payer" }],
|
|
122
|
+
},
|
|
123
|
+
],
|
|
124
|
+
},
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
expect(() => validateBoot([feature])).toThrow(
|
|
128
|
+
/section "Payments" \(relatedList\) column "payerId" \(refEntity\) targets entity "payer", which does not resolve to a registered entity/,
|
|
129
|
+
);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("projectionDetail field-section field with an unknown refEntity throws", () => {
|
|
133
|
+
const feature = defineFeature("app", (r) => {
|
|
134
|
+
r.queryHandler(
|
|
135
|
+
"rent:detail",
|
|
136
|
+
z.object({}),
|
|
137
|
+
async () => ({ description: "x" }),
|
|
138
|
+
openToAllAccess,
|
|
139
|
+
);
|
|
140
|
+
r.screen({
|
|
141
|
+
id: "rent-detail",
|
|
142
|
+
type: "projectionDetail",
|
|
143
|
+
query: "app:query:rent:detail",
|
|
144
|
+
layout: {
|
|
145
|
+
sections: [
|
|
146
|
+
{
|
|
147
|
+
id: "overview",
|
|
148
|
+
title: "Overview",
|
|
149
|
+
fields: [{ field: "ownerId", refEntity: "owner" }],
|
|
150
|
+
},
|
|
151
|
+
],
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
expect(() => validateBoot([feature])).toThrow(
|
|
156
|
+
/field "ownerId" \(refEntity\) targets entity "owner", which does not resolve to a registered entity/,
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("same-feature shorthand and cross-feature refEntity targets pass boot", () => {
|
|
161
|
+
const owners = defineFeature("owners", (r) => {
|
|
162
|
+
r.entity(
|
|
163
|
+
"owner",
|
|
164
|
+
createEntity({
|
|
165
|
+
table: "Owners",
|
|
166
|
+
fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
|
|
167
|
+
}),
|
|
168
|
+
);
|
|
169
|
+
});
|
|
170
|
+
const feature = defineFeature("ledger", (r) => {
|
|
171
|
+
r.entity(
|
|
172
|
+
"tenant",
|
|
173
|
+
createEntity({
|
|
174
|
+
table: "Tenants",
|
|
175
|
+
fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
|
|
176
|
+
}),
|
|
177
|
+
);
|
|
178
|
+
r.queryHandler(
|
|
179
|
+
"schedule:list",
|
|
180
|
+
z.object({}),
|
|
181
|
+
async () => ({ rows: [], nextCursor: null }),
|
|
182
|
+
openToAllAccess,
|
|
183
|
+
);
|
|
184
|
+
r.screen({
|
|
185
|
+
id: "schedule-list",
|
|
186
|
+
type: "projectionList",
|
|
187
|
+
query: "ledger:query:schedule:list",
|
|
188
|
+
columns: [
|
|
189
|
+
{ field: "tenantId", refEntity: "tenant" },
|
|
190
|
+
{ field: "ownerId", refEntity: "owners:owner" },
|
|
191
|
+
],
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
expect(() => validateBoot([feature, owners])).not.toThrow();
|
|
195
|
+
});
|
|
196
|
+
});
|
|
@@ -98,7 +98,10 @@ describe("validateBoot — projectionDetail tabs (fw record-layout)", () => {
|
|
|
98
98
|
expect(() => validateBoot([feature])).toThrow(/duplicate tab id "overview"/);
|
|
99
99
|
});
|
|
100
100
|
|
|
101
|
-
|
|
101
|
+
// fw#3134: entityEdit joined projectionDetail as a tabs host. It renders
|
|
102
|
+
// every tab mounted and validates across all of them on submit, so a
|
|
103
|
+
// required field on an unopened tab surfaces instead of blocking silently.
|
|
104
|
+
test("mode: tabs on entityEdit boots", () => {
|
|
102
105
|
const feature = defineFeature("app", (r) => {
|
|
103
106
|
r.entity(
|
|
104
107
|
"rent",
|
|
@@ -119,8 +122,82 @@ describe("validateBoot — projectionDetail tabs (fw record-layout)", () => {
|
|
|
119
122
|
},
|
|
120
123
|
});
|
|
121
124
|
});
|
|
125
|
+
expect(() => validateBoot([feature])).not.toThrow();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("mode: tabs on entityEdit enforces the same per-tab id as projectionDetail", () => {
|
|
129
|
+
const feature = defineFeature("app", (r) => {
|
|
130
|
+
r.entity(
|
|
131
|
+
"rent",
|
|
132
|
+
createEntity({
|
|
133
|
+
fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
|
|
134
|
+
}),
|
|
135
|
+
);
|
|
136
|
+
r.screen({
|
|
137
|
+
id: "rent-edit",
|
|
138
|
+
type: "entityEdit",
|
|
139
|
+
entity: "rent",
|
|
140
|
+
layout: {
|
|
141
|
+
mode: "tabs",
|
|
142
|
+
sections: [
|
|
143
|
+
{ title: "Overview", columns: 1, fields: ["name"] },
|
|
144
|
+
{ id: "history", title: "History", columns: 1, fields: ["name"] },
|
|
145
|
+
],
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
expect(() => validateBoot([feature])).toThrow(
|
|
150
|
+
/Screen "rent-edit" \(entityEdit\).*sections\[0\] \("Overview"\) has no id/s,
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("mode: tabs on entityEdit with a single section throws", () => {
|
|
155
|
+
const feature = defineFeature("app", (r) => {
|
|
156
|
+
r.entity(
|
|
157
|
+
"rent",
|
|
158
|
+
createEntity({
|
|
159
|
+
fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
|
|
160
|
+
}),
|
|
161
|
+
);
|
|
162
|
+
r.screen({
|
|
163
|
+
id: "rent-edit",
|
|
164
|
+
type: "entityEdit",
|
|
165
|
+
entity: "rent",
|
|
166
|
+
layout: {
|
|
167
|
+
mode: "tabs",
|
|
168
|
+
sections: [{ id: "overview", title: "Overview", columns: 1, fields: ["name"] }],
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
expect(() => validateBoot([feature])).toThrow(/tabs need at least 2 sections/);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// The other three edit-screen types have no jump-to-erroring-tab path, so a
|
|
176
|
+
// required field on a hidden tab would still block their submit in silence.
|
|
177
|
+
test("mode: tabs on actionForm still throws", () => {
|
|
178
|
+
const feature = defineFeature("app", (r) => {
|
|
179
|
+
r.writeHandler(
|
|
180
|
+
"archive",
|
|
181
|
+
z.object({ reason: z.string() }),
|
|
182
|
+
async () => ({ isSuccess: true as const, data: {} }),
|
|
183
|
+
{ access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
|
|
184
|
+
);
|
|
185
|
+
r.screen({
|
|
186
|
+
id: "rent-action",
|
|
187
|
+
type: "actionForm",
|
|
188
|
+
handler: "app:write:archive",
|
|
189
|
+
fields: { reason: { type: "text" } } as never,
|
|
190
|
+
layout: {
|
|
191
|
+
mode: "tabs",
|
|
192
|
+
sections: [
|
|
193
|
+
{ id: "overview", title: "Overview", columns: 1, fields: ["reason"] },
|
|
194
|
+
{ id: "history", title: "History", columns: 1, fields: ["reason"] },
|
|
195
|
+
] as never,
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
});
|
|
122
199
|
expect(() => validateBoot([feature])).toThrow(
|
|
123
|
-
/Screen "rent-
|
|
200
|
+
/Screen "rent-action" \(actionForm\) sets mode: "tabs" — tabs are only supported on projectionDetail and entityEdit/,
|
|
124
201
|
);
|
|
125
202
|
});
|
|
126
203
|
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
// fw#3088 — boot guard for the tenant-handover transfer graph. Both shapes
|
|
2
|
+
// checked here would otherwise leave rows behind in the source tenant at
|
|
3
|
+
// handover time, which is the silent partial move the issue exists to end.
|
|
4
|
+
// Scoped to `transferable: true`, so a consumer that has the shape but never
|
|
5
|
+
// hands the entity over keeps booting.
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from "bun:test";
|
|
8
|
+
import { defineFeature } from "../../define-feature";
|
|
9
|
+
import { createEntity, createTextField } from "../../factories";
|
|
10
|
+
import type { EntityDefinition, FeatureDefinition } from "../../types";
|
|
11
|
+
import { MAX_TRANSFER_DEPTH, validateTransferGraph } from "../transfer-graph";
|
|
12
|
+
|
|
13
|
+
const textField = () => createTextField({ personal: false, reason: "technical_reference" });
|
|
14
|
+
|
|
15
|
+
function entity(opts: {
|
|
16
|
+
readonly references?: Readonly<Record<string, { entity: string; multiple?: true }>>;
|
|
17
|
+
readonly transferable?: boolean;
|
|
18
|
+
}): EntityDefinition {
|
|
19
|
+
const references = Object.fromEntries(
|
|
20
|
+
Object.entries(opts.references ?? {}).map(([field, ref]) => [
|
|
21
|
+
field,
|
|
22
|
+
{ type: "reference" as const, entity: ref.entity, ...(ref.multiple && { multiple: true }) },
|
|
23
|
+
]),
|
|
24
|
+
);
|
|
25
|
+
return createEntity({
|
|
26
|
+
table: "graph_rows",
|
|
27
|
+
...(opts.transferable !== false && { transferable: true }),
|
|
28
|
+
fields: { ...references, label: textField() },
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function validate(entities: Readonly<Record<string, EntityDefinition>>): void {
|
|
33
|
+
const feature = defineFeature("graph", (r) => {
|
|
34
|
+
for (const [name, def] of Object.entries(entities)) r.entity(name, def);
|
|
35
|
+
});
|
|
36
|
+
const featureMap: ReadonlyMap<string, FeatureDefinition> = new Map([[feature.name, feature]]);
|
|
37
|
+
validateTransferGraph(feature, featureMap);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("validateTransferGraph", () => {
|
|
41
|
+
test("accepts a nested reference chain within the depth limit", () => {
|
|
42
|
+
expect(() =>
|
|
43
|
+
validate({
|
|
44
|
+
run: entity({}),
|
|
45
|
+
campaign: entity({ references: { runId: { entity: "run" } } }),
|
|
46
|
+
channelText: entity({ references: { campaignId: { entity: "campaign" } } }),
|
|
47
|
+
}),
|
|
48
|
+
).not.toThrow();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("rejects a multiple reference on a transferable entity", () => {
|
|
52
|
+
expect(() =>
|
|
53
|
+
validate({
|
|
54
|
+
run: entity({}),
|
|
55
|
+
bulk: entity({ references: { runIds: { entity: "run", multiple: true } } }),
|
|
56
|
+
}),
|
|
57
|
+
).toThrow(/multiple reference field "runIds"/);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// The narrow scope is the point: boot-rejecting every multiple reference
|
|
61
|
+
// would break consumers who never hand that entity over.
|
|
62
|
+
test("leaves a multiple reference alone when the entity is not transferable", () => {
|
|
63
|
+
expect(() =>
|
|
64
|
+
validate({
|
|
65
|
+
run: entity({}),
|
|
66
|
+
bulk: entity({
|
|
67
|
+
references: { runIds: { entity: "run", multiple: true } },
|
|
68
|
+
transferable: false,
|
|
69
|
+
}),
|
|
70
|
+
}),
|
|
71
|
+
).not.toThrow();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// The boundary the mover actually draws: it runs MAX_TRANSFER_DEPTH rounds of
|
|
75
|
+
// one hop each, so 5 edges (6 entities) still move as one graph and the
|
|
76
|
+
// validator must not reject them. Off by one here and a supported schema
|
|
77
|
+
// stops booting.
|
|
78
|
+
test("accepts a chain of exactly the maximum depth", () => {
|
|
79
|
+
const chain: Record<string, EntityDefinition> = { e0: entity({}) };
|
|
80
|
+
for (let i = 1; i <= MAX_TRANSFER_DEPTH; i++) {
|
|
81
|
+
chain[`e${i}`] = entity({ references: { parentId: { entity: `e${i - 1}` } } });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
expect(() => validate(chain)).not.toThrow();
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("rejects a chain one edge past the limit and names the path", () => {
|
|
88
|
+
const chain: Record<string, EntityDefinition> = { e0: entity({}) };
|
|
89
|
+
for (let i = 1; i <= MAX_TRANSFER_DEPTH + 1; i++) {
|
|
90
|
+
chain[`e${i}`] = entity({ references: { parentId: { entity: `e${i - 1}` } } });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
expect(() => validate(chain)).toThrow(/deeper than the 5-level limit.*e0 -> e1/s);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("rejects a chain far deeper than the limit and names the path", () => {
|
|
97
|
+
const chain: Record<string, EntityDefinition> = { e0: entity({}) };
|
|
98
|
+
for (let i = 1; i <= 7; i++) {
|
|
99
|
+
chain[`e${i}`] = entity({ references: { parentId: { entity: `e${i - 1}` } } });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
expect(() => validate(chain)).toThrow(/deeper than the 5-level limit.*e0 -> e1/s);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("does not count a non-transferable link as part of the chain", () => {
|
|
106
|
+
const chain: Record<string, EntityDefinition> = { e0: entity({}) };
|
|
107
|
+
for (let i = 1; i <= 7; i++) {
|
|
108
|
+
chain[`e${i}`] = entity({
|
|
109
|
+
references: { parentId: { entity: `e${i - 1}` } },
|
|
110
|
+
// Breaks the chain in the middle: the walk stops here.
|
|
111
|
+
...(i === 3 && { transferable: false }),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
expect(() => validate(chain)).not.toThrow();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("terminates on a reference cycle instead of reporting false depth", () => {
|
|
119
|
+
expect(() =>
|
|
120
|
+
validate({
|
|
121
|
+
a: entity({ references: { bId: { entity: "b" } } }),
|
|
122
|
+
b: entity({ references: { aId: { entity: "a" } } }),
|
|
123
|
+
}),
|
|
124
|
+
).not.toThrow();
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -67,6 +67,7 @@ import {
|
|
|
67
67
|
validateScreens,
|
|
68
68
|
} from "./screens";
|
|
69
69
|
import { warnOnMissingSecurityBaseline } from "./security-baseline";
|
|
70
|
+
import { validateTransferGraph } from "./transfer-graph";
|
|
70
71
|
import {
|
|
71
72
|
collectWorkspaceQns,
|
|
72
73
|
resolveNavAllowlist,
|
|
@@ -80,6 +81,7 @@ export { validateAppCustomScreenWriteQns } from "./custom-screen-write-qns";
|
|
|
80
81
|
// dieselbe Extraktionslogik.
|
|
81
82
|
export { collectWriteHandlerQns } from "./nav";
|
|
82
83
|
export { SECURITY_BASELINE_FEATURE_NAMES } from "./security-baseline";
|
|
84
|
+
export { MAX_TRANSFER_DEPTH } from "./transfer-graph";
|
|
83
85
|
|
|
84
86
|
export type ValidateBootOptions = {
|
|
85
87
|
/** Warn when an access role is used by exactly one handler/config-key/
|
|
@@ -227,6 +229,7 @@ export function validateBoot(
|
|
|
227
229
|
validateConfigKeyPiiEncrypted(feature);
|
|
228
230
|
validateOwnershipRules(feature, allClaimKeys, knownRoles);
|
|
229
231
|
validateParentRefs(feature, featureMap);
|
|
232
|
+
validateTransferGraph(feature, featureMap);
|
|
230
233
|
validateMultiStreamProjections(feature);
|
|
231
234
|
// Vor validateScreens: dessen visible/entityId-Feldref-Checks werfen für
|
|
232
235
|
// einen Function-Wert bereits (mit verwirrender "unknown field undefined"-
|
|
@@ -197,6 +197,52 @@ function validateRowActionNavigateParams(
|
|
|
197
197
|
}
|
|
198
198
|
}
|
|
199
199
|
|
|
200
|
+
// Shared by projectionDetail and entityEdit: a tab strip needs something to
|
|
201
|
+
// label each tab with and a stable id per tab — the id anchors the ?tab=
|
|
202
|
+
// param on projectionDetail and the jump-to-erroring-tab logic on entityEdit
|
|
203
|
+
// (fw#3134).
|
|
204
|
+
function validateTabSections(
|
|
205
|
+
featureName: string,
|
|
206
|
+
screenId: string,
|
|
207
|
+
screenType: "projectionDetail" | "entityEdit",
|
|
208
|
+
sections: readonly { readonly id?: string; readonly title?: string }[],
|
|
209
|
+
): void {
|
|
210
|
+
if (sections.length < 2) {
|
|
211
|
+
throw new Error(
|
|
212
|
+
`[Feature ${featureName}] Screen "${screenId}" (${screenType}) has mode: "tabs" but only ` +
|
|
213
|
+
`${sections.length} section(s) — tabs need at least 2 sections.`,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
const tabIds = new Set<string>();
|
|
217
|
+
sections.forEach((section, index) => {
|
|
218
|
+
if (section.title === undefined || section.title.trim().length === 0) {
|
|
219
|
+
throw new Error(
|
|
220
|
+
`[Feature ${featureName}] Screen "${screenId}" (${screenType}) has mode: "tabs" but ` +
|
|
221
|
+
`sections[${index}] has no title — every tab needs a title.`,
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
if (section.id === undefined || section.id.trim().length === 0) {
|
|
225
|
+
throw new Error(
|
|
226
|
+
`[Feature ${featureName}] Screen "${screenId}" (${screenType}) has mode: "tabs" but ` +
|
|
227
|
+
`sections[${index}] ("${section.title}") has no id — every tab needs a stable id.`,
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
if (!isKebabSegment(section.id)) {
|
|
231
|
+
throw new Error(
|
|
232
|
+
`[Feature ${featureName}] Screen "${screenId}" (${screenType}) sections[${index}] ` +
|
|
233
|
+
`("${section.title}") has id "${section.id}" — must be kebab-case.`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
if (tabIds.has(section.id)) {
|
|
237
|
+
throw new Error(
|
|
238
|
+
`[Feature ${featureName}] Screen "${screenId}" (${screenType}) has duplicate tab id ` +
|
|
239
|
+
`"${section.id}" (sections[${index}]).`,
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
tabIds.add(section.id);
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
200
246
|
// Wizard layouts (mode: "wizard") need >= 2 titled sections — a single or
|
|
201
247
|
// untitled step would leave the progress indicator blank, so both fail at
|
|
202
248
|
// boot rather than as a broken step UI.
|
|
@@ -207,16 +253,19 @@ function validateWizardLayout(
|
|
|
207
253
|
layout: EditLayout,
|
|
208
254
|
featureMap: ReadonlyMap<string, FeatureDefinition>,
|
|
209
255
|
): void {
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
//
|
|
256
|
+
// entityEdit renders tabs like wizard steps: every section stays mounted,
|
|
257
|
+
// submit validates across all of them, and a field error jumps to the tab
|
|
258
|
+
// holding it (fw#3134). The other three have no such jump — actionForm and
|
|
259
|
+
// secretMint are one-shot forms, configEdit fires a write per field — so a
|
|
260
|
+
// required field on a hidden tab would still block their submit silently.
|
|
215
261
|
if (layout.mode === "tabs") {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
`
|
|
219
|
-
|
|
262
|
+
if (screenType !== "entityEdit") {
|
|
263
|
+
throw new Error(
|
|
264
|
+
`[Feature ${featureName}] Screen "${screenId}" (${screenType}) sets mode: "tabs" — tabs are only ` +
|
|
265
|
+
`supported on projectionDetail and entityEdit. Use mode: "wizard" or "single" instead.`,
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
validateTabSections(featureName, screenId, screenType, layout.sections);
|
|
220
269
|
}
|
|
221
270
|
// "form-draft" is hardcoded because the framework layer must not depend on
|
|
222
271
|
// @cosmicdrift/kumiko-bundled-features — same precedence as the
|
|
@@ -961,7 +1010,17 @@ export function validateScreens(
|
|
|
961
1010
|
);
|
|
962
1011
|
}
|
|
963
1012
|
for (const col of screen.columns) {
|
|
964
|
-
|
|
1013
|
+
const normalizedCol = normalizeListColumn(col);
|
|
1014
|
+
validateColumnRendererForm(feature.name, screenId, normalizedCol);
|
|
1015
|
+
if (normalizedCol.refEntity !== undefined) {
|
|
1016
|
+
assertRefTargetRegistered(
|
|
1017
|
+
`[Feature ${feature.name}] Screen "${screenId}" (projectionList)`,
|
|
1018
|
+
`column "${normalizedCol.field}" (refEntity)`,
|
|
1019
|
+
normalizedCol.refEntity,
|
|
1020
|
+
feature.name,
|
|
1021
|
+
featureMap,
|
|
1022
|
+
);
|
|
1023
|
+
}
|
|
965
1024
|
}
|
|
966
1025
|
// Screen filter (fw#2224) — field existence can't be checked without
|
|
967
1026
|
// an entity (columns aren't a complete field inventory of the
|
|
@@ -1077,41 +1136,7 @@ export function validateScreens(
|
|
|
1077
1136
|
);
|
|
1078
1137
|
}
|
|
1079
1138
|
if (screen.layout.mode === "tabs") {
|
|
1080
|
-
|
|
1081
|
-
throw new Error(
|
|
1082
|
-
`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) has mode: "tabs" but only ` +
|
|
1083
|
-
`${screen.layout.sections.length} section(s) — tabs need at least 2 sections.`,
|
|
1084
|
-
);
|
|
1085
|
-
}
|
|
1086
|
-
const tabIds = new Set<string>();
|
|
1087
|
-
screen.layout.sections.forEach((section, index) => {
|
|
1088
|
-
if (section.title === undefined || section.title.trim().length === 0) {
|
|
1089
|
-
throw new Error(
|
|
1090
|
-
`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) has mode: "tabs" but ` +
|
|
1091
|
-
`sections[${index}] has no title — every tab needs a title.`,
|
|
1092
|
-
);
|
|
1093
|
-
}
|
|
1094
|
-
if (section.id === undefined || section.id.trim().length === 0) {
|
|
1095
|
-
throw new Error(
|
|
1096
|
-
`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) has mode: "tabs" but ` +
|
|
1097
|
-
`sections[${index}] ("${section.title}") has no id — every tab needs a stable id for ` +
|
|
1098
|
-
`the ?tab= param.`,
|
|
1099
|
-
);
|
|
1100
|
-
}
|
|
1101
|
-
if (!isKebabSegment(section.id)) {
|
|
1102
|
-
throw new Error(
|
|
1103
|
-
`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) sections[${index}] ` +
|
|
1104
|
-
`("${section.title}") has id "${section.id}" — must be kebab-case.`,
|
|
1105
|
-
);
|
|
1106
|
-
}
|
|
1107
|
-
if (tabIds.has(section.id)) {
|
|
1108
|
-
throw new Error(
|
|
1109
|
-
`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) has duplicate tab id ` +
|
|
1110
|
-
`"${section.id}" (sections[${index}]).`,
|
|
1111
|
-
);
|
|
1112
|
-
}
|
|
1113
|
-
tabIds.add(section.id);
|
|
1114
|
-
});
|
|
1139
|
+
validateTabSections(feature.name, screenId, "projectionDetail", screen.layout.sections);
|
|
1115
1140
|
}
|
|
1116
1141
|
if (screen.metrics !== undefined) {
|
|
1117
1142
|
for (const metric of screen.metrics) {
|
|
@@ -1181,6 +1206,18 @@ export function validateScreens(
|
|
|
1181
1206
|
`(relatedList) has empty or non-string query.`,
|
|
1182
1207
|
);
|
|
1183
1208
|
}
|
|
1209
|
+
for (const col of section.columns) {
|
|
1210
|
+
const normalizedCol = normalizeListColumn(col);
|
|
1211
|
+
if (normalizedCol.refEntity !== undefined) {
|
|
1212
|
+
assertRefTargetRegistered(
|
|
1213
|
+
`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" (relatedList)`,
|
|
1214
|
+
`column "${normalizedCol.field}" (refEntity)`,
|
|
1215
|
+
normalizedCol.refEntity,
|
|
1216
|
+
feature.name,
|
|
1217
|
+
featureMap,
|
|
1218
|
+
);
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1184
1221
|
if (screen.layout.mode === "wizard") {
|
|
1185
1222
|
throw new Error(
|
|
1186
1223
|
`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail) section "${section.title}" ` +
|
|
@@ -1346,6 +1383,18 @@ export function validateScreens(
|
|
|
1346
1383
|
`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail)`,
|
|
1347
1384
|
section,
|
|
1348
1385
|
);
|
|
1386
|
+
for (const fieldSpec of sectionFieldSpecs(section)) {
|
|
1387
|
+
const normalizedField = normalizeEditField(fieldSpec);
|
|
1388
|
+
if (normalizedField.refEntity !== undefined) {
|
|
1389
|
+
assertRefTargetRegistered(
|
|
1390
|
+
`[Feature ${feature.name}] Screen "${screenId}" (projectionDetail)`,
|
|
1391
|
+
`field "${normalizedField.field}" (refEntity)`,
|
|
1392
|
+
normalizedField.refEntity,
|
|
1393
|
+
feature.name,
|
|
1394
|
+
featureMap,
|
|
1395
|
+
);
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1349
1398
|
}
|
|
1350
1399
|
// Header actions reuse RowAction (the displayed record stands in for
|
|
1351
1400
|
// the row), so the same navigate/writeHandler existence checks as
|
|
@@ -2181,6 +2230,30 @@ export function validateColumnRendererForm(
|
|
|
2181
2230
|
}
|
|
2182
2231
|
}
|
|
2183
2232
|
|
|
2233
|
+
// Shared entity-target resolution for reference facets, refEntity columns
|
|
2234
|
+
// (projectionList/relatedList) and refEntity fields (projectionDetail).
|
|
2235
|
+
function assertRefTargetRegistered(
|
|
2236
|
+
prefix: string,
|
|
2237
|
+
subject: string,
|
|
2238
|
+
refTarget: string,
|
|
2239
|
+
currentFeatureName: string,
|
|
2240
|
+
featureMap: ReadonlyMap<string, FeatureDefinition>,
|
|
2241
|
+
): void {
|
|
2242
|
+
const target = parseRefTarget(refTarget, currentFeatureName);
|
|
2243
|
+
const targetFeature = featureMap.get(target.featureName);
|
|
2244
|
+
if (targetFeature?.entities?.[target.entityName] === undefined) {
|
|
2245
|
+
throw new Error(
|
|
2246
|
+
`${prefix} ${subject} targets entity "${refTarget}", which does not resolve to a ` +
|
|
2247
|
+
`registered entity. Known entities in feature "${target.featureName}": ` +
|
|
2248
|
+
`${
|
|
2249
|
+
Object.keys(targetFeature?.entities ?? {})
|
|
2250
|
+
.sort()
|
|
2251
|
+
.join(", ") || "(none)"
|
|
2252
|
+
}.`,
|
|
2253
|
+
);
|
|
2254
|
+
}
|
|
2255
|
+
}
|
|
2256
|
+
|
|
2184
2257
|
// Facets (fw#2224) — unlike filter, a field inventory IS available here: the
|
|
2185
2258
|
// declared columns. A facet on a field with no column is almost always a
|
|
2186
2259
|
// typo (the user never sees the field anywhere), so this is hard-checked
|
|
@@ -2226,20 +2299,13 @@ function validateListFacets(
|
|
|
2226
2299
|
);
|
|
2227
2300
|
}
|
|
2228
2301
|
if (facet.type === "reference") {
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
`${
|
|
2237
|
-
Object.keys(targetFeature?.entities ?? {})
|
|
2238
|
-
.sort()
|
|
2239
|
-
.join(", ") || "(none)"
|
|
2240
|
-
}.`,
|
|
2241
|
-
);
|
|
2242
|
-
}
|
|
2302
|
+
assertRefTargetRegistered(
|
|
2303
|
+
prefix,
|
|
2304
|
+
`facet "${facet.field}" (type "reference")`,
|
|
2305
|
+
facet.entity,
|
|
2306
|
+
currentFeatureName,
|
|
2307
|
+
featureMap,
|
|
2308
|
+
);
|
|
2243
2309
|
}
|
|
2244
2310
|
}
|
|
2245
2311
|
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { EntityDefinition, FeatureDefinition } from "../types";
|
|
2
|
+
|
|
3
|
+
// --- Transfer-graph boot validation (fw#3088) ---
|
|
4
|
+
//
|
|
5
|
+
// tenant-handover walks an entity graph across the tenant boundary along two
|
|
6
|
+
// declared edge kinds: `parentRef` and plain `reference` fields. Two shapes
|
|
7
|
+
// cannot be walked, and both have to fail loudly here rather than silently
|
|
8
|
+
// leaving rows behind in the source tenant — the partial move is exactly what
|
|
9
|
+
// #3088 exists to end.
|
|
10
|
+
//
|
|
11
|
+
// Scoped to entities declaring `transferable: true`, so a consumer that has
|
|
12
|
+
// one of these shapes but never hands that entity over is unaffected.
|
|
13
|
+
|
|
14
|
+
// The single definition of the limit. It lives here rather than next to the
|
|
15
|
+
// resolver in bundled-features because the dependency only runs that way:
|
|
16
|
+
// bundled-features imports from the framework, never the reverse.
|
|
17
|
+
//
|
|
18
|
+
// Guards against a schema whose reference edges span more levels than anyone
|
|
19
|
+
// intended — a runaway graph would move rows an operator never associated with
|
|
20
|
+
// the handover. Deliberately a constant and not per-feature config: the limit
|
|
21
|
+
// is a safety net, and a configurable one gets raised by whoever trips it
|
|
22
|
+
// instead of prompting them to reconsider their graph.
|
|
23
|
+
export const MAX_TRANSFER_DEPTH = 5;
|
|
24
|
+
|
|
25
|
+
type EntityEntry = { readonly name: string; readonly entity: EntityDefinition };
|
|
26
|
+
|
|
27
|
+
function allEntities(featureMap: ReadonlyMap<string, FeatureDefinition>): readonly EntityEntry[] {
|
|
28
|
+
const out: EntityEntry[] = [];
|
|
29
|
+
for (const feature of featureMap.values()) {
|
|
30
|
+
for (const [name, entity] of Object.entries(feature.entities ?? {})) {
|
|
31
|
+
out.push({ name, entity });
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return out;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function referenceTargets(entity: EntityDefinition): readonly string[] {
|
|
38
|
+
const targets: string[] = [];
|
|
39
|
+
for (const field of Object.values(entity.fields)) {
|
|
40
|
+
if (field.type !== "reference") continue;
|
|
41
|
+
if (field.multiple === true) continue;
|
|
42
|
+
targets.push(field.entity);
|
|
43
|
+
}
|
|
44
|
+
return targets;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// A `multiple` reference stores a jsonb array of ids, which the handover's
|
|
48
|
+
// `= ANY($ids)` column match cannot address. Declaring one on a transferable
|
|
49
|
+
// entity would mean its rows never move with their host.
|
|
50
|
+
function validateNoMultipleReferenceEdge(entry: EntityEntry, featureName: string): void {
|
|
51
|
+
// skip: the entity never travels, so the shape that would strand its rows
|
|
52
|
+
// during a handover cannot arise — rejecting it would break consumers that
|
|
53
|
+
// legitimately declare a multiple reference on a non-transferable entity.
|
|
54
|
+
if (entry.entity.transferable !== true) return;
|
|
55
|
+
for (const [fieldName, field] of Object.entries(entry.entity.fields)) {
|
|
56
|
+
if (field.type !== "reference" || field.multiple !== true) continue;
|
|
57
|
+
throw new Error(
|
|
58
|
+
`[Kumiko TransferGraph] entity "${entry.name}" declares transferable: true and a ` +
|
|
59
|
+
`multiple reference field "${fieldName}" -> "${field.entity}" (feature: "${featureName}"). ` +
|
|
60
|
+
`A multiple reference stores a jsonb array, which the tenant-handover transfer graph ` +
|
|
61
|
+
`cannot match rows on — those rows would stay behind in the source tenant. ` +
|
|
62
|
+
`Fix: model the link as a single reference on the owning side, or drop transferable ` +
|
|
63
|
+
`from "${entry.name}".`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Depth is measured over reference edges between transferable entities, the
|
|
69
|
+
// only chain that nests (parentRef is one level by construction — see
|
|
70
|
+
// engine/boot-validator/parent-ref.ts).
|
|
71
|
+
//
|
|
72
|
+
// `onPath` counts NODES, the limit counts EDGES: a chain of exactly
|
|
73
|
+
// MAX_TRANSFER_DEPTH edges holds MAX_TRANSFER_DEPTH + 1 entities, and the
|
|
74
|
+
// mover runs MAX_TRANSFER_DEPTH rounds of one hop each, so it still walks that
|
|
75
|
+
// chain whole. Rejecting it here would make a schema the mover handles
|
|
76
|
+
// correctly refuse to boot.
|
|
77
|
+
function longestTransferableChain(
|
|
78
|
+
startName: string,
|
|
79
|
+
bySource: ReadonlyMap<string, readonly string[]>,
|
|
80
|
+
onPath: readonly string[],
|
|
81
|
+
): readonly string[] | undefined {
|
|
82
|
+
if (onPath.length > MAX_TRANSFER_DEPTH + 1) return onPath;
|
|
83
|
+
for (const target of bySource.get(startName) ?? []) {
|
|
84
|
+
// skip: a cycle revisits a type already on this path, and schema depth
|
|
85
|
+
// cannot measure how far it actually runs — that depends on the rows, not
|
|
86
|
+
// the declaration. The mover carries this one instead, failing with
|
|
87
|
+
// `transfer_graph_too_deep` when its rounds run out (#3131).
|
|
88
|
+
if (onPath.includes(target)) continue;
|
|
89
|
+
const deeper = longestTransferableChain(target, bySource, [...onPath, target]);
|
|
90
|
+
if (deeper !== undefined) return deeper;
|
|
91
|
+
}
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function validateTransferGraph(
|
|
96
|
+
feature: FeatureDefinition,
|
|
97
|
+
featureMap: ReadonlyMap<string, FeatureDefinition>,
|
|
98
|
+
): void {
|
|
99
|
+
for (const [name, entity] of Object.entries(feature.entities ?? {})) {
|
|
100
|
+
validateNoMultipleReferenceEdge({ name, entity }, feature.name);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const transferable = new Set(
|
|
104
|
+
allEntities(featureMap)
|
|
105
|
+
.filter((e) => e.entity.transferable === true)
|
|
106
|
+
.map((e) => e.name),
|
|
107
|
+
);
|
|
108
|
+
// Edges point child -> parent in the schema; the graph walks parent -> child,
|
|
109
|
+
// so the lookup is inverted here.
|
|
110
|
+
const childrenByParent = new Map<string, string[]>();
|
|
111
|
+
for (const { name, entity } of allEntities(featureMap)) {
|
|
112
|
+
if (!transferable.has(name)) continue;
|
|
113
|
+
for (const target of referenceTargets(entity)) {
|
|
114
|
+
if (!transferable.has(target)) continue;
|
|
115
|
+
const children = childrenByParent.get(target) ?? [];
|
|
116
|
+
children.push(name);
|
|
117
|
+
childrenByParent.set(target, children);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
for (const [name, entity] of Object.entries(feature.entities ?? {})) {
|
|
122
|
+
if (entity.transferable !== true) continue;
|
|
123
|
+
const tooDeep = longestTransferableChain(name, childrenByParent, [name]);
|
|
124
|
+
if (tooDeep !== undefined) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
`[Kumiko TransferGraph] the transfer graph rooted at entity "${name}" is deeper than ` +
|
|
127
|
+
`the ${MAX_TRANSFER_DEPTH}-level limit (feature: "${feature.name}"): ` +
|
|
128
|
+
`${tooDeep.join(" -> ")}. Beyond that depth tenant-handover would move fewer rows ` +
|
|
129
|
+
`than the declaration implies. Fix: flatten the chain, or drop transferable from an ` +
|
|
130
|
+
`entity in it that should not move with its host.`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
package/src/engine/index.ts
CHANGED
package/src/schema-cli.ts
CHANGED
|
@@ -91,6 +91,11 @@ export type RunSchemaCliOptions = {
|
|
|
91
91
|
* tables a freshly applied migration changed (via its `.rebuild.json`
|
|
92
92
|
* marker). Omitted (dev `kumiko schema`) → no rebuild, migrations only. */
|
|
93
93
|
readonly features?: readonly FeatureDefinition[];
|
|
94
|
+
/** Schema-declared Key Manager slots, typically `kmsSlotsOf(composedEnv.schema)`.
|
|
95
|
+
* This CLI parses no env schema, so `apply`'s KMS wiring cannot derive them
|
|
96
|
+
* itself; omitted, `resolvePlatformKeks` (inside `resolveKmsWiringAsync`)
|
|
97
|
+
* falls back to its own default. */
|
|
98
|
+
readonly kmsSlots?: readonly string[];
|
|
94
99
|
};
|
|
95
100
|
|
|
96
101
|
/**
|
|
@@ -361,6 +366,8 @@ export async function runSchemaCli(
|
|
|
361
366
|
// the row (fw#3091).
|
|
362
367
|
wiring = await resolveKmsWiringAsync(process.env, {
|
|
363
368
|
logPrefix: "[kumiko schema apply]",
|
|
369
|
+
log: out.log,
|
|
370
|
+
...(options.kmsSlots && { slots: options.kmsSlots }),
|
|
364
371
|
});
|
|
365
372
|
if ("kms" in wiring) {
|
|
366
373
|
configurePiiSubjectKms(wiring.kms);
|
|
@@ -42,7 +42,7 @@ let indexPrefix: string;
|
|
|
42
42
|
describe.skipIf(!MEILI_UP)("meilisearch adapter (live)", () => {
|
|
43
43
|
beforeAll(async () => {
|
|
44
44
|
client = new Meilisearch({ host: MEILI_URL, apiKey: MEILI_KEY });
|
|
45
|
-
indexPrefix = `test_${uuid()
|
|
45
|
+
indexPrefix = `test_${uuid()}_`;
|
|
46
46
|
adapter = createMeilisearchAdapter({
|
|
47
47
|
url: MEILI_URL,
|
|
48
48
|
apiKey: MEILI_KEY,
|
|
@@ -110,10 +110,9 @@ describe.skipIf(!MEILI_UP)("meilisearch adapter (live)", () => {
|
|
|
110
110
|
});
|
|
111
111
|
|
|
112
112
|
afterAll(async () => {
|
|
113
|
-
// Clean up all test indices
|
|
114
113
|
const indices = await client.getIndexes();
|
|
115
114
|
for (const idx of indices.results) {
|
|
116
|
-
if (idx.uid.startsWith(
|
|
115
|
+
if (idx.uid.startsWith(indexPrefix)) {
|
|
117
116
|
try {
|
|
118
117
|
await client.index(idx.uid).delete().waitTask();
|
|
119
118
|
} catch {
|
package/src/stack/db.ts
CHANGED
|
@@ -12,7 +12,7 @@ function requireEnv(name: string): string {
|
|
|
12
12
|
const value = process.env[name];
|
|
13
13
|
if (!value) {
|
|
14
14
|
throw new Error(
|
|
15
|
-
`Missing required env var: ${name}. Copy .env.example to .env and fill in values.`,
|
|
15
|
+
`Missing required env var: ${name}. Copy .env.example to .env and fill in values. Tests also need the test services (Postgres, Redis) running, e.g. "docker compose up -d".`,
|
|
16
16
|
);
|
|
17
17
|
}
|
|
18
18
|
return value;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { requireRealProviders } from "../real-providers";
|
|
3
|
+
|
|
4
|
+
const CI = "CI";
|
|
5
|
+
const FLAG = "KUMIKO_REAL_PROVIDERS";
|
|
6
|
+
const API_KEY = "ANTHROPIC_API_KEY";
|
|
7
|
+
const TOUCHED_ENV = [CI, FLAG, API_KEY];
|
|
8
|
+
|
|
9
|
+
describe("requireRealProviders", () => {
|
|
10
|
+
const saved = new Map<string, string | undefined>();
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
for (const name of TOUCHED_ENV) {
|
|
14
|
+
saved.set(name, process.env[name]);
|
|
15
|
+
delete process.env[name];
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
for (const [name, value] of saved) {
|
|
21
|
+
if (value === undefined) delete process.env[name];
|
|
22
|
+
else process.env[name] = value;
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("throws when the flag is missing, even with an API key set", () => {
|
|
27
|
+
process.env[API_KEY] = "sk-test";
|
|
28
|
+
expect(() => requireRealProviders()).toThrow(/KUMIKO_REAL_PROVIDERS=1/);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("throws when the flag has any value other than 1", () => {
|
|
32
|
+
process.env[FLAG] = "true";
|
|
33
|
+
expect(() => requireRealProviders()).toThrow(/KUMIKO_REAL_PROVIDERS=1/);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("throws in CI even with the flag set", () => {
|
|
37
|
+
process.env[CI] = "true";
|
|
38
|
+
process.env[FLAG] = "1";
|
|
39
|
+
expect(() => requireRealProviders()).toThrow(/never run in CI/);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("does not throw with the flag set outside CI", () => {
|
|
43
|
+
process.env[FLAG] = "1";
|
|
44
|
+
expect(() => requireRealProviders()).not.toThrow();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("treats CI=false and CI=0 as not CI", () => {
|
|
48
|
+
process.env[FLAG] = "1";
|
|
49
|
+
process.env[CI] = "false";
|
|
50
|
+
expect(() => requireRealProviders()).not.toThrow();
|
|
51
|
+
process.env[CI] = "0";
|
|
52
|
+
expect(() => requireRealProviders()).not.toThrow();
|
|
53
|
+
});
|
|
54
|
+
});
|
package/src/testing/index.ts
CHANGED
|
@@ -44,6 +44,7 @@ export {
|
|
|
44
44
|
createRecordingProvider,
|
|
45
45
|
type RecordingProvider,
|
|
46
46
|
} from "./observability-recorder";
|
|
47
|
+
export { requireRealProviders } from "./real-providers";
|
|
47
48
|
export { deleteRows, seedRow, seedRows, updateRows } from "./seed";
|
|
48
49
|
export {
|
|
49
50
|
sharedItemEntity,
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
const REAL_PROVIDERS_FLAG = "KUMIKO_REAL_PROVIDERS";
|
|
2
|
+
const CI_FLAG = "CI";
|
|
3
|
+
|
|
4
|
+
function isCi(): boolean {
|
|
5
|
+
const ci = process.env[CI_FLAG];
|
|
6
|
+
return ci !== undefined && ci !== "" && ci !== "0" && ci.toLowerCase() !== "false";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function requireRealProviders(): void {
|
|
10
|
+
if (isCi()) {
|
|
11
|
+
throw new Error(
|
|
12
|
+
`Real-provider tests never run in CI (CI is set). Run them locally with ${REAL_PROVIDERS_FLAG}=1 via the test:real / e2e:real script.`,
|
|
13
|
+
);
|
|
14
|
+
}
|
|
15
|
+
if (process.env[REAL_PROVIDERS_FLAG] !== "1") {
|
|
16
|
+
throw new Error(
|
|
17
|
+
`Real-provider test needs ${REAL_PROVIDERS_FLAG}=1 (an API key alone never enables it). Run it via the test:real / e2e:real script.`,
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
}
|
package/src/ui-types/index.ts
CHANGED
|
@@ -138,10 +138,12 @@ export type {
|
|
|
138
138
|
export type {
|
|
139
139
|
ListRowMetaColumnType,
|
|
140
140
|
ListRowMetaReference,
|
|
141
|
+
ReferenceLookupSource,
|
|
141
142
|
SystemReferenceLabel,
|
|
142
143
|
} from "./list-row-meta";
|
|
143
144
|
export {
|
|
144
145
|
LIST_ROW_META_COLUMNS,
|
|
145
146
|
LIST_ROW_META_REFERENCES,
|
|
147
|
+
REFERENCE_LOOKUP_SOURCES,
|
|
146
148
|
SYSTEM_REFERENCE_LABELS,
|
|
147
149
|
} from "./list-row-meta";
|
|
@@ -32,9 +32,9 @@ export type ListRowMetaReference = {
|
|
|
32
32
|
readonly refLabelField: string;
|
|
33
33
|
};
|
|
34
34
|
|
|
35
|
-
// tenantId would otherwise render the raw GUID; the lookup query is
|
|
36
|
-
//
|
|
37
|
-
// `
|
|
35
|
+
// tenantId would otherwise render the raw GUID; the lookup query is resolved
|
|
36
|
+
// through REFERENCE_LOOKUP_SOURCES (tenant-directory), not the
|
|
37
|
+
// entity-convention `tenant:query:tenant:list`.
|
|
38
38
|
// ponytail: bulk lookup is capped at REFERENCE_LIST_LOOKUP_LIMIT (200) —
|
|
39
39
|
// above that, rows fall back to the GUID; paginate the lookup if an install
|
|
40
40
|
// ever exceeds it.
|
|
@@ -42,6 +42,31 @@ export const LIST_ROW_META_REFERENCES: Readonly<Record<string, ListRowMetaRefere
|
|
|
42
42
|
tenantId: { refFeature: "tenant", refEntity: "tenant", refLabelField: "name" },
|
|
43
43
|
};
|
|
44
44
|
|
|
45
|
+
export type ReferenceLookupSource = {
|
|
46
|
+
readonly queryQn: string;
|
|
47
|
+
readonly labelKey: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// Overrides the `<refFeature>:query:<refEntity>:list` convention the renderer
|
|
51
|
+
// otherwise derives for a reference lookup. Keyed by `${refFeature}:${refEntity}`
|
|
52
|
+
// like SYSTEM_REFERENCE_LABELS, so the override holds for every screen that
|
|
53
|
+
// references the entity instead of each one re-declaring it.
|
|
54
|
+
//
|
|
55
|
+
// user:user — the convention QN is a SystemAdmin cross-tenant roster whose
|
|
56
|
+
// `tenants` join must not reach a TenantAdmin, so it stays SystemAdmin-only and
|
|
57
|
+
// the label lookup goes through the member directory instead: own-tenant
|
|
58
|
+
// members for an admin, every user for a SystemAdmin (fw#3107).
|
|
59
|
+
//
|
|
60
|
+
// tenant:tenant — the convention QN, `tenant:query:tenant:list`, is a
|
|
61
|
+
// SystemAdmin-only entity-list handler, so a TenantAdmin got a 403 and every
|
|
62
|
+
// tenant cell fell back to the raw UUID. The label lookup goes through the
|
|
63
|
+
// tenant directory instead: the caller's own tenant for an admin, every
|
|
64
|
+
// tenant for a SystemAdmin (fw#3142).
|
|
65
|
+
export const REFERENCE_LOOKUP_SOURCES: Readonly<Record<string, ReferenceLookupSource>> = {
|
|
66
|
+
"user:user": { queryQn: "tenant:query:member-directory", labelKey: "label" },
|
|
67
|
+
"tenant:tenant": { queryQn: "tenant:query:tenant-directory", labelKey: "label" },
|
|
68
|
+
};
|
|
69
|
+
|
|
45
70
|
export type SystemReferenceLabel = {
|
|
46
71
|
readonly id: string;
|
|
47
72
|
readonly labelKey: string;
|