@cosmicdrift/kumiko-framework 0.238.0 → 0.240.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.
@@ -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: {} }));