@cosmicdrift/kumiko-framework 0.256.0 → 0.258.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.256.0",
3
+ "version": "0.258.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.256.0",
201
+ "@cosmicdrift/kumiko-types": "0.258.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.256.0",
217
+ "@cosmicdrift/kumiko-dispatcher-live": "0.258.0",
218
218
  "bun-types": "^1.3.13",
219
219
  "pino-pretty": "^13.1.3"
220
220
  },
@@ -0,0 +1,213 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { createEntity, createTextField } from "../../engine/factories";
3
+ import { combineClauses, from } from "../../engine/ownership";
4
+ import type { EntityDefinition, SessionUser } from "../../engine/types";
5
+ import { buildParentRefClause } from "../parent-ref-clause";
6
+ import { buildEntityTable } from "../table-builder";
7
+ import type { TenantDb } from "../tenant-db";
8
+
9
+ const TENANT = "11111111-1111-4111-8111-111111111111";
10
+
11
+ const user: SessionUser = {
12
+ id: "22222222-2222-4222-8222-222222222222",
13
+ tenantId: TENANT,
14
+ roles: ["TenantMember"],
15
+ claims: {},
16
+ };
17
+
18
+ // Only `mode` and `tenantId` are read by the clause builder.
19
+ const tenantDb = { mode: "tenant", tenantId: TENANT } as unknown as TenantDb;
20
+ const systemDb = { mode: "system", tenantId: TENANT } as unknown as TenantDb;
21
+
22
+ const textField = () =>
23
+ createTextField({
24
+ required: true,
25
+ maxLength: 64,
26
+ personal: false,
27
+ reason: "technical_reference",
28
+ });
29
+
30
+ const joinEntity = createEntity({
31
+ table: "read_test_joins",
32
+ fields: { entityType: textField(), entityId: textField() },
33
+ parentRef: { entityTypeField: "entityType", entityIdField: "entityId" },
34
+ });
35
+
36
+ function hostEntity(opts?: {
37
+ readonly access?: EntityDefinition["access"];
38
+ readonly softDelete?: boolean;
39
+ readonly idType?: "serial" | "uuid";
40
+ readonly parentRef?: EntityDefinition["parentRef"];
41
+ }) {
42
+ return createEntity({
43
+ table: "read_test_hosts",
44
+ fields: { ownerId: textField(), entityType: textField(), entityId: textField() },
45
+ ...opts,
46
+ });
47
+ }
48
+
49
+ const joinTable = buildEntityTable("test-join", joinEntity);
50
+
51
+ function build(
52
+ entities: ReadonlyMap<string, EntityDefinition> | undefined,
53
+ opts: { includeDeleted?: boolean; narrowTypes?: ReadonlySet<string>; db?: TenantDb } = {},
54
+ ) {
55
+ return buildParentRefClause(
56
+ joinEntity,
57
+ joinTable,
58
+ "read_test_joins",
59
+ user,
60
+ opts.db ?? tenantDb,
61
+ entities === undefined ? undefined : { entities },
62
+ {
63
+ ...(opts.includeDeleted !== undefined && { includeDeleted: opts.includeDeleted }),
64
+ ...(opts.narrowTypes !== undefined && { narrowTypes: opts.narrowTypes }),
65
+ },
66
+ );
67
+ }
68
+
69
+ describe("buildParentRefClause", () => {
70
+ test("an entity without parentRef is not gated", () => {
71
+ const plain = createEntity({ table: "read_plain", fields: { a: textField() } });
72
+ const clause = buildParentRefClause(
73
+ plain,
74
+ buildEntityTable("plain", plain),
75
+ "read_plain",
76
+ user,
77
+ tenantDb,
78
+ { entities: new Map([["host", hostEntity()]]) },
79
+ {},
80
+ );
81
+ expect(clause.kind).toBe("pass");
82
+ });
83
+
84
+ test("a raw executor call without the registry option stays ungated", () => {
85
+ expect(build(undefined).kind).toBe("pass");
86
+ });
87
+
88
+ test("no candidate at all denies every row", () => {
89
+ expect(build(new Map()).kind).toBe("empty");
90
+ });
91
+
92
+ test("a candidate whose read-ownership can never match is dropped, denying when it was the only one", () => {
93
+ // The role has a rule, but the column it names is not on the host — the
94
+ // ownership clause collapses to `empty`, so the branch cannot be satisfied.
95
+ const host = hostEntity({ access: { read: { TenantMember: from("user:id", "notAColumn") } } });
96
+ expect(build(new Map([["host", host]])).kind).toBe("empty");
97
+ });
98
+
99
+ test("a serial-id candidate is excluded — it has no event-store read path", () => {
100
+ const host = hostEntity({ idType: "serial" });
101
+ expect(build(new Map([["host", host]])).kind).toBe("empty");
102
+ });
103
+
104
+ test("a candidate that is itself a join row is excluded — no recursive gating", () => {
105
+ const host = hostEntity({
106
+ parentRef: { entityTypeField: "entityType", entityIdField: "entityId" },
107
+ });
108
+ expect(build(new Map([["host", host]])).kind).toBe("empty");
109
+ });
110
+
111
+ test("narrowTypes restricts the OR-chain to the named hosts", () => {
112
+ const entities = new Map([
113
+ ["host", hostEntity()],
114
+ ["other", hostEntity()],
115
+ ["third", hostEntity()],
116
+ ]);
117
+ const all = build(entities);
118
+ const narrowed = build(entities, { narrowTypes: new Set(["other"]) });
119
+ if (all.kind !== "sql" || narrowed.kind !== "sql") throw new Error("expected sql clauses");
120
+ expect(all.sqlText.split(" OR ")).toHaveLength(3);
121
+ expect(narrowed.sqlText.split(" OR ")).toHaveLength(1);
122
+ expect(narrowed.params).toContain("other");
123
+ expect(narrowed.params).not.toContain("host");
124
+ });
125
+
126
+ test("the host's soft-delete filter applies by default and lifts under includeDeleted", () => {
127
+ const entities = new Map([["host", hostEntity({ softDelete: true })]]);
128
+ const gated = build(entities);
129
+ const trash = build(entities, { includeDeleted: true });
130
+ if (gated.kind !== "sql" || trash.kind !== "sql") throw new Error("expected sql clauses");
131
+ expect(gated.sqlText).toContain('"is_deleted" = FALSE');
132
+ expect(trash.sqlText).not.toContain("is_deleted");
133
+ });
134
+
135
+ test("the host id is matched through a shape guard, so a foreign id form cannot raise 22P02", () => {
136
+ const clause = build(new Map([["host", hostEntity()]]));
137
+ if (clause.kind !== "sql") throw new Error("expected sql clause");
138
+ expect(clause.sqlText).toContain("CASE WHEN");
139
+ expect(clause.sqlText).toContain("::uuid");
140
+ // The shape itself is bound, never inlined — a literal carrying `$` would
141
+ // be rewritten by shiftParams when the clause is combined.
142
+ expect(clause.params[0]).toBe("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$");
143
+ expect(clause.sqlText).not.toContain("[0-9a-f]");
144
+ });
145
+
146
+ test("the tenant predicate is bound once and reused across branches", () => {
147
+ const entities = new Map([
148
+ ["host", hostEntity()],
149
+ ["other", hostEntity()],
150
+ ]);
151
+ const clause = build(entities);
152
+ if (clause.kind !== "sql") throw new Error("expected sql clause");
153
+ expect(clause.params.filter((p) => p === TENANT)).toHaveLength(1);
154
+ // uuid shape + 2 tenant params + one host-name param per branch.
155
+ expect(clause.params).toHaveLength(5);
156
+ });
157
+
158
+ test("a system-mode db drops the tenant predicate, mirroring list()", () => {
159
+ const clause = build(new Map([["host", hostEntity()]]), { db: systemDb });
160
+ if (clause.kind !== "sql") throw new Error("expected sql clause");
161
+ expect(clause.sqlText).not.toContain("tenant_id");
162
+ expect(clause.params).not.toContain(TENANT);
163
+ });
164
+
165
+ test("a dropped branch reserves no placeholder — every $N still binds its own value", () => {
166
+ // First candidate collapses to `empty` (column missing on the host), so its
167
+ // tentatively-reserved indices must not leave a gap for the second.
168
+ const entities = new Map([
169
+ [
170
+ "dropped",
171
+ hostEntity({ access: { read: { TenantMember: from("user:id", "notAColumn") } } }),
172
+ ],
173
+ ["kept", hostEntity({ access: { read: { TenantMember: from("user:id", "ownerId") } } })],
174
+ ]);
175
+ const clause = build(entities);
176
+ if (clause.kind !== "sql") throw new Error("expected sql clause");
177
+
178
+ const indices = [...clause.sqlText.matchAll(/\$(\d+)/g)].map((m) => Number(m[1]));
179
+ expect(Math.max(...indices)).toBeLessThanOrEqual(clause.params.length);
180
+ expect(Math.min(...indices)).toBeGreaterThanOrEqual(1);
181
+
182
+ // The kept branch's host-name param must sit at the placeholder the SQL
183
+ // actually names for it, not one slot over.
184
+ const keptSlot = clause.params.indexOf("kept") + 1;
185
+ expect(keptSlot).toBeGreaterThan(0);
186
+ expect(clause.sqlText).toContain(`"entity_type" = $${keptSlot}`);
187
+ expect(clause.params).toContain(user.id);
188
+ expect(clause.params).not.toContain("dropped");
189
+ });
190
+ });
191
+
192
+ describe("combineClauses", () => {
193
+ const sqlA = { kind: "sql", sqlText: '"a" = $1', params: ["av"] } as const;
194
+ const sqlB = { kind: "sql", sqlText: '"b" = $1 OR "c" = $2', params: ["bv", "cv"] } as const;
195
+
196
+ test("empty on either side denies", () => {
197
+ expect(combineClauses({ kind: "empty" }, sqlA).kind).toBe("empty");
198
+ expect(combineClauses(sqlA, { kind: "empty" }).kind).toBe("empty");
199
+ });
200
+
201
+ test("pass is neutral", () => {
202
+ expect(combineClauses({ kind: "pass" }, sqlA)).toEqual(sqlA);
203
+ expect(combineClauses(sqlA, { kind: "pass" })).toEqual(sqlA);
204
+ expect(combineClauses({ kind: "pass" }, { kind: "pass" }).kind).toBe("pass");
205
+ });
206
+
207
+ test("the second clause's placeholders are shifted past the first's params", () => {
208
+ const combined = combineClauses(sqlA, sqlB);
209
+ if (combined.kind !== "sql") throw new Error("expected sql clause");
210
+ expect(combined.sqlText).toBe('("a" = $1 AND "b" = $2 OR "c" = $3)');
211
+ expect(combined.params).toEqual(["av", "bv", "cv"]);
212
+ });
213
+ });
@@ -3,7 +3,12 @@ import { collectPiiSubjectFields, computeBlindIndex, configuredBlindIndexKey } f
3
3
  import { escapeLikePattern } from "../crypto/ciphertext-pattern";
4
4
  import { executeRawQueryRead } from "../db/queries/raw-sql";
5
5
  import { coerceRow, extractTableInfo } from "../db/query";
6
- import { buildOwnershipClause, normalizeAccessEntry, shiftParams } from "../engine/ownership";
6
+ import {
7
+ buildOwnershipClause,
8
+ combineClauses,
9
+ normalizeAccessEntry,
10
+ shiftParams,
11
+ } from "../engine/ownership";
7
12
  import type { EntityDefinition, EntityId } from "../engine/types";
8
13
  import { SYSTEM_TENANT_ID } from "../engine/types/identifiers";
9
14
  import { UnprocessableError } from "../errors";
@@ -14,7 +19,8 @@ import { decodeKeysetCursor, encodeCursor, encodeKeysetCursor } from "./cursor";
14
19
  import { collectEncryptedFieldNames } from "./entity-field-encryption";
15
20
  import type { EventStoreExecutor } from "./event-store-executor";
16
21
  import { buildFilterWhere, type ExecutorContext, type Table } from "./event-store-executor-context";
17
- import { buildEntityTable, toSnakeCase } from "./table-builder";
22
+ import { buildParentRefClause } from "./parent-ref-clause";
23
+ import { buildEntityTable, physicalColumnName } from "./table-builder";
18
24
  import type { TenantDb, TenantDbMode } from "./tenant-db";
19
25
 
20
26
  // The two read verbs (list/detail) of the event-store-executor. Split out
@@ -204,16 +210,51 @@ function applyScreenFilter(
204
210
  }
205
211
  }
206
212
 
213
+ // A `filter`/`filters` entry that already pins the parentRef's entityType
214
+ // field is a real subset of the parent-ref clause's own OR-branches — the
215
+ // client-supplied filter can't widen the result, only narrow which branches
216
+ // the SQL even needs to consider. Skipped for multiSelect fields, where `eq`
217
+ // means containment (see applyMultiSelectFilter) rather than equality.
218
+ function deriveParentNarrowTypes(
219
+ entity: EntityDefinition,
220
+ payload: { readonly filter?: ListFilter; readonly filters?: readonly ListFilter[] },
221
+ ): ReadonlySet<string> | undefined {
222
+ const parentRef = entity.parentRef;
223
+ if (parentRef === undefined) return undefined;
224
+ if (entity.fields[parentRef.entityTypeField]?.type === "multiSelect") return undefined;
225
+
226
+ const typeFilters = [
227
+ ...(payload.filter ? [payload.filter] : []),
228
+ ...(payload.filters ?? []),
229
+ ].filter((f) => f.field === parentRef.entityTypeField);
230
+
231
+ let narrowed: Set<string> | undefined;
232
+ for (const f of typeFilters) {
233
+ let values: readonly string[] | undefined;
234
+ if (f.op === "eq" && typeof f.value === "string") {
235
+ values = [f.value];
236
+ } else if (
237
+ f.op === "in" &&
238
+ Array.isArray(f.value) &&
239
+ f.value.every((v) => typeof v === "string")
240
+ ) {
241
+ values = f.value;
242
+ }
243
+ if (values === undefined) continue;
244
+ narrowed =
245
+ narrowed === undefined
246
+ ? new Set(values)
247
+ : new Set([...narrowed].filter((v) => values.includes(v)));
248
+ }
249
+ return narrowed;
250
+ }
251
+
207
252
  // fw#2660 — above this many target-row matches, drop the reference clause
208
253
  // entirely instead of truncating: an arbitrary 200-row slice of "which
209
254
  // targets match" would silently hide rows a full-cardinality match would
210
255
  // have found. Native text search on the entity's own fields still applies.
211
256
  const MAX_REFERENCE_SEARCH_IDS = 200;
212
257
 
213
- function physicalColumnName(table: Table, field: string): string {
214
- return (table[field] as { name?: string } | undefined)?.name ?? toSnakeCase(field);
215
- }
216
-
217
258
  type ReferenceSearchDescriptor = {
218
259
  readonly ownColumn: string;
219
260
  readonly targetEntityName: string;
@@ -384,11 +425,27 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
384
425
  async list(payload, user, db, runtimeOptions) {
385
426
  const { limit, offset } = resolveListPagination(payload);
386
427
  const totalCount = payload.totalCount === true;
428
+ const tableName = String((table as unknown as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL]);
387
429
 
388
- // H.2 — entity-level read ownership. Decide before touching search or
389
- // the DB: `empty` means there's no row the user could ever see, so
390
- // skip both paths and return an empty page.
391
- const ownership = buildOwnershipClause(user, entity.access?.read, table);
430
+ // H.2 — entity-level read ownership, AND-ed with the parent-ref
431
+ // read-gate (join-row entities derive their visibility from a host
432
+ // row's own read path — see db/parent-ref-clause.ts). Decide before
433
+ // touching search or the DB: `empty` means there's no row the user
434
+ // could ever see, so skip both paths and return an empty page.
435
+ const narrowTypes = deriveParentNarrowTypes(entity, payload);
436
+ const parentClause = buildParentRefClause(
437
+ entity,
438
+ table,
439
+ tableName,
440
+ user,
441
+ db,
442
+ runtimeOptions?.parentVisibility,
443
+ { includeDeleted: runtimeOptions?.includeDeleted === true, narrowTypes },
444
+ );
445
+ const ownership = combineClauses(
446
+ buildOwnershipClause(user, entity.access?.read, table),
447
+ parentClause,
448
+ );
392
449
  if (ownership.kind === "empty") {
393
450
  return { rows: [], nextCursor: null, ...(totalCount && { total: 0 }) };
394
451
  }
@@ -401,7 +458,6 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
401
458
  // Build the WHERE clause as raw SQL — ownership produces a
402
459
  // parameterised fragment that we splice in alongside simple WhereObject
403
460
  // conditions (cursor, search-filter-IDs, screen-filter, tenant-scope).
404
- const tableName = String((table as unknown as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL]);
405
461
  const whereSql: string[] = [];
406
462
  const params: unknown[] = [];
407
463
  const physicalCol = (field: string): string => physicalColumnName(table, field);
@@ -493,6 +549,10 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
493
549
  // tenant filter that follows.
494
550
  whereSql.push(`(${orParts.join(" OR ")})`);
495
551
  }
552
+ // The total-count fast-path below is only valid while this is still
553
+ // true after every later clause (tenant, soft-delete, cursor,
554
+ // ownership, parent-ref, filters) — see where it's checked.
555
+ const whereSqlLenAfterSearch = whereSql.length;
496
556
 
497
557
  const sortCandidate =
498
558
  payload.sort && table[payload.sort] && fieldReadClause(payload.sort).kind === "pass"
@@ -664,11 +724,13 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
664
724
  // Postgres cost is O(table-scan) without a filter, with a filter as
665
725
  // expensive as the corresponding WHERE — cheap enough on indexed columns.
666
726
  // On the search path, `total = filterIds.length` needs no extra query —
667
- // unless a reference match contributed additional rows (fw#2660), in
668
- // which case the raw filterIds length alone would undercount.
727
+ // unless a reference match contributed additional rows (fw#2660), or the
728
+ // WHERE grew past the search block (ownership, parent-ref, filters,
729
+ // soft-delete, cursor all splice in afterwards), in which case the raw
730
+ // filterIds length alone would undercount.
669
731
  let total: number | undefined;
670
732
  if (totalCount) {
671
- if (filterIds && !referenceClauseActive) {
733
+ if (filterIds && !referenceClauseActive && whereSql.length === whereSqlLenAfterSearch) {
672
734
  total = filterIds.length;
673
735
  } else {
674
736
  const countSql = `SELECT COUNT(*)::int AS count FROM "${tableName}"${whereClauseSqlText}`;
@@ -684,11 +746,25 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
684
746
  return { rows, nextCursor, ...(total !== undefined && { total }) };
685
747
  },
686
748
 
687
- async detail(payload, user, db) {
688
- // H.2 — ownership check. `empty` → the user can never see this row
749
+ async detail(payload, user, db, options) {
750
+ const tableName = String((table as unknown as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL]);
751
+ // H.2 — ownership check, AND-ed with the parent-ref read-gate (see
752
+ // db/parent-ref-clause.ts). `empty` → the user can never see this row
689
753
  // regardless of its id. Return null (same shape as "not found", so a
690
754
  // probing attacker can't distinguish "no access" from "doesn't exist").
691
- const ownership = buildOwnershipClause(user, entity.access?.read, table);
755
+ const parentClause = buildParentRefClause(
756
+ entity,
757
+ table,
758
+ tableName,
759
+ user,
760
+ db,
761
+ options?.parentVisibility,
762
+ { includeDeleted: false, narrowTypes: undefined },
763
+ );
764
+ const ownership = combineClauses(
765
+ buildOwnershipClause(user, entity.access?.read, table),
766
+ parentClause,
767
+ );
692
768
  if (ownership.kind === "empty") return null;
693
769
 
694
770
  const idWhere = idFilter(payload.id);
@@ -714,8 +790,10 @@ export function createReadVerbs(ctx: ExecutorContext): Pick<EventStoreExecutor,
714
790
  const cached = await entityCache.get(user.tenantId, entityName, payload.id);
715
791
  if (cached) {
716
792
  if (ownership.kind === "sql") {
717
- // Re-check ownership predicate against the live row — the cache
718
- // is keyed only by tenant + id, not by role.
793
+ // Re-check ownership predicate (incl. the parent-ref gate) against
794
+ // the live row — the cache is keyed only by tenant + id, not by
795
+ // role, so a cache hit can't bypass either gate. Deliberate price:
796
+ // one extra roundtrip per cache hit once either gate produces SQL.
719
797
  const checkRows = await loadWithOwnership(db, idWhere, ownership);
720
798
  if (checkRows.length === 0) return null;
721
799
  }
@@ -0,0 +1,182 @@
1
+ import type { ParentVisibilityOption } from "@cosmicdrift/kumiko-types/event-store-executor-types";
2
+ import { KUMIKO_NAME_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
3
+ import { buildOwnershipClause, type OwnershipClause } from "../engine/ownership";
4
+ import type { EntityDefinition, SessionUser } from "../engine/types";
5
+ import { SYSTEM_TENANT_ID, UUID_SHAPE_PATTERN } from "../engine/types/identifiers";
6
+ import type { Table } from "./event-store-executor-context";
7
+ import { buildEntityTable, physicalColumnName } from "./table-builder";
8
+ import type { TenantDb } from "./tenant-db";
9
+
10
+ export type { ParentVisibilityOption } from "@cosmicdrift/kumiko-types/event-store-executor-types";
11
+
12
+ // Static, per-candidate SQL fragments (table name + physical column names) —
13
+ // derived from the entity definition alone, so they're the same on every
14
+ // request. buildEntityTable is too expensive to redo per request per
15
+ // candidate, hence the cache keyed by the entity object itself.
16
+ type CandidateStatics = {
17
+ readonly table: Table;
18
+ readonly tableName: string;
19
+ readonly tenantIdCol: string | undefined;
20
+ readonly isDeletedCol: string | undefined;
21
+ };
22
+
23
+ const candidateStaticsCache = new WeakMap<EntityDefinition, CandidateStatics>();
24
+
25
+ function candidateStaticsFor(candidateName: string, candidate: EntityDefinition): CandidateStatics {
26
+ const cached = candidateStaticsCache.get(candidate);
27
+ if (cached) return cached;
28
+ const table = buildEntityTable(candidateName, candidate);
29
+ const tableName = String((table as unknown as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL]);
30
+ const statics: CandidateStatics = {
31
+ table,
32
+ tableName,
33
+ tenantIdCol:
34
+ table["tenantId"] !== undefined ? physicalColumnName(table, "tenantId") : undefined,
35
+ isDeletedCol:
36
+ table["isDeleted"] !== undefined ? physicalColumnName(table, "isDeleted") : undefined,
37
+ };
38
+ candidateStaticsCache.set(candidate, statics);
39
+ return statics;
40
+ }
41
+
42
+ // Candidate order follows `option.entities` (Registry insertion order) —
43
+ // deterministic given a fixed feature-registration order.
44
+ function selectCandidates(
45
+ parentRef: NonNullable<EntityDefinition["parentRef"]>,
46
+ option: ParentVisibilityOption,
47
+ narrowTypes: ReadonlySet<string> | undefined,
48
+ ): ReadonlyArray<readonly [string, EntityDefinition]> {
49
+ const candidates: Array<readonly [string, EntityDefinition]> = [];
50
+ for (const [name, candidate] of option.entities) {
51
+ if (parentRef.allowedTypes && !parentRef.allowedTypes.includes(name)) continue;
52
+ if (narrowTypes && !narrowTypes.has(name)) continue;
53
+ // No recursive gating — a host that is itself a join-row entity would
54
+ // need its own visibility resolved first, which this function doesn't do.
55
+ if (candidate.parentRef !== undefined) continue;
56
+ // event-store-executor-context.ts rejects non-uuid idType at executor
57
+ // build time, so there is no read path to check such a candidate against.
58
+ if (candidate.idType !== undefined && candidate.idType !== "uuid") continue;
59
+ candidates.push([name, candidate]);
60
+ }
61
+ return candidates;
62
+ }
63
+
64
+ export function buildParentRefClause(
65
+ entity: EntityDefinition,
66
+ table: Table,
67
+ tableName: string,
68
+ user: SessionUser,
69
+ db: TenantDb,
70
+ option: ParentVisibilityOption | undefined,
71
+ opts: {
72
+ readonly includeDeleted?: boolean;
73
+ readonly narrowTypes?: ReadonlySet<string>;
74
+ },
75
+ ): OwnershipClause {
76
+ const parentRef = entity.parentRef;
77
+ if (parentRef === undefined) return { kind: "pass" };
78
+ // The raw executor stays ungated when no registry is available — the
79
+ // fail-closed guarantee for client-reachable paths comes from the boot
80
+ // guard (validateParentRefs), not from throwing here. Throwing would 500
81
+ // every existing custom handler and framework-internal detail() call that
82
+ // doesn't pass parentVisibility.
83
+ if (option === undefined) return { kind: "pass" };
84
+
85
+ if (
86
+ entity.fields[parentRef.entityTypeField] === undefined ||
87
+ entity.fields[parentRef.entityIdField] === undefined
88
+ ) {
89
+ // Misconfigured parentRef — fail closed here; the boot guard catches
90
+ // this earlier for any entity the registry actually validates.
91
+ return { kind: "empty" };
92
+ }
93
+ const typeCol = physicalColumnName(table, parentRef.entityTypeField);
94
+ const idCol = physicalColumnName(table, parentRef.entityIdField);
95
+
96
+ const candidates = selectCandidates(parentRef, option, opts.narrowTypes);
97
+ if (candidates.length === 0) return { kind: "empty" };
98
+
99
+ // The uuid shape and the tenant ids are bound once and shared by every
100
+ // branch. The shape is a parameter rather than a literal because shiftParams
101
+ // rewrites `$<digits>` anywhere in the text, including inside a string.
102
+ const params: unknown[] = [UUID_SHAPE_PATTERN];
103
+ const uuidIdx = params.length;
104
+ let tenantIdx: readonly [number, number] | undefined;
105
+ if (db.mode === "tenant") {
106
+ params.push(db.tenantId, SYSTEM_TENANT_ID);
107
+ tenantIdx = [params.length - 1, params.length];
108
+ }
109
+
110
+ const branches: string[] = [];
111
+ for (const [candidateName, candidate] of candidates) {
112
+ const branch = buildCandidateBranch(candidateName, candidate, user, {
113
+ joinTableName: tableName,
114
+ typeCol,
115
+ idCol,
116
+ uuidIdx,
117
+ tenantIdx,
118
+ includeDeleted: opts.includeDeleted === true,
119
+ // Ownership params are laid out right after the branch's own host-type
120
+ // placeholder. Nothing reaches the shared `params` until the branch is
121
+ // kept, so a dropped branch reserves no index and can't shift the
122
+ // positional binding of the branches after it.
123
+ typeParamIdx: params.length + 1,
124
+ });
125
+ if (branch === undefined) continue;
126
+ params.push(candidateName, ...branch.params);
127
+ branches.push(branch.sqlText);
128
+ }
129
+
130
+ if (branches.length === 0) return { kind: "empty" };
131
+ return { kind: "sql", sqlText: `(${branches.join(" OR ")})`, params };
132
+ }
133
+
134
+ type BranchContext = {
135
+ readonly joinTableName: string;
136
+ readonly typeCol: string;
137
+ readonly idCol: string;
138
+ readonly uuidIdx: number;
139
+ readonly tenantIdx: readonly [number, number] | undefined;
140
+ readonly includeDeleted: boolean;
141
+ readonly typeParamIdx: number;
142
+ };
143
+
144
+ // One `entity_type = $n AND EXISTS (...)` arm, or undefined when the host's own
145
+ // read ownership can never match — an unsatisfiable arm is dropped rather than
146
+ // emitted, which is what keeps an unreadable host from widening the OR-chain.
147
+ function buildCandidateBranch(
148
+ candidateName: string,
149
+ candidate: EntityDefinition,
150
+ user: SessionUser,
151
+ ctx: BranchContext,
152
+ ): { readonly sqlText: string; readonly params: readonly unknown[] } | undefined {
153
+ const statics = candidateStaticsFor(candidateName, candidate);
154
+ const ownership = buildOwnershipClause(
155
+ user,
156
+ candidate.access?.read,
157
+ statics.table,
158
+ ctx.typeParamIdx + 1,
159
+ );
160
+ if (ownership.kind === "empty") return undefined;
161
+
162
+ const hostId = `"${ctx.joinTableName}"."${ctx.idCol}"`;
163
+ // CASE, not a bare cast: an id shaped for another host entity would abort the
164
+ // whole statement with 22P02, and AND does not guarantee evaluation order.
165
+ const conditions: string[] = [
166
+ `"${statics.tableName}"."id" = (CASE WHEN ${hostId} ~ $${ctx.uuidIdx} THEN ${hostId} END)::uuid`,
167
+ ];
168
+ if (statics.tenantIdCol !== undefined && ctx.tenantIdx !== undefined) {
169
+ conditions.push(
170
+ `"${statics.tableName}"."${statics.tenantIdCol}" IN ($${ctx.tenantIdx[0]}, $${ctx.tenantIdx[1]})`,
171
+ );
172
+ }
173
+ if (candidate.softDelete === true && statics.isDeletedCol !== undefined && !ctx.includeDeleted) {
174
+ conditions.push(`"${statics.tableName}"."${statics.isDeletedCol}" = FALSE`);
175
+ }
176
+ if (ownership.kind === "sql") conditions.push(ownership.sqlText);
177
+
178
+ return {
179
+ sqlText: `("${ctx.joinTableName}"."${ctx.typeCol}" = $${ctx.typeParamIdx} AND EXISTS (SELECT 1 FROM "${statics.tableName}" WHERE ${conditions.join(" AND ")}))`,
180
+ params: ownership.kind === "sql" ? ownership.params : [],
181
+ };
182
+ }
@@ -7,6 +7,7 @@ import type {
7
7
  } from "../engine/types";
8
8
  import { assertUnreachable } from "../utils";
9
9
  import { toSnakeCase } from "../utils/case";
10
+ import type { Table } from "./event-store-executor-context";
10
11
 
11
12
  export { toSnakeCase } from "../utils/case";
12
13
 
@@ -35,6 +36,12 @@ import {
35
36
  uuid,
36
37
  } from "./dialect";
37
38
 
39
+ // Shared by the CRUD executor and the parent-ref read-gate — a field's
40
+ // declared column name if it has one, else its snake_case default.
41
+ export function physicalColumnName(table: Table, field: string): string {
42
+ return (table[field] as { name?: string } | undefined)?.name ?? toSnakeCase(field);
43
+ }
44
+
38
45
  // Local AnyPgColumn alias — kept for legacy field-definition callers that
39
46
  // still import this name as a type. ColumnHandle from the native dialect
40
47
  // matches the same role (snake_case name + sql type accessor).