@cosmicdrift/kumiko-framework 0.237.2 → 0.239.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/db/__tests__/event-store-executor-reference-search.integration.test.ts +381 -0
- package/src/db/event-store-executor-read.ts +186 -34
- package/src/engine/__tests__/engine.test.ts +77 -28
- package/src/engine/entity-handlers.ts +7 -0
- package/src/engine/registry-facade.ts +3 -3
- package/src/engine/registry-state.ts +13 -2
- package/src/engine/registry-validate.ts +47 -14
- package/src/engine/registry.ts +2 -2
- package/src/ui-types/index.ts +10 -2
- package/src/ui-types/list-row-meta.ts +18 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.239.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,7 +198,7 @@
|
|
|
198
198
|
"./package.json": "./package.json"
|
|
199
199
|
},
|
|
200
200
|
"dependencies": {
|
|
201
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
201
|
+
"@cosmicdrift/kumiko-types": "0.239.0",
|
|
202
202
|
"bullmq": "^5.76.7",
|
|
203
203
|
"bun-types": "^1.3.13",
|
|
204
204
|
"hono": "^4.13.1",
|
|
@@ -214,7 +214,7 @@
|
|
|
214
214
|
"zod": "^4.4.3"
|
|
215
215
|
},
|
|
216
216
|
"devDependencies": {
|
|
217
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
217
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.239.0",
|
|
218
218
|
"bun-types": "^1.3.13",
|
|
219
219
|
"pino-pretty": "^13.1.3"
|
|
220
220
|
},
|
|
@@ -0,0 +1,381 @@
|
|
|
1
|
+
// fw#2660 — a text search on an entity must also match its `searchable`
|
|
2
|
+
// reference fields by the target row's labelField, not just the raw FK
|
|
3
|
+
// column (a GUID never contains the search term a human types). Covers the
|
|
4
|
+
// four cases the issue mandates: reference-only match, union with a native
|
|
5
|
+
// text match (no duplicates), cross-tenant target label match (must yield
|
|
6
|
+
// zero rows — the hard security constraint), and the >200-match cap (drop
|
|
7
|
+
// the reference clause, native search still applies).
|
|
8
|
+
|
|
9
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
10
|
+
import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
|
|
11
|
+
import { asRawClient } from "../../db/query";
|
|
12
|
+
import { createEntity, createTextField } from "../../engine";
|
|
13
|
+
import type { EntityDefinition } from "../../engine/types";
|
|
14
|
+
import { createEventsTable } from "../../event-store";
|
|
15
|
+
import { createInMemorySearchAdapter } from "../../search";
|
|
16
|
+
import { TestUsers, unsafeCreateEntityTable } from "../../stack";
|
|
17
|
+
import { seedRows } from "../../testing";
|
|
18
|
+
import { ensureTemporalPolyfill } from "../../time/polyfill";
|
|
19
|
+
import { createEventStoreExecutor } from "../event-store-executor";
|
|
20
|
+
import { buildEntityTable } from "../table-builder";
|
|
21
|
+
import { createTenantDb, type TenantDb } from "../tenant-db";
|
|
22
|
+
|
|
23
|
+
const customerEntity = createEntity({
|
|
24
|
+
table: "read_ref_search_customers",
|
|
25
|
+
fields: {
|
|
26
|
+
name: createTextField({ required: true }),
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
const customerTable = buildEntityTable("refSearchCustomer", customerEntity);
|
|
30
|
+
|
|
31
|
+
const orderEntity = createEntity({
|
|
32
|
+
table: "read_ref_search_orders",
|
|
33
|
+
fields: {
|
|
34
|
+
note: createTextField({ searchable: true }),
|
|
35
|
+
customerId: {
|
|
36
|
+
type: "reference",
|
|
37
|
+
entity: "refSearchCustomer",
|
|
38
|
+
labelField: "name",
|
|
39
|
+
searchable: true,
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
});
|
|
43
|
+
const orderTable = buildEntityTable("refSearchOrder", orderEntity);
|
|
44
|
+
|
|
45
|
+
// A caller who cannot read `customerId` itself must not be able to infer its
|
|
46
|
+
// value by matching the target's label through the reference clause — same
|
|
47
|
+
// probe surface fw#2629 already closed for filter/sort (fw#2660 parity).
|
|
48
|
+
const restrictedOrderEntity = createEntity({
|
|
49
|
+
table: "read_ref_search_restricted_orders",
|
|
50
|
+
fields: {
|
|
51
|
+
note: createTextField({ searchable: true }),
|
|
52
|
+
customerId: {
|
|
53
|
+
type: "reference",
|
|
54
|
+
entity: "refSearchCustomer",
|
|
55
|
+
labelField: "name",
|
|
56
|
+
searchable: true,
|
|
57
|
+
access: { read: { Admin: "all" } },
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
});
|
|
61
|
+
const restrictedOrderTable = buildEntityTable("refSearchRestrictedOrder", restrictedOrderEntity);
|
|
62
|
+
|
|
63
|
+
// Minimal stand-in for the real bundled `tenant` entity (table `read_tenants`)
|
|
64
|
+
// — mirrors the pattern in entity-table-from-registry.test.ts. Only `name`
|
|
65
|
+
// matters here: the implicit tenantId->tenant.name row-meta reference
|
|
66
|
+
// (LIST_ROW_META_REFERENCES) is what's under test, not tenant's full schema.
|
|
67
|
+
const tenantMetaEntity = createEntity({
|
|
68
|
+
table: "read_tenants",
|
|
69
|
+
fields: { name: createTextField({ required: true }) },
|
|
70
|
+
});
|
|
71
|
+
const tenantMetaTable = buildEntityTable("tenant", tenantMetaEntity);
|
|
72
|
+
|
|
73
|
+
function resolveEntity(name: string): EntityDefinition | undefined {
|
|
74
|
+
return name === "refSearchCustomer" ? customerEntity : undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const referenceSearch = {
|
|
78
|
+
fields: [{ fieldName: "customerId", targetEntityName: "refSearchCustomer", labelField: "name" }],
|
|
79
|
+
resolveEntity,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const restrictedReferenceSearch = {
|
|
83
|
+
fields: referenceSearch.fields,
|
|
84
|
+
resolveEntity,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// Same fields as `referenceSearch`, but resolves "tenant" too — kept separate
|
|
88
|
+
// from the module-default `referenceSearch` so the other tests (whose
|
|
89
|
+
// resolveEntity never resolves "tenant") never issue a query against
|
|
90
|
+
// `read_tenants`.
|
|
91
|
+
const referenceSearchWithTenant = {
|
|
92
|
+
fields: referenceSearch.fields,
|
|
93
|
+
resolveEntity: (name: string) => (name === "tenant" ? tenantMetaEntity : resolveEntity(name)),
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
let testDb: BunTestDb;
|
|
97
|
+
let tdbA: TenantDb;
|
|
98
|
+
let tdbB: TenantDb;
|
|
99
|
+
const admin = TestUsers.admin;
|
|
100
|
+
const otherTenantAdmin = TestUsers.otherTenant;
|
|
101
|
+
const orderExec = createEventStoreExecutor(orderTable, orderEntity, {
|
|
102
|
+
entityName: "refSearchOrder",
|
|
103
|
+
});
|
|
104
|
+
const restrictedOrderExec = createEventStoreExecutor(restrictedOrderTable, restrictedOrderEntity, {
|
|
105
|
+
entityName: "refSearchRestrictedOrder",
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
beforeAll(async () => {
|
|
109
|
+
await ensureTemporalPolyfill();
|
|
110
|
+
testDb = await createTestDb();
|
|
111
|
+
await unsafeCreateEntityTable(testDb.db, customerEntity, "refSearchCustomer");
|
|
112
|
+
await unsafeCreateEntityTable(testDb.db, orderEntity, "refSearchOrder");
|
|
113
|
+
await unsafeCreateEntityTable(testDb.db, restrictedOrderEntity, "refSearchRestrictedOrder");
|
|
114
|
+
await unsafeCreateEntityTable(testDb.db, tenantMetaEntity, "tenant");
|
|
115
|
+
await createEventsTable(testDb.db);
|
|
116
|
+
tdbA = createTenantDb(testDb.db, admin.tenantId);
|
|
117
|
+
tdbB = createTenantDb(testDb.db, otherTenantAdmin.tenantId);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
afterAll(async () => {
|
|
121
|
+
await testDb.cleanup();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
beforeEach(async () => {
|
|
125
|
+
await asRawClient(testDb.db).unsafe(
|
|
126
|
+
"TRUNCATE kumiko_events, read_ref_search_customers, read_ref_search_orders, " +
|
|
127
|
+
"read_ref_search_restricted_orders, read_tenants RESTART IDENTITY CASCADE",
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("event-store-executor.list — searchable reference fields (fw#2660)", () => {
|
|
132
|
+
test("matches via the reference column's label with no text-field match", async () => {
|
|
133
|
+
const [acme] = await seedRows(testDb.db, customerTable, [
|
|
134
|
+
{ id: crypto.randomUUID(), tenantId: admin.tenantId, name: "Acme Corp" },
|
|
135
|
+
]);
|
|
136
|
+
const [order] = await seedRows(testDb.db, orderTable, [
|
|
137
|
+
{
|
|
138
|
+
id: crypto.randomUUID(),
|
|
139
|
+
tenantId: admin.tenantId,
|
|
140
|
+
note: "totally unrelated note",
|
|
141
|
+
customerId: (acme as { id: string }).id,
|
|
142
|
+
},
|
|
143
|
+
]);
|
|
144
|
+
|
|
145
|
+
const searchAdapter = createInMemorySearchAdapter();
|
|
146
|
+
await searchAdapter.configure(admin.tenantId, { searchableFields: ["note"] });
|
|
147
|
+
await searchAdapter.index(admin.tenantId, {
|
|
148
|
+
entityType: "refSearchOrder",
|
|
149
|
+
entityId: (order as { id: string }).id,
|
|
150
|
+
weight: 1,
|
|
151
|
+
fields: { note: "totally unrelated note" },
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const res = await orderExec.list({ search: "acme" }, admin, tdbA, {
|
|
155
|
+
searchAdapter,
|
|
156
|
+
referenceSearch,
|
|
157
|
+
});
|
|
158
|
+
expect(res.rows.map((r) => r["id"])).toEqual([(order as { id: string }).id]);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("unions a native text-field match with a reference-label match, no duplicates", async () => {
|
|
162
|
+
const [acme, other] = await seedRows(testDb.db, customerTable, [
|
|
163
|
+
{ id: crypto.randomUUID(), tenantId: admin.tenantId, name: "Acme Corp" },
|
|
164
|
+
{ id: crypto.randomUUID(), tenantId: admin.tenantId, name: "Some Other Customer" },
|
|
165
|
+
]);
|
|
166
|
+
const [textMatchOrder, refMatchOrder, decoyOrder] = await seedRows(testDb.db, orderTable, [
|
|
167
|
+
{
|
|
168
|
+
id: crypto.randomUUID(),
|
|
169
|
+
tenantId: admin.tenantId,
|
|
170
|
+
note: "great acme deal closed",
|
|
171
|
+
customerId: (other as { id: string }).id,
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
id: crypto.randomUUID(),
|
|
175
|
+
tenantId: admin.tenantId,
|
|
176
|
+
note: "nothing special here",
|
|
177
|
+
customerId: (acme as { id: string }).id,
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
id: crypto.randomUUID(),
|
|
181
|
+
tenantId: admin.tenantId,
|
|
182
|
+
note: "nothing special here either",
|
|
183
|
+
customerId: (other as { id: string }).id,
|
|
184
|
+
},
|
|
185
|
+
]);
|
|
186
|
+
|
|
187
|
+
const searchAdapter = createInMemorySearchAdapter();
|
|
188
|
+
await searchAdapter.configure(admin.tenantId, { searchableFields: ["note"] });
|
|
189
|
+
for (const o of [textMatchOrder, refMatchOrder, decoyOrder]) {
|
|
190
|
+
const row = o as { id: string; note: string };
|
|
191
|
+
await searchAdapter.index(admin.tenantId, {
|
|
192
|
+
entityType: "refSearchOrder",
|
|
193
|
+
entityId: row.id,
|
|
194
|
+
weight: 1,
|
|
195
|
+
fields: { note: row.note },
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const res = await orderExec.list({ search: "acme" }, admin, tdbA, {
|
|
200
|
+
searchAdapter,
|
|
201
|
+
referenceSearch,
|
|
202
|
+
});
|
|
203
|
+
const ids = res.rows.map((r) => r["id"]);
|
|
204
|
+
expect(ids.sort()).toEqual(
|
|
205
|
+
[(textMatchOrder as { id: string }).id, (refMatchOrder as { id: string }).id].sort(),
|
|
206
|
+
);
|
|
207
|
+
expect(new Set(ids).size).toBe(ids.length);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("a target label match in a foreign tenant never surfaces a row (mandatory)", async () => {
|
|
211
|
+
const [confidential] = await seedRows(testDb.db, customerTable, [
|
|
212
|
+
{ id: crypto.randomUUID(), tenantId: admin.tenantId, name: "Confidential Co" },
|
|
213
|
+
]);
|
|
214
|
+
// Reference UUIDs are freely settable (no FK constraint) — a tenant-B
|
|
215
|
+
// row can point at a tenant-A id. If the target lookup weren't
|
|
216
|
+
// tenant-scoped, this order would surface and leak that a
|
|
217
|
+
// "Confidential Co" row exists in another tenant.
|
|
218
|
+
const [order] = await seedRows(testDb.db, orderTable, [
|
|
219
|
+
{
|
|
220
|
+
id: crypto.randomUUID(),
|
|
221
|
+
tenantId: otherTenantAdmin.tenantId,
|
|
222
|
+
note: "unrelated",
|
|
223
|
+
customerId: (confidential as { id: string }).id,
|
|
224
|
+
},
|
|
225
|
+
]);
|
|
226
|
+
|
|
227
|
+
const searchAdapter = createInMemorySearchAdapter();
|
|
228
|
+
await searchAdapter.configure(otherTenantAdmin.tenantId, { searchableFields: ["note"] });
|
|
229
|
+
await searchAdapter.index(otherTenantAdmin.tenantId, {
|
|
230
|
+
entityType: "refSearchOrder",
|
|
231
|
+
entityId: (order as { id: string }).id,
|
|
232
|
+
weight: 1,
|
|
233
|
+
fields: { note: "unrelated" },
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
const res = await orderExec.list({ search: "confidential" }, otherTenantAdmin, tdbB, {
|
|
237
|
+
searchAdapter,
|
|
238
|
+
referenceSearch,
|
|
239
|
+
});
|
|
240
|
+
expect(res.rows).toHaveLength(0);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("drops the reference clause above 200 target matches — native search still applies", async () => {
|
|
244
|
+
const fooCustomers = Array.from({ length: 201 }, (_, i) => ({
|
|
245
|
+
id: crypto.randomUUID(),
|
|
246
|
+
tenantId: admin.tenantId,
|
|
247
|
+
name: `Foo Customer ${i}`,
|
|
248
|
+
}));
|
|
249
|
+
await seedRows(testDb.db, customerTable, fooCustomers);
|
|
250
|
+
|
|
251
|
+
const [refOnlyOrder, textMatchOrder] = await seedRows(testDb.db, orderTable, [
|
|
252
|
+
{
|
|
253
|
+
id: crypto.randomUUID(),
|
|
254
|
+
tenantId: admin.tenantId,
|
|
255
|
+
note: "no keyword here",
|
|
256
|
+
customerId: fooCustomers[5]?.id,
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
id: crypto.randomUUID(),
|
|
260
|
+
tenantId: admin.tenantId,
|
|
261
|
+
note: "foo appears right here",
|
|
262
|
+
customerId: fooCustomers[10]?.id,
|
|
263
|
+
},
|
|
264
|
+
]);
|
|
265
|
+
|
|
266
|
+
const searchAdapter = createInMemorySearchAdapter();
|
|
267
|
+
await searchAdapter.configure(admin.tenantId, { searchableFields: ["note"] });
|
|
268
|
+
for (const o of [refOnlyOrder, textMatchOrder]) {
|
|
269
|
+
const row = o as { id: string; note: string };
|
|
270
|
+
await searchAdapter.index(admin.tenantId, {
|
|
271
|
+
entityType: "refSearchOrder",
|
|
272
|
+
entityId: row.id,
|
|
273
|
+
weight: 1,
|
|
274
|
+
fields: { note: row.note },
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const res = await orderExec.list({ search: "foo" }, admin, tdbA, {
|
|
279
|
+
searchAdapter,
|
|
280
|
+
referenceSearch,
|
|
281
|
+
});
|
|
282
|
+
expect(res.rows.map((r) => r["id"])).toEqual([(textMatchOrder as { id: string }).id]);
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("a caller without read access to the reference column gets no reference-match hit (fw#2629 parity)", async () => {
|
|
286
|
+
const [acme] = await seedRows(testDb.db, customerTable, [
|
|
287
|
+
{ id: crypto.randomUUID(), tenantId: admin.tenantId, name: "Acme Corp" },
|
|
288
|
+
]);
|
|
289
|
+
const [order] = await seedRows(testDb.db, restrictedOrderTable, [
|
|
290
|
+
{
|
|
291
|
+
id: crypto.randomUUID(),
|
|
292
|
+
tenantId: admin.tenantId,
|
|
293
|
+
note: "totally unrelated note",
|
|
294
|
+
customerId: (acme as { id: string }).id,
|
|
295
|
+
},
|
|
296
|
+
]);
|
|
297
|
+
|
|
298
|
+
const searchAdapter = createInMemorySearchAdapter();
|
|
299
|
+
await searchAdapter.configure(admin.tenantId, { searchableFields: ["note"] });
|
|
300
|
+
|
|
301
|
+
const asUser = await restrictedOrderExec.list({ search: "acme" }, TestUsers.user, tdbA, {
|
|
302
|
+
searchAdapter,
|
|
303
|
+
referenceSearch: restrictedReferenceSearch,
|
|
304
|
+
});
|
|
305
|
+
expect(asUser.rows).toHaveLength(0);
|
|
306
|
+
|
|
307
|
+
const asAdmin = await restrictedOrderExec.list({ search: "acme" }, admin, tdbA, {
|
|
308
|
+
searchAdapter,
|
|
309
|
+
referenceSearch: restrictedReferenceSearch,
|
|
310
|
+
});
|
|
311
|
+
expect(asAdmin.rows.map((r) => r["id"])).toEqual([(order as { id: string }).id]);
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("matches via the implicit tenantId->tenant.name row-meta reference in a system-scoped cross-tenant search", async () => {
|
|
315
|
+
const [acmeTenant] = await seedRows(testDb.db, tenantMetaTable, [
|
|
316
|
+
{ id: admin.tenantId, tenantId: admin.tenantId, name: "Acme Tenant" },
|
|
317
|
+
]);
|
|
318
|
+
await seedRows(testDb.db, tenantMetaTable, [
|
|
319
|
+
{ id: otherTenantAdmin.tenantId, tenantId: otherTenantAdmin.tenantId, name: "Globex Tenant" },
|
|
320
|
+
]);
|
|
321
|
+
const [someCustomer] = await seedRows(testDb.db, customerTable, [
|
|
322
|
+
{ id: crypto.randomUUID(), tenantId: admin.tenantId, name: "Some Customer" },
|
|
323
|
+
]);
|
|
324
|
+
|
|
325
|
+
const [tenantAOrder] = await seedRows(testDb.db, orderTable, [
|
|
326
|
+
{
|
|
327
|
+
id: crypto.randomUUID(),
|
|
328
|
+
tenantId: admin.tenantId,
|
|
329
|
+
note: "nothing relevant here",
|
|
330
|
+
customerId: (someCustomer as { id: string }).id,
|
|
331
|
+
},
|
|
332
|
+
]);
|
|
333
|
+
await seedRows(testDb.db, orderTable, [
|
|
334
|
+
{
|
|
335
|
+
id: crypto.randomUUID(),
|
|
336
|
+
tenantId: otherTenantAdmin.tenantId,
|
|
337
|
+
note: "also nothing relevant",
|
|
338
|
+
customerId: (someCustomer as { id: string }).id,
|
|
339
|
+
},
|
|
340
|
+
]);
|
|
341
|
+
|
|
342
|
+
const searchAdapter = createInMemorySearchAdapter();
|
|
343
|
+
await searchAdapter.configure(TestUsers.systemAdmin.tenantId, { searchableFields: ["note"] });
|
|
344
|
+
|
|
345
|
+
const systemDb = createTenantDb(testDb.db, TestUsers.systemAdmin.tenantId, "system");
|
|
346
|
+
const res = await orderExec.list({ search: "acme" }, TestUsers.systemAdmin, systemDb, {
|
|
347
|
+
searchAdapter,
|
|
348
|
+
referenceSearch: referenceSearchWithTenant,
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
expect(res.rows.map((r) => r["id"])).toEqual([(tenantAOrder as { id: string }).id]);
|
|
352
|
+
// sanity: the match is scoped to the matching tenant, not "everything"
|
|
353
|
+
expect((acmeTenant as { id: string }).id).toBe(admin.tenantId);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test("tenant-mode search never issues the tenantId row-meta lookup (gate)", async () => {
|
|
357
|
+
await seedRows(testDb.db, tenantMetaTable, [
|
|
358
|
+
{ id: admin.tenantId, tenantId: admin.tenantId, name: "Acme Tenant" },
|
|
359
|
+
]);
|
|
360
|
+
await seedRows(testDb.db, orderTable, [
|
|
361
|
+
{ id: crypto.randomUUID(), tenantId: admin.tenantId, note: "nothing relevant" },
|
|
362
|
+
]);
|
|
363
|
+
|
|
364
|
+
const searchAdapter = createInMemorySearchAdapter();
|
|
365
|
+
await searchAdapter.configure(admin.tenantId, { searchableFields: ["note"] });
|
|
366
|
+
|
|
367
|
+
// Drop the lookup target so the gated query would throw ("relation does
|
|
368
|
+
// not exist") if it were still issued — proves absence of the query, not
|
|
369
|
+
// just an equal-looking result.
|
|
370
|
+
await asRawClient(testDb.db).unsafe('DROP TABLE "read_tenants"');
|
|
371
|
+
try {
|
|
372
|
+
const res = await orderExec.list({ search: "acme" }, admin, tdbA, {
|
|
373
|
+
searchAdapter,
|
|
374
|
+
referenceSearch: referenceSearchWithTenant,
|
|
375
|
+
});
|
|
376
|
+
expect(res.rows).toHaveLength(0);
|
|
377
|
+
} finally {
|
|
378
|
+
await unsafeCreateEntityTable(testDb.db, tenantMetaEntity, "tenant");
|
|
379
|
+
}
|
|
380
|
+
});
|
|
381
|
+
});
|
|
@@ -1,17 +1,21 @@
|
|
|
1
1
|
import { KUMIKO_NAME_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
|
|
2
|
-
import { computeBlindIndex, configuredBlindIndexKey } from "../crypto";
|
|
2
|
+
import { collectPiiSubjectFields, computeBlindIndex, configuredBlindIndexKey } from "../crypto";
|
|
3
|
+
import { escapeLikePattern } from "../crypto/ciphertext-pattern";
|
|
3
4
|
import { executeRawQueryRead } from "../db/queries/raw-sql";
|
|
4
5
|
import { coerceRow, extractTableInfo } from "../db/query";
|
|
5
6
|
import { buildOwnershipClause, normalizeAccessEntry, shiftParams } from "../engine/ownership";
|
|
6
|
-
import type { EntityId } from "../engine/types";
|
|
7
|
+
import type { EntityDefinition, EntityId } from "../engine/types";
|
|
7
8
|
import { SYSTEM_TENANT_ID } from "../engine/types/identifiers";
|
|
8
9
|
import { UnprocessableError } from "../errors";
|
|
9
10
|
import { getStreamVersion } from "../event-store";
|
|
11
|
+
import { LIST_ROW_META_REFERENCES } from "../ui-types/list-row-meta";
|
|
10
12
|
import { rehydrateCompoundTypes } from "./compound-types";
|
|
11
13
|
import { decodeKeysetCursor, encodeCursor, encodeKeysetCursor } from "./cursor";
|
|
14
|
+
import { collectEncryptedFieldNames } from "./entity-field-encryption";
|
|
12
15
|
import type { EventStoreExecutor } from "./event-store-executor";
|
|
13
16
|
import { buildFilterWhere, type ExecutorContext, type Table } from "./event-store-executor-context";
|
|
14
|
-
import { toSnakeCase } from "./table-builder";
|
|
17
|
+
import { buildEntityTable, toSnakeCase } from "./table-builder";
|
|
18
|
+
import type { TenantDb, TenantDbMode } from "./tenant-db";
|
|
15
19
|
|
|
16
20
|
// The two read verbs (list/detail) of the event-store-executor. Split out
|
|
17
21
|
// of event-store-executor.ts (#1005, Welle 2) — behavior-preserving
|
|
@@ -183,9 +187,9 @@ function applyScreenFilter(
|
|
|
183
187
|
whereSql.push(`${colSql(field)} ${opSym} $${params.length}`);
|
|
184
188
|
}
|
|
185
189
|
} else {
|
|
186
|
-
// Blind-
|
|
187
|
-
// in bun-db/query.ts —
|
|
188
|
-
//
|
|
190
|
+
// Blind-index OR rewrite (#818), lock-step with buildWhereClause
|
|
191
|
+
// in bun-db/query.ts — equality on lookupable fields matches the
|
|
192
|
+
// plaintext arm OR the HMAC arm.
|
|
189
193
|
const bidxKey = configuredBlindIndexKey();
|
|
190
194
|
if (bidxKey !== undefined && typeof value === "string" && table[`${field}Bidx`]) {
|
|
191
195
|
params.push(value, computeBlindIndex(bidxKey, value));
|
|
@@ -200,6 +204,102 @@ function applyScreenFilter(
|
|
|
200
204
|
}
|
|
201
205
|
}
|
|
202
206
|
|
|
207
|
+
// fw#2660 — above this many target-row matches, drop the reference clause
|
|
208
|
+
// entirely instead of truncating: an arbitrary 200-row slice of "which
|
|
209
|
+
// targets match" would silently hide rows a full-cardinality match would
|
|
210
|
+
// have found. Native text search on the entity's own fields still applies.
|
|
211
|
+
const MAX_REFERENCE_SEARCH_IDS = 200;
|
|
212
|
+
|
|
213
|
+
function physicalColumnName(table: Table, field: string): string {
|
|
214
|
+
return (table[field] as { name?: string } | undefined)?.name ?? toSnakeCase(field);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
type ReferenceSearchDescriptor = {
|
|
218
|
+
readonly ownColumn: string;
|
|
219
|
+
readonly targetEntityName: string;
|
|
220
|
+
readonly labelField: string;
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
// Registry-declared `searchable` reference fields, plus the implicit
|
|
224
|
+
// tenantId→tenant.name row-meta reference (fw#2660) — both resolve a text
|
|
225
|
+
// search term against a target row's label instead of the raw FK column.
|
|
226
|
+
function collectReferenceSearchDescriptors(
|
|
227
|
+
table: Table,
|
|
228
|
+
referenceSearch: NonNullable<Parameters<EventStoreExecutor["list"]>[3]>["referenceSearch"],
|
|
229
|
+
dbMode: TenantDbMode,
|
|
230
|
+
): readonly ReferenceSearchDescriptor[] {
|
|
231
|
+
if (!referenceSearch) return [];
|
|
232
|
+
const descriptors: ReferenceSearchDescriptor[] = referenceSearch.fields.map((f) => ({
|
|
233
|
+
ownColumn: f.fieldName,
|
|
234
|
+
targetEntityName: f.targetEntityName,
|
|
235
|
+
labelField: f.labelField,
|
|
236
|
+
}));
|
|
237
|
+
for (const [ownColumn, ref] of Object.entries(LIST_ROW_META_REFERENCES)) {
|
|
238
|
+
if (table[ownColumn] === undefined) continue;
|
|
239
|
+
// In "tenant" mode the outer tenant filter already pins tenantId to one
|
|
240
|
+
// of exactly two values, so a by-name lookup here can never narrow that further.
|
|
241
|
+
if (ownColumn === "tenantId" && dbMode === "tenant") continue;
|
|
242
|
+
descriptors.push({ ownColumn, targetEntityName: ref.refEntity, labelField: ref.refLabelField });
|
|
243
|
+
}
|
|
244
|
+
return descriptors;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Tenant-scoped ILIKE lookup against one reference target — never raw/
|
|
248
|
+
// unscoped SQL, a match in a foreign tenant must never surface a row here
|
|
249
|
+
// (fw#2660's hard constraint). Mirrors the main table's own tenant filter
|
|
250
|
+
// (see the `table["tenantId"]` check further down in list()).
|
|
251
|
+
async function resolveReferenceMatches(
|
|
252
|
+
descriptor: ReferenceSearchDescriptor,
|
|
253
|
+
searchTerm: string,
|
|
254
|
+
resolveEntity: (entityName: string) => EntityDefinition | undefined,
|
|
255
|
+
db: TenantDb,
|
|
256
|
+
): Promise<{ readonly ownColumn: string; readonly ids: readonly string[] } | undefined> {
|
|
257
|
+
const targetEntity = resolveEntity(descriptor.targetEntityName);
|
|
258
|
+
if (targetEntity === undefined) {
|
|
259
|
+
// skip: unknown target entity — registry inconsistency the boot-validator
|
|
260
|
+
// should have caught; fail open (no clause) rather than 500 the request
|
|
261
|
+
return undefined;
|
|
262
|
+
}
|
|
263
|
+
if (targetEntity.fields[descriptor.labelField] === undefined) {
|
|
264
|
+
// skip: labelField isn't a real (declared) column on the target entity —
|
|
265
|
+
// row-meta references (e.g. tenant.name) always resolve here since
|
|
266
|
+
// `name` is a declared field on the tenant entity, not an id/row-meta column
|
|
267
|
+
return undefined;
|
|
268
|
+
}
|
|
269
|
+
if (
|
|
270
|
+
collectEncryptedFieldNames(targetEntity).has(descriptor.labelField) ||
|
|
271
|
+
collectPiiSubjectFields(targetEntity).includes(descriptor.labelField)
|
|
272
|
+
) {
|
|
273
|
+
// skip: encrypted/PII labelField — ILIKE can never match ciphertext,
|
|
274
|
+
// emitting the query would just be a silent, permanent non-match
|
|
275
|
+
return undefined;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const targetTable = buildEntityTable(descriptor.targetEntityName, targetEntity);
|
|
279
|
+
const targetTableName = String(
|
|
280
|
+
(targetTable as unknown as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL],
|
|
281
|
+
);
|
|
282
|
+
const labelCol = physicalColumnName(targetTable, descriptor.labelField);
|
|
283
|
+
const subParams: unknown[] = [`%${escapeLikePattern(searchTerm)}%`];
|
|
284
|
+
let tenantClause = "";
|
|
285
|
+
if (targetTable["tenantId"] !== undefined && db.mode === "tenant") {
|
|
286
|
+
subParams.push(db.tenantId, SYSTEM_TENANT_ID);
|
|
287
|
+
tenantClause = ` AND "${physicalColumnName(targetTable, "tenantId")}" IN ($2, $3)`;
|
|
288
|
+
}
|
|
289
|
+
// ::text cast covers a non-text labelField (e.g. a number/select column
|
|
290
|
+
// used as label) — Postgres has no ILIKE for those types otherwise.
|
|
291
|
+
const sql =
|
|
292
|
+
`SELECT "id" FROM "${targetTableName}" WHERE ("${labelCol}")::text ILIKE $1${tenantClause} ` +
|
|
293
|
+
`LIMIT ${MAX_REFERENCE_SEARCH_IDS + 1}`;
|
|
294
|
+
const rows = await executeRawQueryRead<{ id: string }>(db.raw, sql, subParams);
|
|
295
|
+
if (rows.length === 0 || rows.length > MAX_REFERENCE_SEARCH_IDS) {
|
|
296
|
+
// skip: no match, or over the cap — drop the clause rather than
|
|
297
|
+
// truncate to an arbitrary slice of matching targets
|
|
298
|
+
return undefined;
|
|
299
|
+
}
|
|
300
|
+
return { ownColumn: descriptor.ownColumn, ids: rows.map((r) => r.id) };
|
|
301
|
+
}
|
|
302
|
+
|
|
203
303
|
export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor, "list" | "detail"> {
|
|
204
304
|
const {
|
|
205
305
|
table,
|
|
@@ -231,12 +331,35 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
|
|
|
231
331
|
}
|
|
232
332
|
|
|
233
333
|
let filterIds: EntityId[] | undefined;
|
|
334
|
+
// fw#2660 — true once a searchable-reference or row-meta-reference
|
|
335
|
+
// clause contributed rows, so the total-count fast-path below knows
|
|
336
|
+
// `filterIds.length` alone would undercount.
|
|
337
|
+
let referenceClauseActive = false;
|
|
338
|
+
// Build the WHERE clause as raw SQL — ownership produces a
|
|
339
|
+
// parameterised fragment that we splice in alongside simple WhereObject
|
|
340
|
+
// conditions (cursor, search-filter-IDs, screen-filter, tenant-scope).
|
|
341
|
+
const tableName = String((table as unknown as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL]);
|
|
342
|
+
const whereSql: string[] = [];
|
|
343
|
+
const params: unknown[] = [];
|
|
344
|
+
const physicalCol = (field: string): string => physicalColumnName(table, field);
|
|
345
|
+
const colSql = (field: string): string => `"${physicalCol(field)}"`;
|
|
346
|
+
|
|
347
|
+
// Field-level read access gates filtering and sorting the same way it
|
|
348
|
+
// gates the projected row: a caller who cannot read a field must not be
|
|
349
|
+
// able to probe its values through result counts or ordering (fw#2629).
|
|
350
|
+
// Hoisted above the search block (fw#2660) — a searchable-reference
|
|
351
|
+
// clause is the same probe surface as a filter/sort and must be gated
|
|
352
|
+
// the same way, before the reference-match query even runs.
|
|
353
|
+
const fieldReadClause = (field: string) =>
|
|
354
|
+
buildOwnershipClause(user, normalizeAccessEntry(entity.fields[field]?.access?.read), table);
|
|
355
|
+
|
|
234
356
|
// Build-Time options.searchAdapter gewinnt; runtime-Override ist
|
|
235
357
|
// Fallback für die defaultEntityQueryHandler-Pipe (die nutzt den
|
|
236
358
|
// ctx.searchAdapter erst zur Laufzeit weil createEventStoreExecutor
|
|
237
359
|
// beim Definition-Time noch keinen Server-Context hat).
|
|
238
360
|
const effectiveSearchAdapter = searchAdapter ?? runtimeOptions?.searchAdapter;
|
|
239
361
|
if (payload.search) {
|
|
362
|
+
const searchTerm = payload.search;
|
|
240
363
|
// #2032 — a search term with no adapter wired must fail loud, not
|
|
241
364
|
// silently return the unfiltered list dressed up as a search result.
|
|
242
365
|
if (!effectiveSearchAdapter) {
|
|
@@ -251,29 +374,63 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
|
|
|
251
374
|
// SYSTEM_TENANT_ID; everything else under the session tenant — db.mode
|
|
252
375
|
// must not invent a second predicate (kumiko-framework#2412).
|
|
253
376
|
const searchTenantId = streamTenantFor(user);
|
|
254
|
-
const results = await effectiveSearchAdapter.search(searchTenantId,
|
|
377
|
+
const results = await effectiveSearchAdapter.search(searchTenantId, searchTerm, {
|
|
255
378
|
filterType: entityName,
|
|
256
379
|
});
|
|
257
380
|
filterIds = results.map((r) => r.entityId);
|
|
258
|
-
|
|
381
|
+
|
|
382
|
+
// fw#2660 — union in reference-column label matches: a searchable
|
|
383
|
+
// reference field (or the implicit tenantId→tenant.name row-meta
|
|
384
|
+
// reference) whose target row's labelField matches the search term
|
|
385
|
+
// also counts as a hit on this entity, not just a raw text-field hit.
|
|
386
|
+
const referenceSearch = runtimeOptions?.referenceSearch;
|
|
387
|
+
// fw#2629 parity: a caller who cannot read the reference column itself
|
|
388
|
+
// must not be able to infer its value by matching the target's label —
|
|
389
|
+
// same probe surface as filtering/sorting on it directly.
|
|
390
|
+
const descriptors = collectReferenceSearchDescriptors(
|
|
391
|
+
table,
|
|
392
|
+
referenceSearch,
|
|
393
|
+
db.mode,
|
|
394
|
+
).filter((d) => fieldReadClause(d.ownColumn).kind === "pass");
|
|
395
|
+
const referenceMatches = referenceSearch
|
|
396
|
+
? (
|
|
397
|
+
await Promise.all(
|
|
398
|
+
descriptors.map((d) =>
|
|
399
|
+
resolveReferenceMatches(d, searchTerm, referenceSearch.resolveEntity, db),
|
|
400
|
+
),
|
|
401
|
+
)
|
|
402
|
+
).filter(
|
|
403
|
+
(m): m is { readonly ownColumn: string; readonly ids: readonly string[] } =>
|
|
404
|
+
m !== undefined,
|
|
405
|
+
)
|
|
406
|
+
: [];
|
|
407
|
+
|
|
408
|
+
if (filterIds.length === 0 && referenceMatches.length === 0) {
|
|
259
409
|
return { rows: [], nextCursor: null, ...(totalCount && { total: 0 }) };
|
|
260
410
|
}
|
|
411
|
+
|
|
412
|
+
const orParts: string[] = [];
|
|
413
|
+
if (filterIds.length > 0) {
|
|
414
|
+
const placeholders = filterIds.map((id) => {
|
|
415
|
+
params.push(id);
|
|
416
|
+
return `$${params.length}`;
|
|
417
|
+
});
|
|
418
|
+
orParts.push(`${colSql("id")} IN (${placeholders.join(", ")})`);
|
|
419
|
+
}
|
|
420
|
+
for (const match of referenceMatches) {
|
|
421
|
+
const placeholders = match.ids.map((id) => {
|
|
422
|
+
params.push(id);
|
|
423
|
+
return `$${params.length}`;
|
|
424
|
+
});
|
|
425
|
+
orParts.push(`${colSql(match.ownColumn)} IN (${placeholders.join(", ")})`);
|
|
426
|
+
referenceClauseActive = true;
|
|
427
|
+
}
|
|
428
|
+
// Parenthesized as one whereSql entry — whereSql is joined with
|
|
429
|
+
// " AND " below, an unparenthesized OR here would leak past the
|
|
430
|
+
// tenant filter that follows.
|
|
431
|
+
whereSql.push(`(${orParts.join(" OR ")})`);
|
|
261
432
|
}
|
|
262
433
|
|
|
263
|
-
// Build the WHERE clause as raw SQL — ownership produces a
|
|
264
|
-
// parameterised fragment that we splice in alongside simple WhereObject
|
|
265
|
-
// conditions (cursor, search-filter-IDs, screen-filter, tenant-scope).
|
|
266
|
-
const tableName = String((table as unknown as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL]);
|
|
267
|
-
const whereSql: string[] = [];
|
|
268
|
-
const params: unknown[] = [];
|
|
269
|
-
const physicalCol = (field: string): string =>
|
|
270
|
-
(table[field] as { name?: string } | undefined)?.name ?? toSnakeCase(field);
|
|
271
|
-
const colSql = (field: string): string => `"${physicalCol(field)}"`;
|
|
272
|
-
// Field-level read access gates filtering and sorting the same way it
|
|
273
|
-
// gates the projected row: a caller who cannot read a field must not be
|
|
274
|
-
// able to probe its values through result counts or ordering (fw#2629).
|
|
275
|
-
const fieldReadClause = (field: string) =>
|
|
276
|
-
buildOwnershipClause(user, normalizeAccessEntry(entity.fields[field]?.access?.read), table);
|
|
277
434
|
const sortField =
|
|
278
435
|
payload.sort && table[payload.sort] && fieldReadClause(payload.sort).kind === "pass"
|
|
279
436
|
? payload.sort
|
|
@@ -305,13 +462,6 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
|
|
|
305
462
|
);
|
|
306
463
|
}
|
|
307
464
|
}
|
|
308
|
-
if (filterIds) {
|
|
309
|
-
const placeholders = filterIds.map((id) => {
|
|
310
|
-
params.push(id);
|
|
311
|
-
return `$${params.length}`;
|
|
312
|
-
});
|
|
313
|
-
whereSql.push(`${colSql("id")} IN (${placeholders.join(", ")})`);
|
|
314
|
-
}
|
|
315
465
|
if (ownership.kind === "sql") {
|
|
316
466
|
const shifted = shiftParams(
|
|
317
467
|
{ sqlText: ownership.sqlText, params: ownership.params },
|
|
@@ -397,13 +547,15 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
|
|
|
397
547
|
sortText === undefined ? encodeCursor(cursorId) : encodeKeysetCursor(sortText, cursorId);
|
|
398
548
|
}
|
|
399
549
|
|
|
400
|
-
// total: extra COUNT(*) —
|
|
401
|
-
// Postgres
|
|
402
|
-
//
|
|
403
|
-
//
|
|
550
|
+
// total: extra COUNT(*) — only when explicitly requested (pager UI).
|
|
551
|
+
// Postgres cost is O(table-scan) without a filter, with a filter as
|
|
552
|
+
// expensive as the corresponding WHERE — cheap enough on indexed columns.
|
|
553
|
+
// On the search path, `total = filterIds.length` needs no extra query —
|
|
554
|
+
// unless a reference match contributed additional rows (fw#2660), in
|
|
555
|
+
// which case the raw filterIds length alone would undercount.
|
|
404
556
|
let total: number | undefined;
|
|
405
557
|
if (totalCount) {
|
|
406
|
-
if (filterIds) {
|
|
558
|
+
if (filterIds && !referenceClauseActive) {
|
|
407
559
|
total = filterIds.length;
|
|
408
560
|
} else {
|
|
409
561
|
const countSql = `SELECT COUNT(*)::int AS count FROM "${tableName}"${whereClauseSqlText}`;
|
|
@@ -621,6 +621,83 @@ describe("createRegistry", () => {
|
|
|
621
621
|
expect(registry.getSearchableFields("user")).toEqual(["email", "lastName"]);
|
|
622
622
|
expect(registry.getSearchableFields("nonexistent")).toEqual([]);
|
|
623
623
|
});
|
|
624
|
+
|
|
625
|
+
test("returns searchable reference fields for entity, without leaking them into getSearchableFields (fw#2660)", () => {
|
|
626
|
+
const feature = defineFeature("crm", (r) => {
|
|
627
|
+
r.entity(
|
|
628
|
+
"customer",
|
|
629
|
+
createEntity({ table: "Customers", fields: { name: createTextField() } }),
|
|
630
|
+
);
|
|
631
|
+
r.entity(
|
|
632
|
+
"order",
|
|
633
|
+
createEntity({
|
|
634
|
+
table: "Orders",
|
|
635
|
+
fields: {
|
|
636
|
+
note: createTextField({ searchable: true }),
|
|
637
|
+
customerId: {
|
|
638
|
+
type: "reference",
|
|
639
|
+
entity: "customer",
|
|
640
|
+
labelField: "name",
|
|
641
|
+
searchable: true,
|
|
642
|
+
},
|
|
643
|
+
},
|
|
644
|
+
}),
|
|
645
|
+
);
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
const registry = createRegistry([feature]);
|
|
649
|
+
// Meili invariant: the search-index build reads getSearchableFields, and
|
|
650
|
+
// a reference field is never text-indexed there — it's resolved via a
|
|
651
|
+
// live DB lookup at query time instead (getSearchableReferences).
|
|
652
|
+
expect(registry.getSearchableFields("order")).toEqual(["note"]);
|
|
653
|
+
expect(registry.getSearchableReferences("order")).toEqual([
|
|
654
|
+
{ fieldName: "customerId", targetEntityName: "customer", labelField: "name" },
|
|
655
|
+
]);
|
|
656
|
+
expect(registry.getSearchableReferences("nonexistent")).toEqual([]);
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
test("throws at boot when a searchable reference field has no explicit labelField (fw#2660)", () => {
|
|
660
|
+
const feature = defineFeature("crm", (r) => {
|
|
661
|
+
r.entity(
|
|
662
|
+
"customer",
|
|
663
|
+
createEntity({ table: "Customers", fields: { name: createTextField() } }),
|
|
664
|
+
);
|
|
665
|
+
r.entity(
|
|
666
|
+
"order",
|
|
667
|
+
createEntity({
|
|
668
|
+
table: "Orders",
|
|
669
|
+
fields: {
|
|
670
|
+
customerId: { type: "reference", entity: "customer", searchable: true },
|
|
671
|
+
},
|
|
672
|
+
}),
|
|
673
|
+
);
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
expect(() => createRegistry([feature])).toThrow(/labelField/);
|
|
677
|
+
});
|
|
678
|
+
|
|
679
|
+
test("throws at boot when searchable is combined with multiple on a reference field (fw#2660)", () => {
|
|
680
|
+
const feature = defineFeature("crm", (r) => {
|
|
681
|
+
r.entity("tag", createEntity({ table: "Tags", fields: { name: createTextField() } }));
|
|
682
|
+
r.entity(
|
|
683
|
+
"post",
|
|
684
|
+
createEntity({
|
|
685
|
+
table: "Posts",
|
|
686
|
+
fields: {
|
|
687
|
+
tagIds: {
|
|
688
|
+
type: "reference",
|
|
689
|
+
entity: "tag",
|
|
690
|
+
labelField: "name",
|
|
691
|
+
searchable: true,
|
|
692
|
+
multiple: true,
|
|
693
|
+
},
|
|
694
|
+
},
|
|
695
|
+
}),
|
|
696
|
+
);
|
|
697
|
+
});
|
|
698
|
+
|
|
699
|
+
expect(() => createRegistry([feature])).toThrow(/multiple/);
|
|
700
|
+
});
|
|
624
701
|
});
|
|
625
702
|
|
|
626
703
|
// --- Access ---
|
|
@@ -1597,7 +1674,6 @@ describe("r.relation()", () => {
|
|
|
1597
1674
|
type: "belongsTo",
|
|
1598
1675
|
target: "department",
|
|
1599
1676
|
foreignKey: "departmentId",
|
|
1600
|
-
searchInclude: ["name"],
|
|
1601
1677
|
});
|
|
1602
1678
|
});
|
|
1603
1679
|
|
|
@@ -1613,7 +1689,6 @@ describe("r.relation()", () => {
|
|
|
1613
1689
|
type: "manyToMany",
|
|
1614
1690
|
target: "role",
|
|
1615
1691
|
through: { table: "UserRoles", sourceKey: "userId", targetKey: "roleId" },
|
|
1616
|
-
searchInclude: ["name"],
|
|
1617
1692
|
});
|
|
1618
1693
|
});
|
|
1619
1694
|
|
|
@@ -1648,7 +1723,6 @@ describe("registry relations", () => {
|
|
|
1648
1723
|
type: "manyToMany",
|
|
1649
1724
|
target: "role",
|
|
1650
1725
|
through: { table: "UserRoles", sourceKey: "userId", targetKey: "roleId" },
|
|
1651
|
-
searchInclude: ["name"],
|
|
1652
1726
|
});
|
|
1653
1727
|
});
|
|
1654
1728
|
|
|
@@ -1657,31 +1731,6 @@ describe("registry relations", () => {
|
|
|
1657
1731
|
expect(rels["roles"]).toBeDefined();
|
|
1658
1732
|
});
|
|
1659
1733
|
|
|
1660
|
-
test("getSearchIncludes returns fields to index from relations", () => {
|
|
1661
|
-
const feature = defineFeature("test", (r) => {
|
|
1662
|
-
r.entity("user", createEntity({ table: "Users", fields: {} }));
|
|
1663
|
-
r.entity("role", createEntity({ table: "Roles", fields: { name: createTextField() } }));
|
|
1664
|
-
r.entity("department", createEntity({ table: "Depts", fields: { name: createTextField() } }));
|
|
1665
|
-
r.relation("user", "roles", {
|
|
1666
|
-
type: "manyToMany",
|
|
1667
|
-
target: "role",
|
|
1668
|
-
through: { table: "UserRoles", sourceKey: "userId", targetKey: "roleId" },
|
|
1669
|
-
searchInclude: ["name"],
|
|
1670
|
-
});
|
|
1671
|
-
r.relation("user", "department", {
|
|
1672
|
-
type: "belongsTo",
|
|
1673
|
-
target: "department",
|
|
1674
|
-
foreignKey: "departmentId",
|
|
1675
|
-
searchInclude: ["name"],
|
|
1676
|
-
});
|
|
1677
|
-
});
|
|
1678
|
-
|
|
1679
|
-
const registry = createRegistry([feature]);
|
|
1680
|
-
const includes = registry.getSearchIncludes("user");
|
|
1681
|
-
expect(includes.get("roles")).toEqual(["name"]);
|
|
1682
|
-
expect(includes.get("department")).toEqual(["name"]);
|
|
1683
|
-
});
|
|
1684
|
-
|
|
1685
1734
|
test("throws on relation to non-existent entity", () => {
|
|
1686
1735
|
const feature = defineFeature("test", (r) => {
|
|
1687
1736
|
r.entity("user", createEntity({ table: "Users", fields: {} }));
|
|
@@ -339,6 +339,13 @@ export function defineEntityQueryHandler(
|
|
|
339
339
|
const result = await executor.list(listPayload, query.user, db, {
|
|
340
340
|
...(ctx.searchAdapter !== undefined && { searchAdapter: ctx.searchAdapter }),
|
|
341
341
|
...(ctx.includeDeleted === true && { includeDeleted: true }),
|
|
342
|
+
// fw#2660 — registry access only exists at request time, so the
|
|
343
|
+
// searchable-reference cache + entity resolver travel through the
|
|
344
|
+
// same runtime-override extension point as searchAdapter above.
|
|
345
|
+
referenceSearch: {
|
|
346
|
+
fields: ctx.registry.getSearchableReferences(entityName),
|
|
347
|
+
resolveEntity: (name) => ctx.registry.getEntity(name),
|
|
348
|
+
},
|
|
342
349
|
});
|
|
343
350
|
const enrichedRows = hasRefFields
|
|
344
351
|
? await enrichWithReferences(
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { IncomingRelation, RegistryState } from "./registry-state";
|
|
1
|
+
import type { IncomingRelation, RegistryState, SearchableReferenceField } from "./registry-state";
|
|
2
2
|
import { filterByPhase, filterOwned } from "./registry-state";
|
|
3
3
|
import type {
|
|
4
4
|
AuthClaimsHookDef,
|
|
@@ -97,8 +97,8 @@ export function buildRegistryFacade(state: RegistryState): Registry {
|
|
|
97
97
|
return (state.relationMap.get(entityName) ?? {}) as EntityRelations; // @cast-boundary schema-walk
|
|
98
98
|
},
|
|
99
99
|
|
|
100
|
-
|
|
101
|
-
return state.
|
|
100
|
+
getSearchableReferences(entityName: string): readonly SearchableReferenceField[] {
|
|
101
|
+
return state.searchableReferencesCache.get(entityName) ?? [];
|
|
102
102
|
},
|
|
103
103
|
|
|
104
104
|
getIncomingRelations(entityName: string): readonly IncomingRelation[] {
|
|
@@ -49,6 +49,17 @@ export type IncomingRelation = {
|
|
|
49
49
|
relation: RelationDefinition;
|
|
50
50
|
};
|
|
51
51
|
|
|
52
|
+
// One entry per `ReferenceFieldDef` with `searchable: true` (fw#2660) —
|
|
53
|
+
// resolved once at boot so the read-path never re-parses `field.entity`
|
|
54
|
+
// per request. `labelField` is guaranteed defined here: buildSearchableSortableCaches
|
|
55
|
+
// throws at boot for a searchable reference missing an explicit labelField
|
|
56
|
+
// (the "id" default is a UUID column, ILIKE against it would 500 at request time).
|
|
57
|
+
export type SearchableReferenceField = {
|
|
58
|
+
readonly fieldName: string;
|
|
59
|
+
readonly targetEntityName: string;
|
|
60
|
+
readonly labelField: string;
|
|
61
|
+
};
|
|
62
|
+
|
|
52
63
|
const IMPLICIT_PROJECTION_SUFFIX = "-entity" as const;
|
|
53
64
|
|
|
54
65
|
// Pro r.entity-Registration eine ImplicitProjection mit auto-generierten
|
|
@@ -223,7 +234,7 @@ export type RegistryState = {
|
|
|
223
234
|
treeActionsMap: Map<string, Readonly<Record<string, TreeActionDef>>>;
|
|
224
235
|
searchableFieldsCache: Map<string, readonly string[]>;
|
|
225
236
|
sortableFieldsCache: Map<string, readonly string[]>;
|
|
226
|
-
|
|
237
|
+
searchableReferencesCache: Map<string, readonly SearchableReferenceField[]>;
|
|
227
238
|
incomingRelationsCache: Map<string, IncomingRelation[]>;
|
|
228
239
|
hasRateLimitedHandlerCached: boolean;
|
|
229
240
|
};
|
|
@@ -286,7 +297,7 @@ export function createInitialState(): RegistryState {
|
|
|
286
297
|
treeActionsMap: new Map(),
|
|
287
298
|
searchableFieldsCache: new Map(),
|
|
288
299
|
sortableFieldsCache: new Map(),
|
|
289
|
-
|
|
300
|
+
searchableReferencesCache: new Map(),
|
|
290
301
|
incomingRelationsCache: new Map(),
|
|
291
302
|
hasRateLimitedHandlerCached: false,
|
|
292
303
|
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { configureEventPiiCatalog } from "../crypto/event-pii";
|
|
2
2
|
import { resolveName } from "./handler-helpers";
|
|
3
|
-
import type { RegistryState } from "./registry-state";
|
|
3
|
+
import type { RegistryState, SearchableReferenceField } from "./registry-state";
|
|
4
4
|
import { buildImplicitProjection, hasFieldAccessRules, qualify } from "./registry-state";
|
|
5
5
|
import {
|
|
6
6
|
buildSoftDeleteCleanupJob,
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
SOFT_DELETE_GRACE_DAYS_KEY,
|
|
11
11
|
softDeleteGraceDaysConfig,
|
|
12
12
|
} from "./soft-delete-cleanup";
|
|
13
|
-
import type { EventPiiFields, EventUpcastFn, FeatureDefinition } from "./types";
|
|
13
|
+
import type { EventPiiFields, EventUpcastFn, FeatureDefinition, ReferenceFieldDef } from "./types";
|
|
14
14
|
import { HookPhases } from "./types";
|
|
15
15
|
|
|
16
16
|
function allHandlerQns(state: RegistryState): ReadonlySet<string> {
|
|
@@ -154,13 +154,50 @@ export function applyExtensionUsages(state: RegistryState): void {
|
|
|
154
154
|
}
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
-
//
|
|
158
|
-
|
|
159
|
-
|
|
157
|
+
// "customer" (same-feature) or "users:customer" (cross-feature,
|
|
158
|
+
// "<featureName>:<entityName>") — only the entity name matters here, names
|
|
159
|
+
// are globally unique in entityMap. Local duplicate of the same parse done
|
|
160
|
+
// in db/eagerload.ts's parseRefEntity — this loop has no feature context to
|
|
161
|
+
// hand to engine/parse-ref-target.ts's parseRefTarget.
|
|
162
|
+
function parseReferenceTargetEntityName(raw: string): string {
|
|
163
|
+
const idx = raw.indexOf(":");
|
|
164
|
+
return idx < 0 ? raw : raw.slice(idx + 1);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// fw#2660: labelField defaults to "id" (a UUID column) elsewhere, but a
|
|
168
|
+
// searchable reference ILIKEs the label column — an implicit or explicit
|
|
169
|
+
// "id" default would 500 on every search request. Fail at boot.
|
|
170
|
+
function buildSearchableReferenceField(
|
|
171
|
+
entityName: string,
|
|
172
|
+
fieldName: string,
|
|
173
|
+
field: ReferenceFieldDef,
|
|
174
|
+
): SearchableReferenceField {
|
|
175
|
+
if (field.labelField === undefined || field.labelField === "id") {
|
|
176
|
+
throw new Error(
|
|
177
|
+
`[Entity ${entityName}] field "${fieldName}": searchable reference fields require an ` +
|
|
178
|
+
`explicit, non-"id" labelField. "id" is a UUID column — ILIKE against it fails ` +
|
|
179
|
+
`at request time. Set labelField to a human-readable field on the referenced entity.`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
if (field.multiple === true) {
|
|
183
|
+
throw new Error(
|
|
184
|
+
`[Entity ${entityName}] field "${fieldName}": searchable is not supported on multiple ` +
|
|
185
|
+
`(array) reference fields — remove "multiple" or "searchable".`,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
return {
|
|
189
|
+
fieldName,
|
|
190
|
+
targetEntityName: parseReferenceTargetEntityName(field.entity),
|
|
191
|
+
labelField: field.labelField,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
160
194
|
|
|
195
|
+
// Precompute: searchable/sortable fields, searchable reference fields.
|
|
196
|
+
export function buildSearchableSortableCaches(state: RegistryState): void {
|
|
161
197
|
for (const [name, entity] of state.entityMap) {
|
|
162
198
|
const searchable: string[] = [];
|
|
163
199
|
const sortable: string[] = [];
|
|
200
|
+
const searchableReferences: SearchableReferenceField[] = [];
|
|
164
201
|
for (const [fieldName, field] of Object.entries(entity.fields)) {
|
|
165
202
|
if (field.type === "text" && field.searchable === true) searchable.push(fieldName);
|
|
166
203
|
if (field.type === "text" && field.sortable === true) sortable.push(fieldName);
|
|
@@ -169,9 +206,13 @@ export function buildSearchableSortableCaches(state: RegistryState): void {
|
|
|
169
206
|
if (subField.searchable === true) searchable.push(`${fieldName}_${subName}`);
|
|
170
207
|
}
|
|
171
208
|
}
|
|
209
|
+
if (field.type === "reference" && field.searchable === true) {
|
|
210
|
+
searchableReferences.push(buildSearchableReferenceField(name, fieldName, field));
|
|
211
|
+
}
|
|
172
212
|
}
|
|
173
213
|
state.searchableFieldsCache.set(name, searchable);
|
|
174
214
|
state.sortableFieldsCache.set(name, sortable);
|
|
215
|
+
state.searchableReferencesCache.set(name, searchableReferences);
|
|
175
216
|
}
|
|
176
217
|
}
|
|
177
218
|
|
|
@@ -223,16 +264,8 @@ export function buildImplicitProjections(
|
|
|
223
264
|
}
|
|
224
265
|
}
|
|
225
266
|
|
|
226
|
-
export function
|
|
267
|
+
export function buildIncomingRelations(state: RegistryState): void {
|
|
227
268
|
for (const [entityName, rels] of state.relationMap) {
|
|
228
|
-
const includes = new Map<string, readonly string[]>();
|
|
229
|
-
for (const [relName, rel] of Object.entries(rels)) {
|
|
230
|
-
if ((rel.type === "belongsTo" || rel.type === "manyToMany") && rel.searchInclude?.length) {
|
|
231
|
-
includes.set(relName, rel.searchInclude);
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
state.searchIncludesCache.set(entityName, includes);
|
|
235
|
-
|
|
236
269
|
// Build reverse index for incoming relations
|
|
237
270
|
for (const [relName, rel] of Object.entries(rels)) {
|
|
238
271
|
const existing = state.incomingRelationsCache.get(rel.target) ?? [];
|
package/src/engine/registry.ts
CHANGED
|
@@ -20,8 +20,8 @@ import {
|
|
|
20
20
|
autoWireSoftDeleteJobs,
|
|
21
21
|
buildEventUpcasterChains,
|
|
22
22
|
buildImplicitProjections,
|
|
23
|
+
buildIncomingRelations,
|
|
23
24
|
buildSearchableSortableCaches,
|
|
24
|
-
buildSearchIncludesAndIncomingRelations,
|
|
25
25
|
computeHasRateLimitedHandler,
|
|
26
26
|
finalizeWorkspaceNavMembership,
|
|
27
27
|
populateHandlerEntityMappings,
|
|
@@ -68,7 +68,7 @@ export function createRegistry(features: readonly FeatureDefinition[]): Registry
|
|
|
68
68
|
applyExtensionUsages(state);
|
|
69
69
|
buildSearchableSortableCaches(state);
|
|
70
70
|
buildImplicitProjections(state, features);
|
|
71
|
-
|
|
71
|
+
buildIncomingRelations(state);
|
|
72
72
|
validateFieldAccessHandlersAreEntityMapped(state, features);
|
|
73
73
|
validateExtensionPreSaveWiring(features);
|
|
74
74
|
validateRelationTargetsExist(state);
|
package/src/ui-types/index.ts
CHANGED
|
@@ -107,5 +107,13 @@ export type {
|
|
|
107
107
|
QualifiedContentCollection,
|
|
108
108
|
WorkspaceSchema,
|
|
109
109
|
} from "./app-schema";
|
|
110
|
-
export type {
|
|
111
|
-
|
|
110
|
+
export type {
|
|
111
|
+
ListRowMetaColumnType,
|
|
112
|
+
ListRowMetaReference,
|
|
113
|
+
SystemReferenceLabel,
|
|
114
|
+
} from "./list-row-meta";
|
|
115
|
+
export {
|
|
116
|
+
LIST_ROW_META_COLUMNS,
|
|
117
|
+
LIST_ROW_META_REFERENCES,
|
|
118
|
+
SYSTEM_REFERENCE_LABELS,
|
|
119
|
+
} from "./list-row-meta";
|
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
// deletedById) — this is also the SAME set the boot-validator's entityList
|
|
13
13
|
// column checks accept, so a softDelete column stays a boot-time error
|
|
14
14
|
// instead of a renderer-side throw (see screens.ts / entity-list-screens.ts).
|
|
15
|
+
import { SYSTEM_TENANT_ID } from "../engine/types/identifiers";
|
|
16
|
+
|
|
15
17
|
export type ListRowMetaColumnType = "text" | "number" | "timestamp";
|
|
16
18
|
|
|
17
19
|
export const LIST_ROW_META_COLUMNS: Readonly<Record<string, ListRowMetaColumnType>> = {
|
|
@@ -39,3 +41,19 @@ export type ListRowMetaReference = {
|
|
|
39
41
|
export const LIST_ROW_META_REFERENCES: Readonly<Record<string, ListRowMetaReference>> = {
|
|
40
42
|
tenantId: { refFeature: "tenant", refEntity: "tenant", refLabelField: "name" },
|
|
41
43
|
};
|
|
44
|
+
|
|
45
|
+
export type SystemReferenceLabel = {
|
|
46
|
+
readonly id: string;
|
|
47
|
+
readonly labelKey: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// Reference ids that never resolve through the bulk id->row lookup because
|
|
51
|
+
// no row exists for them (e.g. SYSTEM_TENANT_ID has no tenant record, see
|
|
52
|
+
// isSystemTenant()) — every reference column falls back to the raw id
|
|
53
|
+
// otherwise. Keyed by `${refFeature}:${refEntity}` so useReferenceLookup can
|
|
54
|
+
// consult this generically instead of any screen/hook hardcoding an entity
|
|
55
|
+
// name. Central so the label applies to every screen that references this
|
|
56
|
+
// entity, not just delivery-log (fw#2662).
|
|
57
|
+
export const SYSTEM_REFERENCE_LABELS: Readonly<Record<string, SystemReferenceLabel>> = {
|
|
58
|
+
"tenant:tenant": { id: SYSTEM_TENANT_ID, labelKey: "kumiko.reference.system-tenant" },
|
|
59
|
+
};
|