@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.237.2",
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.237.2",
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.237.2",
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-Index-OR-Rewrite (#818), lock-step mit buildWhereClause
187
- // in bun-db/query.ts — Equality auf lookupable-Feldern matcht
188
- // Klartext-Arm ODER HMAC-Arm.
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, payload.search, {
377
+ const results = await effectiveSearchAdapter.search(searchTenantId, searchTerm, {
255
378
  filterType: entityName,
256
379
  });
257
380
  filterIds = results.map((r) => r.entityId);
258
- if (filterIds.length === 0) {
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(*) — nur wenn explizit angefordert (Pager-UI).
401
- // Postgres-Cost ist O(table-scan) ohne Filter, mit Filter so teuer
402
- // wie der entsprechende WHERE — bei indexed columns billig genug.
403
- // Bei Search-Path ist `total = filterIds.length` ohne extra Query.
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
- getSearchIncludes(entityName: string): ReadonlyMap<string, readonly string[]> {
101
- return state.searchIncludesCache.get(entityName) ?? new Map();
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
- searchIncludesCache: Map<string, ReadonlyMap<string, readonly string[]>>;
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
- searchIncludesCache: new Map(),
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
- // Precompute: searchable/sortable fields.
158
- export function buildSearchableSortableCaches(state: RegistryState): void {
159
- // Precompute: searchable/sortable fields, search includes, incoming relations
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 buildSearchIncludesAndIncomingRelations(state: RegistryState): void {
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) ?? [];
@@ -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
- buildSearchIncludesAndIncomingRelations(state);
71
+ buildIncomingRelations(state);
72
72
  validateFieldAccessHandlersAreEntityMapped(state, features);
73
73
  validateExtensionPreSaveWiring(features);
74
74
  validateRelationTargetsExist(state);
@@ -107,5 +107,13 @@ export type {
107
107
  QualifiedContentCollection,
108
108
  WorkspaceSchema,
109
109
  } from "./app-schema";
110
- export type { ListRowMetaColumnType, ListRowMetaReference } from "./list-row-meta";
111
- export { LIST_ROW_META_COLUMNS, LIST_ROW_META_REFERENCES } from "./list-row-meta";
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
+ };