@cosmicdrift/kumiko-framework 0.257.0 → 0.258.1

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.
Files changed (138) hide show
  1. package/package.json +3 -3
  2. package/src/__tests__/anonymous-access.integration.test.ts +3 -3
  3. package/src/__tests__/derived-fields.integration.test.ts +1 -1
  4. package/src/__tests__/entity-list-limits.integration.test.ts +1 -1
  5. package/src/__tests__/error-contract.integration.test.ts +1 -1
  6. package/src/__tests__/ownership-where-write-path.integration.test.ts +1 -1
  7. package/src/__tests__/ownership.integration.test.ts +7 -3
  8. package/src/__tests__/schema-cli-rebuild.integration.test.ts +2 -2
  9. package/src/__tests__/transition-guard.integration.test.ts +3 -3
  10. package/src/api/__tests__/api.test.ts +21 -3
  11. package/src/api/__tests__/batch.integration.test.ts +3 -3
  12. package/src/api/__tests__/body-limit.test.ts +7 -1
  13. package/src/api/__tests__/dispatcher-live.integration.test.ts +1 -1
  14. package/src/api/__tests__/http-route-rate-limit.integration.test.ts +7 -1
  15. package/src/api/__tests__/metrics-endpoint.test.ts +7 -1
  16. package/src/api/__tests__/nested-write.integration.test.ts +3 -3
  17. package/src/api/__tests__/server-boot-guards.test.ts +11 -4
  18. package/src/bun-db/__tests__/write-brand.test.ts +4 -1
  19. package/src/db/__tests__/apply-entity-event-tenant.integration.test.ts +1 -1
  20. package/src/db/__tests__/assert-exists-in.integration.test.ts +1 -1
  21. package/src/db/__tests__/assert-no-unreachable-live-rows.integration.test.ts +2 -2
  22. package/src/db/__tests__/collect-table-metas.test.ts +9 -5
  23. package/src/db/__tests__/compound-types.test.ts +1 -1
  24. package/src/db/__tests__/config-seed.integration.test.ts +3 -3
  25. package/src/db/__tests__/eagerload.integration.test.ts +7 -7
  26. package/src/db/__tests__/entity-table-from-registry.test.ts +8 -2
  27. package/src/db/__tests__/entity-table-meta-source.test.ts +7 -3
  28. package/src/db/__tests__/entity-table-types.test.ts +4 -4
  29. package/src/db/__tests__/event-store-executor-list.integration.test.ts +9 -2
  30. package/src/db/__tests__/event-store-executor-reference-search.integration.test.ts +4 -4
  31. package/src/db/__tests__/event-store-executor-reference-sort.integration.test.ts +3 -3
  32. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +4 -4
  33. package/src/db/__tests__/event-store-executor.integration.test.ts +11 -6
  34. package/src/db/__tests__/event-store-system-stream.integration.test.ts +2 -2
  35. package/src/db/__tests__/implicit-projection-equivalence.integration.test.ts +6 -4
  36. package/src/db/__tests__/located-timestamp.test.ts +1 -1
  37. package/src/db/__tests__/money.test.ts +1 -1
  38. package/src/db/__tests__/multi-row-insert.integration.test.ts +3 -3
  39. package/src/db/__tests__/parent-ref-clause.test.ts +213 -0
  40. package/src/db/__tests__/schema-migration.integration.test.ts +30 -14
  41. package/src/db/__tests__/source-shadow-create.integration.test.ts +1 -1
  42. package/src/db/__tests__/table-builder-indexes.test.ts +14 -12
  43. package/src/db/__tests__/table-builder-required.test.ts +2 -2
  44. package/src/db/__tests__/tenant-db-where-merge.integration.test.ts +1 -1
  45. package/src/db/__tests__/tenant-db-where-merge.test.ts +5 -2
  46. package/src/db/__tests__/tenant-db.integration.test.ts +2 -2
  47. package/src/db/__tests__/unique-violation-mapping.integration.test.ts +2 -2
  48. package/src/db/event-store-executor-read.ts +97 -19
  49. package/src/db/parent-ref-clause.ts +182 -0
  50. package/src/db/table-builder.ts +7 -0
  51. package/src/engine/__tests__/boot-validator-action-wiring.test.ts +50 -8
  52. package/src/engine/__tests__/boot-validator-detail-for.test.ts +28 -4
  53. package/src/engine/__tests__/boot-validator-entity-list.test.ts +20 -4
  54. package/src/engine/__tests__/boot-validator-i18n-keys.test.ts +9 -3
  55. package/src/engine/__tests__/boot-validator.test.ts +716 -91
  56. package/src/engine/__tests__/define-feature-entity-mapping.test.ts +2 -2
  57. package/src/engine/__tests__/engine.test.ts +128 -37
  58. package/src/engine/__tests__/entity-handlers.test.ts +3 -3
  59. package/src/engine/__tests__/entity-presave-wiring.integration.test.ts +4 -4
  60. package/src/engine/__tests__/entity-restore-tenant-isolation.integration.test.ts +1 -1
  61. package/src/engine/__tests__/entity-write-crosstenant.integration.test.ts +1 -1
  62. package/src/engine/__tests__/extend-entity-projection.test.ts +1 -1
  63. package/src/engine/__tests__/extends-registrar.test.ts +13 -3
  64. package/src/engine/__tests__/factories-long-text.test.ts +10 -6
  65. package/src/engine/__tests__/feature-crud-shorthand.test.ts +1 -1
  66. package/src/engine/__tests__/feature-crud-verb-access.integration.test.ts +1 -1
  67. package/src/engine/__tests__/multiselect-filter.integration.test.ts +1 -1
  68. package/src/engine/__tests__/nav.test.ts +1 -1
  69. package/src/engine/__tests__/pipeline-handler.integration.test.ts +1 -1
  70. package/src/engine/__tests__/pipeline-observability.integration.test.ts +1 -1
  71. package/src/engine/__tests__/pipeline-performance.integration.test.ts +1 -1
  72. package/src/engine/__tests__/projection-detail-actions.test.ts +6 -1
  73. package/src/engine/__tests__/projection-detail-singleton.test.ts +6 -1
  74. package/src/engine/__tests__/projection-detail-tabs.test.ts +6 -1
  75. package/src/engine/__tests__/projection-list-filter.integration.test.ts +7 -2
  76. package/src/engine/__tests__/projection.test.ts +1 -1
  77. package/src/engine/__tests__/registry.test.ts +13 -3
  78. package/src/engine/__tests__/schema-builder.test.ts +39 -11
  79. package/src/engine/__tests__/screen.test.ts +21 -15
  80. package/src/engine/__tests__/search-payload-extension.test.ts +15 -3
  81. package/src/engine/__tests__/store-table.test.ts +6 -3
  82. package/src/engine/__tests__/validate-projection-allowlist.test.ts +12 -4
  83. package/src/engine/__tests__/validation-hooks.test.ts +7 -1
  84. package/src/engine/boot-validator/__tests__/ownership.test.ts +2 -2
  85. package/src/engine/boot-validator/__tests__/parent-ref.test.ts +149 -0
  86. package/src/engine/boot-validator/action-wiring.ts +7 -7
  87. package/src/engine/boot-validator/index.ts +2 -0
  88. package/src/engine/boot-validator/parent-ref.ts +96 -0
  89. package/src/engine/boot-validator/projection-list-screens.ts +36 -23
  90. package/src/engine/boot-validator/query-output-columns.ts +2 -1
  91. package/src/engine/boot-validator/query-refs.ts +2 -1
  92. package/src/engine/boot-validator/screens.ts +322 -131
  93. package/src/engine/build-config-feature-schema.ts +2 -2
  94. package/src/engine/define-handler.ts +6 -0
  95. package/src/engine/entity-handlers.ts +6 -1
  96. package/src/engine/factories.ts +2 -0
  97. package/src/engine/feature-entity-handlers.ts +5 -0
  98. package/src/engine/index.ts +6 -0
  99. package/src/engine/ownership.ts +15 -0
  100. package/src/engine/pattern-library/mixed-schemas.ts +1 -0
  101. package/src/engine/screen-helpers.ts +25 -0
  102. package/src/engine/types/index.ts +5 -0
  103. package/src/es-ops/__tests__/runner.integration.test.ts +1 -1
  104. package/src/event-store/__tests__/upcaster.integration.test.ts +1 -1
  105. package/src/files/__tests__/file-field-pipeline.integration.test.ts +1 -1
  106. package/src/files/__tests__/files.integration.test.ts +1 -1
  107. package/src/files/file-ref-entity.ts +9 -9
  108. package/src/i18n/__tests__/i18n.test.ts +7 -1
  109. package/src/i18n/required-surface-keys.ts +48 -0
  110. package/src/migrations/__tests__/kumiko-drift.integration.test.ts +3 -1
  111. package/src/migrations/__tests__/pending-rebuilds.integration.test.ts +2 -2
  112. package/src/pipeline/__tests__/archive-stream.integration.test.ts +1 -1
  113. package/src/pipeline/__tests__/cascade-handler.integration.test.ts +23 -8
  114. package/src/pipeline/__tests__/cascade-handler.test.ts +18 -3
  115. package/src/pipeline/__tests__/causation-chain.integration.test.ts +1 -1
  116. package/src/pipeline/__tests__/ctx-bridge.integration.test.ts +5 -3
  117. package/src/pipeline/__tests__/dispatcher.test.ts +42 -6
  118. package/src/pipeline/__tests__/domain-events-projections.integration.test.ts +2 -2
  119. package/src/pipeline/__tests__/fetch-for-writing.integration.test.ts +1 -1
  120. package/src/pipeline/__tests__/lifecycle-pipeline.test.ts +42 -6
  121. package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +2 -2
  122. package/src/pipeline/__tests__/msp-multi-hop.integration.test.ts +1 -1
  123. package/src/pipeline/__tests__/msp-rebuild.integration.test.ts +6 -2
  124. package/src/pipeline/__tests__/multi-stream-projection.integration.test.ts +6 -2
  125. package/src/pipeline/__tests__/perf-rebuild.integration.test.ts +1 -1
  126. package/src/pipeline/__tests__/post-query-hook.integration.test.ts +3 -3
  127. package/src/pipeline/__tests__/projection-rebuild.integration.test.ts +3 -3
  128. package/src/pipeline/__tests__/query-projection.integration.test.ts +1 -1
  129. package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +2 -2
  130. package/src/pipeline/__tests__/search-consumer-named-events.integration.test.ts +13 -2
  131. package/src/pipeline/__tests__/try-append-event.integration.test.ts +1 -1
  132. package/src/search/__tests__/reindex-entity.integration.test.ts +6 -1
  133. package/src/search/__tests__/search-pii-derived-index.integration.test.ts +7 -1
  134. package/src/stack/__tests__/ownership-boot-guard.integration.test.ts +1 -1
  135. package/src/testing/__tests__/e2e-generator.test.ts +15 -5
  136. package/src/testing/e2e-generator.ts +2 -0
  137. package/src/testing/shared-entities.ts +4 -2
  138. package/src/ui-types/index.ts +4 -0
@@ -40,8 +40,8 @@ describe("buildEntityTable — entity.indexes", () => {
40
40
  test("composite unique-index landet als unique=true in Drizzle table-config", () => {
41
41
  const entity = createEntity({
42
42
  fields: {
43
- key: createTextField({ required: true }),
44
- userId: createTextField({}),
43
+ key: createTextField({ required: true, personal: false, reason: "test_fixture" }),
44
+ userId: createTextField({ personal: false, reason: "test_fixture" }),
45
45
  },
46
46
  indexes: [{ unique: true, columns: ["key", "tenantId", "userId"] }],
47
47
  });
@@ -62,8 +62,8 @@ describe("buildEntityTable — entity.indexes", () => {
62
62
  test("composite non-unique-index landet als unique=false in Drizzle table-config", () => {
63
63
  const entity = createEntity({
64
64
  fields: {
65
- startedAt: createTextField({}),
66
- endedAt: createTextField({}),
65
+ startedAt: createTextField({ personal: false, reason: "test_fixture" }),
66
+ endedAt: createTextField({ personal: false, reason: "test_fixture" }),
67
67
  },
68
68
  indexes: [{ columns: ["startedAt", "endedAt"] }],
69
69
  });
@@ -78,7 +78,9 @@ describe("buildEntityTable — entity.indexes", () => {
78
78
 
79
79
  test("custom name override wird respektiert", () => {
80
80
  const entity = createEntity({
81
- fields: { slug: createTextField({ required: true }) },
81
+ fields: {
82
+ slug: createTextField({ required: true, personal: false, reason: "test_fixture" }),
83
+ },
82
84
  indexes: [{ unique: true, columns: ["slug"], name: "my_custom_idx" }],
83
85
  });
84
86
  const tbl = buildEntityTable("page", entity);
@@ -91,7 +93,7 @@ describe("buildEntityTable — entity.indexes", () => {
91
93
  test("ohne indexes — keine zusätzlichen Indices, kein Error", () => {
92
94
  const entity = createEntity({
93
95
  fields: {
94
- title: createTextField({}),
96
+ title: createTextField({ personal: false, reason: "test_fixture" }),
95
97
  },
96
98
  });
97
99
  expect(() => buildEntityTable("widget", entity)).not.toThrow();
@@ -120,7 +122,7 @@ describe("validateBoot — entity.indexes", () => {
120
122
  test("Tippfehler im column-Namen wirft", () => {
121
123
  const feature = defineFeature("widgetFeature", (r) => {
122
124
  r.entity("widget", {
123
- fields: { title: createTextField({}) },
125
+ fields: { title: createTextField({ personal: false, reason: "test_fixture" }) },
124
126
  indexes: [{ columns: ["titel"] }], // typo
125
127
  });
126
128
  });
@@ -130,7 +132,7 @@ describe("validateBoot — entity.indexes", () => {
130
132
  test("leere column-Liste wirft", () => {
131
133
  const feature = defineFeature("widgetFeature", (r) => {
132
134
  r.entity("widget", {
133
- fields: { title: createTextField({}) },
135
+ fields: { title: createTextField({ personal: false, reason: "test_fixture" }) },
134
136
  indexes: [{ columns: [] as never }],
135
137
  });
136
138
  });
@@ -140,7 +142,7 @@ describe("validateBoot — entity.indexes", () => {
140
142
  test("single-column index nur auf tenantId ist redundant", () => {
141
143
  const feature = defineFeature("widgetFeature", (r) => {
142
144
  r.entity("widget", {
143
- fields: { title: createTextField({}) },
145
+ fields: { title: createTextField({ personal: false, reason: "test_fixture" }) },
144
146
  indexes: [{ columns: ["tenantId"] }],
145
147
  });
146
148
  });
@@ -155,7 +157,7 @@ describe("validateBoot — entity.indexes", () => {
155
157
  // sonst fliegt der Branch beim nächsten Revert/Refactor stumm raus.
156
158
  const feature = defineFeature("widgetFeature", (r) => {
157
159
  r.entity("widget", {
158
- fields: { title: createTextField({}) },
160
+ fields: { title: createTextField({ personal: false, reason: "test_fixture" }) },
159
161
  indexes: [{ unique: true, columns: ["tenantId"] }],
160
162
  });
161
163
  });
@@ -166,7 +168,7 @@ describe("validateBoot — entity.indexes", () => {
166
168
  const feature = defineFeature("widgetFeature", (r) => {
167
169
  r.entity("widget", {
168
170
  fields: {
169
- key: createTextField({ required: true }),
171
+ key: createTextField({ required: true, personal: false, reason: "test_fixture" }),
170
172
  archived: createBooleanField({}),
171
173
  },
172
174
  indexes: [{ unique: true, columns: ["key", "tenantId"] }],
@@ -178,7 +180,7 @@ describe("validateBoot — entity.indexes", () => {
178
180
  test("base columns (id, tenantId, version) sind erlaubt", () => {
179
181
  const feature = defineFeature("widgetFeature", (r) => {
180
182
  r.entity("widget", {
181
- fields: { key: createTextField({}) },
183
+ fields: { key: createTextField({ personal: false, reason: "test_fixture" }) },
182
184
  indexes: [{ columns: ["tenantId", "version"] }],
183
185
  });
184
186
  });
@@ -44,8 +44,8 @@ describe("buildEntityTable — required: true → NOT NULL", () => {
44
44
  test("text field — required true makes column NOT NULL", () => {
45
45
  const entity = createEntity({
46
46
  fields: {
47
- title: createTextField({ required: true }),
48
- subtitle: createTextField({}),
47
+ title: createTextField({ required: true, personal: false, reason: "test_fixture" }),
48
+ subtitle: createTextField({ personal: false, reason: "test_fixture" }),
49
49
  },
50
50
  });
51
51
  const tbl = buildEntityTable("widget", entity);
@@ -25,7 +25,7 @@ import { buildEntityTable } from "../table-builder";
25
25
 
26
26
  const noteEntity = createEntity({
27
27
  fields: {
28
- title: createTextField({ required: true }),
28
+ title: createTextField({ required: true, personal: false, reason: "test_fixture" }),
29
29
  },
30
30
  table: "where_merge_notes",
31
31
  });
@@ -14,7 +14,7 @@ import { createTenantDb } from "../tenant-db";
14
14
 
15
15
  const entity = createEntity({
16
16
  table: "merge_items",
17
- fields: { name: createTextField({ required: true }) },
17
+ fields: { name: createTextField({ required: true, personal: false, reason: "test_fixture" }) },
18
18
  });
19
19
  // Brand (#742) is compile-time-only; hold the handle at the unbranded TableColumns
20
20
  // view so the method-form scoping test still compiles (runtime shape is identical).
@@ -130,7 +130,10 @@ describe("tenant-db WHERE merge — narrowing within the enforced scope", () =>
130
130
  describe("tenant-db WHERE merge — unmanaged EntityTableMeta tables (deriveEntityTableMeta)", () => {
131
131
  const unmanagedEntity = createEntity({
132
132
  table: "merge_meta_items",
133
- fields: { tenantId: createTextField({ required: true }), name: createTextField() },
133
+ fields: {
134
+ tenantId: createTextField({ required: true, personal: false, reason: "test_fixture" }),
135
+ name: createTextField({ personal: false, reason: "test_fixture" }),
136
+ },
134
137
  });
135
138
  const unmanagedTable = deriveEntityTableMeta("merge-meta-item", unmanagedEntity);
136
139
 
@@ -19,8 +19,8 @@ import { createTenantDb } from "../tenant-db";
19
19
  const entity = createEntity({
20
20
  table: "tenant_db_items",
21
21
  fields: {
22
- name: createTextField({ required: true }),
23
- status: createTextField({ default: "draft" }),
22
+ name: createTextField({ required: true, personal: false, reason: "test_fixture" }),
23
+ status: createTextField({ default: "draft", personal: false, reason: "test_fixture" }),
24
24
  isActive: createBooleanField({ default: true }),
25
25
  },
26
26
  softDelete: true,
@@ -27,8 +27,8 @@ import { createTenantDb, type TenantDb } from "../tenant-db";
27
27
  const userEntity = createEntity({
28
28
  table: "read_unique_users",
29
29
  fields: {
30
- email: createTextField({ required: true }),
31
- displayName: createTextField({ required: true }),
30
+ email: createTextField({ required: true, personal: false, reason: "test_fixture" }),
31
+ displayName: createTextField({ required: true, personal: false, reason: "test_fixture" }),
32
32
  },
33
33
  // softDelete=true damit wir den restore-Pfad pinnen können (siehe
34
34
  // restore-Test unten — "kein 23505 möglich" claim).
@@ -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).
@@ -7,7 +7,12 @@ import { createEntity, createTextField } from "../factories";
7
7
  describe("validateBoot — action wiring (no function values)", () => {
8
8
  test("rowAction writeHandler payload as function → Throw", () => {
9
9
  const feature = defineFeature("shop", (r) => {
10
- r.entity("product", createEntity({ fields: { name: createTextField() } }));
10
+ r.entity(
11
+ "product",
12
+ createEntity({
13
+ fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
14
+ }),
15
+ );
11
16
  r.screen({
12
17
  id: "product-list",
13
18
  type: "entityList",
@@ -33,7 +38,14 @@ describe("validateBoot — action wiring (no function values)", () => {
33
38
 
34
39
  test("rowAction writeHandler payload as declarative pick → kein Throw", () => {
35
40
  const feature = defineFeature("shop", (r) => {
36
- r.entity("product", createEntity({ fields: { name: createTextField({ sortable: true }) } }));
41
+ r.entity(
42
+ "product",
43
+ createEntity({
44
+ fields: {
45
+ name: createTextField({ sortable: true, personal: false, reason: "test_fixture" }),
46
+ },
47
+ }),
48
+ );
37
49
  r.screen({
38
50
  id: "product-list",
39
51
  type: "entityList",
@@ -65,7 +77,12 @@ describe("validateBoot — action wiring (no function values)", () => {
65
77
 
66
78
  test("rowAction navigate visible as function → Throw", () => {
67
79
  const feature = defineFeature("shop", (r) => {
68
- r.entity("product", createEntity({ fields: { name: createTextField() } }));
80
+ r.entity(
81
+ "product",
82
+ createEntity({
83
+ fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
84
+ }),
85
+ );
69
86
  r.screen({
70
87
  id: "product-list",
71
88
  type: "entityList",
@@ -88,7 +105,12 @@ describe("validateBoot — action wiring (no function values)", () => {
88
105
 
89
106
  test("toolbarAction writeHandler payload as function → Throw", () => {
90
107
  const feature = defineFeature("shop", (r) => {
91
- r.entity("product", createEntity({ fields: { name: createTextField() } }));
108
+ r.entity(
109
+ "product",
110
+ createEntity({
111
+ fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
112
+ }),
113
+ );
92
114
  r.screen({
93
115
  id: "product-list",
94
116
  type: "entityList",
@@ -136,7 +158,12 @@ describe("validateBoot — action wiring (no function values)", () => {
136
158
 
137
159
  test("entityList column renderer as function → Throw", () => {
138
160
  const feature = defineFeature("shop", (r) => {
139
- r.entity("product", createEntity({ fields: { name: createTextField() } }));
161
+ r.entity(
162
+ "product",
163
+ createEntity({
164
+ fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
165
+ }),
166
+ );
140
167
  r.screen({
141
168
  id: "product-list",
142
169
  type: "entityList",
@@ -152,7 +179,12 @@ describe("validateBoot — action wiring (no function values)", () => {
152
179
 
153
180
  test("entityEdit field visible as function → Throw", () => {
154
181
  const feature = defineFeature("shop", (r) => {
155
- r.entity("product", createEntity({ fields: { name: createTextField() } }));
182
+ r.entity(
183
+ "product",
184
+ createEntity({
185
+ fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
186
+ }),
187
+ );
156
188
  r.screen({
157
189
  id: "product-edit",
158
190
  type: "entityEdit",
@@ -173,7 +205,12 @@ describe("validateBoot — action wiring (no function values)", () => {
173
205
 
174
206
  test("entityEdit field renderer as function → Throw", () => {
175
207
  const feature = defineFeature("shop", (r) => {
176
- r.entity("product", createEntity({ fields: { name: createTextField() } }));
208
+ r.entity(
209
+ "product",
210
+ createEntity({
211
+ fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
212
+ }),
213
+ );
177
214
  r.screen({
178
215
  id: "product-edit",
179
216
  type: "entityEdit",
@@ -194,7 +231,12 @@ describe("validateBoot — action wiring (no function values)", () => {
194
231
 
195
232
  test("entityEdit field with declarative visible/readOnly/required → kein Throw", () => {
196
233
  const feature = defineFeature("shop", (r) => {
197
- r.entity("product", createEntity({ fields: { name: createTextField() } }));
234
+ r.entity(
235
+ "product",
236
+ createEntity({
237
+ fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
238
+ }),
239
+ );
198
240
  r.screen({
199
241
  id: "product-edit",
200
242
  type: "entityEdit",