@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
@@ -23,7 +23,7 @@ import { createTenantDb, type TenantDb } from "../tenant-db";
23
23
  const customerEntity = createEntity({
24
24
  table: "read_ref_search_customers",
25
25
  fields: {
26
- name: createTextField({ required: true }),
26
+ name: createTextField({ required: true, personal: false, reason: "test_fixture" }),
27
27
  },
28
28
  });
29
29
  const customerTable = buildEntityTable("refSearchCustomer", customerEntity);
@@ -31,7 +31,7 @@ const customerTable = buildEntityTable("refSearchCustomer", customerEntity);
31
31
  const orderEntity = createEntity({
32
32
  table: "read_ref_search_orders",
33
33
  fields: {
34
- note: createTextField({ searchable: true }),
34
+ note: createTextField({ searchable: true, personal: false, reason: "test_fixture" }),
35
35
  customerId: {
36
36
  type: "reference",
37
37
  entity: "refSearchCustomer",
@@ -48,7 +48,7 @@ const orderTable = buildEntityTable("refSearchOrder", orderEntity);
48
48
  const restrictedOrderEntity = createEntity({
49
49
  table: "read_ref_search_restricted_orders",
50
50
  fields: {
51
- note: createTextField({ searchable: true }),
51
+ note: createTextField({ searchable: true, personal: false, reason: "test_fixture" }),
52
52
  customerId: {
53
53
  type: "reference",
54
54
  entity: "refSearchCustomer",
@@ -66,7 +66,7 @@ const restrictedOrderTable = buildEntityTable("refSearchRestrictedOrder", restri
66
66
  // (LIST_ROW_META_REFERENCES) is what's under test, not tenant's full schema.
67
67
  const tenantMetaEntity = createEntity({
68
68
  table: "read_tenants",
69
- fields: { name: createTextField({ required: true }) },
69
+ fields: { name: createTextField({ required: true, personal: false, reason: "test_fixture" }) },
70
70
  });
71
71
  const tenantMetaTable = buildEntityTable("tenant", tenantMetaEntity);
72
72
 
@@ -19,14 +19,14 @@ import { createTenantDb, type TenantDb } from "../tenant-db";
19
19
 
20
20
  const customerEntity = createEntity({
21
21
  table: "read_ref_sort_customers",
22
- fields: { name: createTextField({ required: true }) },
22
+ fields: { name: createTextField({ required: true, personal: false, reason: "test_fixture" }) },
23
23
  });
24
24
  const customerTable = buildEntityTable("refSortCustomer", customerEntity);
25
25
 
26
26
  const orderEntity = createEntity({
27
27
  table: "read_ref_sort_orders",
28
28
  fields: {
29
- note: createTextField(),
29
+ note: createTextField({ personal: false, reason: "test_fixture" }),
30
30
  customerId: {
31
31
  type: "reference",
32
32
  entity: "refSortCustomer",
@@ -43,7 +43,7 @@ const orderTable = buildEntityTable("refSortOrder", orderEntity);
43
43
  const restrictedOrderEntity = createEntity({
44
44
  table: "read_ref_sort_restricted_orders",
45
45
  fields: {
46
- note: createTextField(),
46
+ note: createTextField({ personal: false, reason: "test_fixture" }),
47
47
  customerId: {
48
48
  type: "reference",
49
49
  entity: "refSortCustomer",
@@ -44,7 +44,7 @@ afterAll(async () => {
44
44
  const restrictedEntity = createEntity({
45
45
  table: "read_es_write_restricted",
46
46
  fields: {
47
- email: createTextField({ required: true }),
47
+ email: createTextField({ required: true, personal: false, reason: "test_fixture" }),
48
48
  },
49
49
  softDelete: true,
50
50
  access: { write: { Admin: "all" } },
@@ -189,7 +189,7 @@ describe("event-store-executor write-verbs — entity-level ownership_denied", (
189
189
  const hardDeleteEntity = createEntity({
190
190
  table: "read_es_write_hard",
191
191
  fields: {
192
- email: createTextField({ required: true }),
192
+ email: createTextField({ required: true, personal: false, reason: "test_fixture" }),
193
193
  },
194
194
  });
195
195
  const hardDeleteTable = buildEntityTable("esWriteHard", hardDeleteEntity);
@@ -340,7 +340,7 @@ describe("event-store-executor write-verbs — field-level ownership_denied", ()
340
340
  const versionEntity = createEntity({
341
341
  table: "read_es_write_version",
342
342
  fields: {
343
- email: createTextField({ required: true }),
343
+ email: createTextField({ required: true, personal: false, reason: "test_fixture" }),
344
344
  },
345
345
  });
346
346
  const versionTable = buildEntityTable("esWriteVersion", versionEntity);
@@ -394,7 +394,7 @@ describe("event-store-executor write-verbs — version_conflict edge cases", ()
394
394
  const raceEntity = createEntity({
395
395
  table: "read_es_write_race",
396
396
  fields: {
397
- email: createTextField({ required: true }),
397
+ email: createTextField({ required: true, personal: false, reason: "test_fixture" }),
398
398
  },
399
399
  softDelete: true,
400
400
  });
@@ -21,8 +21,13 @@ import { createTenantDb, type TenantDb } from "../tenant-db";
21
21
  const entity = createEntity({
22
22
  table: "read_es_exec_users",
23
23
  fields: {
24
- email: createTextField({ required: true, searchable: true }),
25
- firstName: createTextField(),
24
+ email: createTextField({
25
+ required: true,
26
+ searchable: true,
27
+ personal: false,
28
+ reason: "test_fixture",
29
+ }),
30
+ firstName: createTextField({ personal: false, reason: "test_fixture" }),
26
31
  isEnabled: createBooleanField({ default: true }),
27
32
  },
28
33
  softDelete: true,
@@ -133,7 +138,7 @@ const SENSITIVE_TEST_KEY = Buffer.from("s3nS!t1vE.kP9xQ2@wN!vL$hR5yT8eU0").toStr
133
138
  const sensitiveEntity = createEntity({
134
139
  table: "read_es_exec_sensitive",
135
140
  fields: {
136
- email: createTextField({ required: true }),
141
+ email: createTextField({ required: true, personal: false, reason: "test_fixture" }),
137
142
  passwordHash: createTextField({ sensitive: true, encrypted: true }),
138
143
  apiToken: createTextField({ sensitive: true, encrypted: true }),
139
144
  },
@@ -337,7 +342,7 @@ const ENCRYPTION_TEST_KEY = Buffer.from("a]bJm#kP9xQ2@wN!vL$hR5yT8eU0iO3f").toSt
337
342
  const encryptedEntity = createEntity({
338
343
  table: "read_es_exec_encrypted",
339
344
  fields: {
340
- email: createTextField({ required: true }),
345
+ email: createTextField({ required: true, personal: false, reason: "test_fixture" }),
341
346
  secretNote: createTextField({ encrypted: true }),
342
347
  },
343
348
  });
@@ -346,7 +351,7 @@ const encryptedTable = buildEntityTable("esExecEncrypted", encryptedEntity);
346
351
  const encryptedSoftDeleteEntity = createEntity({
347
352
  table: "read_es_exec_enc_soft",
348
353
  fields: {
349
- email: createTextField({ required: true }),
354
+ email: createTextField({ required: true, personal: false, reason: "test_fixture" }),
350
355
  secretNote: createTextField({ encrypted: true }),
351
356
  },
352
357
  softDelete: true,
@@ -699,7 +704,7 @@ const piiEntity = createEntity({
699
704
  find: "none",
700
705
  }),
701
706
  authorId: createTextField(),
702
- plain: createTextField(),
707
+ plain: createTextField({ personal: false, reason: "test_fixture" }),
703
708
  },
704
709
  });
705
710
  const piiTable = buildEntityTable("esExecPii", piiEntity);
@@ -22,11 +22,11 @@ import { createTenantDb } from "../tenant-db";
22
22
  const systemEntity = createEntity({
23
23
  table: "sstream_sys",
24
24
  systemStream: true,
25
- fields: { name: createTextField({ required: true }) },
25
+ fields: { name: createTextField({ required: true, personal: false, reason: "test_fixture" }) },
26
26
  });
27
27
  const tenantOwnedEntity = createEntity({
28
28
  table: "sstream_tn",
29
- fields: { name: createTextField({ required: true }) },
29
+ fields: { name: createTextField({ required: true, personal: false, reason: "test_fixture" }) },
30
30
  });
31
31
 
32
32
  const systemTable = buildEntityTable("sstreamSys", systemEntity);
@@ -31,8 +31,8 @@ import { createTenantDb, type TenantDb } from "../tenant-db";
31
31
  const userEntity = createEntity({
32
32
  table: "read_implicit_users",
33
33
  fields: {
34
- email: createTextField({ required: true }),
35
- firstName: createTextField(),
34
+ email: createTextField({ required: true, personal: false, reason: "test_fixture" }),
35
+ firstName: createTextField({ personal: false, reason: "test_fixture" }),
36
36
  isEnabled: createBooleanField({ default: true }),
37
37
  },
38
38
  softDelete: true,
@@ -204,7 +204,9 @@ describe("implicit-projection / Live==Rebuild equivalence", () => {
204
204
  test("ohne softDelete → keine restore-apply-key registriert", () => {
205
205
  const hardDeleteEntity = createEntity({
206
206
  table: "read_implicit_hard",
207
- fields: { name: createTextField({ required: true }) },
207
+ fields: {
208
+ name: createTextField({ required: true, personal: false, reason: "test_fixture" }),
209
+ },
208
210
  });
209
211
  const hardFeature = defineFeature("implicithard", (r) => {
210
212
  r.entity("widget", hardDeleteEntity);
@@ -249,7 +251,7 @@ const SENSITIVE_BIDX_KEY = decodeBlindIndexKey(SENSITIVE_BIDX_KEY_B64);
249
251
  const sensitiveEntity = createEntity({
250
252
  table: sensitiveTable,
251
253
  fields: {
252
- email: createTextField({ required: true }),
254
+ email: createTextField({ required: true, personal: false, reason: "test_fixture" }),
253
255
  apiKey: createTextField({ personal: "self", find: "exact" }),
254
256
  },
255
257
  });
@@ -8,7 +8,7 @@ import { flattenLocatedTimestamp, rehydrateLocatedTimestamp } from "../located-t
8
8
 
9
9
  const orderEntity: EntityDefinition = createEntity({
10
10
  fields: {
11
- clientName: createTextField(),
11
+ clientName: createTextField({ personal: false, reason: "test_fixture" }),
12
12
  pickup: createLocatedTimestampField(),
13
13
  delivery: createLocatedTimestampField(),
14
14
  },
@@ -12,7 +12,7 @@ import { flattenMoney, type MoneyRead, moneyPayloadToMinorUnits, rehydrateMoney
12
12
  const orderEntity: EntityDefinition = createEntity({
13
13
  defaultCurrency: "EUR",
14
14
  fields: {
15
- label: createTextField(),
15
+ label: createTextField({ personal: false, reason: "test_fixture" }),
16
16
  buyingPrice: createMoneyField(),
17
17
  sellingPrice: createMoneyField(),
18
18
  },
@@ -18,8 +18,8 @@ import { setupTestStack, type TestStack, unsafeCreateEntityTable } from "../../s
18
18
  const linkEntity = createEntity({
19
19
  table: "mri_links",
20
20
  fields: {
21
- leftId: createTextField({ required: true }),
22
- rightId: createTextField({ required: true }),
21
+ leftId: createTextField({ required: true, personal: false, reason: "test_fixture" }),
22
+ rightId: createTextField({ required: true, personal: false, reason: "test_fixture" }),
23
23
  },
24
24
  });
25
25
  const linkTable = buildEntityTable("link", linkEntity);
@@ -42,7 +42,7 @@ describe("instant() customType is forgiving with ISO strings", () => {
42
42
 
43
43
  const tsEntity = createEntity({
44
44
  table: "mri_ts",
45
- fields: { name: createTextField({ required: true }) },
45
+ fields: { name: createTextField({ required: true, personal: false, reason: "test_fixture" }) },
46
46
  });
47
47
  const tsTable = buildEntityTable("ts-row", tsEntity);
48
48
 
@@ -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
+ });
@@ -80,8 +80,8 @@ describe("schema migration workflows", () => {
80
80
  createEntity({
81
81
  table: "wf1_posts",
82
82
  fields: {
83
- title: createTextField({ required: true }),
84
- body: createTextField(),
83
+ title: createTextField({ required: true, personal: false, reason: "test_fixture" }),
84
+ body: createTextField({ personal: false, reason: "test_fixture" }),
85
85
  viewCount: createNumberField(),
86
86
  publishedAt: createDateField(),
87
87
  isDraft: createBooleanField({ default: true }),
@@ -121,7 +121,7 @@ describe("schema migration workflows", () => {
121
121
  "article",
122
122
  createEntity({
123
123
  table: "wf1b_articles",
124
- fields: { title: createTextField() },
124
+ fields: { title: createTextField({ personal: false, reason: "test_fixture" }) },
125
125
  }),
126
126
  );
127
127
  });
@@ -138,7 +138,7 @@ describe("schema migration workflows", () => {
138
138
  // Initial entity with just email
139
139
  const initialEntity = createEntity({
140
140
  table: "wf2_users",
141
- fields: { email: createTextField() },
141
+ fields: { email: createTextField({ personal: false, reason: "test_fixture" }) },
142
142
  });
143
143
  await unsafePushTables(testDb.db, { user: buildEntityTable("user", initialEntity) });
144
144
 
@@ -146,8 +146,8 @@ describe("schema migration workflows", () => {
146
146
  const updatedEntity = createEntity({
147
147
  table: "wf2_users",
148
148
  fields: {
149
- email: createTextField(),
150
- displayName: createTextField(), // NEW FIELD
149
+ email: createTextField({ personal: false, reason: "test_fixture" }),
150
+ displayName: createTextField({ personal: false, reason: "test_fixture" }), // NEW FIELD
151
151
  },
152
152
  });
153
153
 
@@ -168,7 +168,7 @@ describe("schema migration workflows", () => {
168
168
  // Initial entity with just name
169
169
  const initialEntity = createEntity({
170
170
  table: "wf3_projects",
171
- fields: { name: createTextField() },
171
+ fields: { name: createTextField({ personal: false, reason: "test_fixture" }) },
172
172
  });
173
173
  const initialTable = buildEntityTable("project", initialEntity);
174
174
  await unsafePushTables(testDb.db, { project: initialTable });
@@ -182,7 +182,10 @@ describe("schema migration workflows", () => {
182
182
  // Developer adds boolean field with default
183
183
  const updatedEntity = createEntity({
184
184
  table: "wf3_projects",
185
- fields: { name: createTextField(), isArchived: createBooleanField({ default: false }) },
185
+ fields: {
186
+ name: createTextField({ personal: false, reason: "test_fixture" }),
187
+ isArchived: createBooleanField({ default: false }),
188
+ },
186
189
  });
187
190
  const updatedTable = buildEntityTable("project", updatedEntity);
188
191
  await unsafePushTables(testDb.db, { project: updatedTable }, { project: initialTable });
@@ -202,7 +205,9 @@ describe("schema migration workflows", () => {
202
205
  // greift.
203
206
  const initialEntity = createEntity({
204
207
  table: "wf3b_users",
205
- fields: { email: createTextField({ required: true }) },
208
+ fields: {
209
+ email: createTextField({ required: true, personal: false, reason: "test_fixture" }),
210
+ },
206
211
  });
207
212
  const initialTable = buildEntityTable("user", initialEntity);
208
213
  await unsafePushTables(testDb.db, { user: initialTable });
@@ -215,8 +220,13 @@ describe("schema migration workflows", () => {
215
220
  const updatedEntity = createEntity({
216
221
  table: "wf3b_users",
217
222
  fields: {
218
- email: createTextField({ required: true }),
219
- roles: createTextField({ required: true, default: "[]" }),
223
+ email: createTextField({ required: true, personal: false, reason: "test_fixture" }),
224
+ roles: createTextField({
225
+ required: true,
226
+ default: "[]",
227
+ personal: false,
228
+ reason: "test_fixture",
229
+ }),
220
230
  },
221
231
  });
222
232
  const updatedTable = buildEntityTable("user", updatedEntity);
@@ -232,7 +242,7 @@ describe("schema migration workflows", () => {
232
242
  "task",
233
243
  createEntity({
234
244
  table: "wf4_tasks",
235
- fields: { title: createTextField() },
245
+ fields: { title: createTextField({ personal: false, reason: "test_fixture" }) },
236
246
  softDelete: true,
237
247
  }),
238
248
  );
@@ -253,7 +263,10 @@ describe("schema migration workflows", () => {
253
263
  const blogFeature = defineFeature("blog", (r) => {
254
264
  r.entity(
255
265
  "article",
256
- createEntity({ table: "wf5_articles", fields: { title: createTextField() } }),
266
+ createEntity({
267
+ table: "wf5_articles",
268
+ fields: { title: createTextField({ personal: false, reason: "test_fixture" }) },
269
+ }),
257
270
  );
258
271
  });
259
272
 
@@ -262,7 +275,10 @@ describe("schema migration workflows", () => {
262
275
  "product",
263
276
  createEntity({
264
277
  table: "wf5_products",
265
- fields: { name: createTextField(), price: createNumberField() },
278
+ fields: {
279
+ name: createTextField({ personal: false, reason: "test_fixture" }),
280
+ price: createNumberField(),
281
+ },
266
282
  }),
267
283
  );
268
284
  });
@@ -23,7 +23,7 @@ const sourceEntity = createEntity({
23
23
  table: "ssc_source",
24
24
  fields: {
25
25
  // `source` collides with the EntityTableMeta discriminator key.
26
- source: createTextField({ required: true }),
26
+ source: createTextField({ required: true, personal: false, reason: "test_fixture" }),
27
27
  },
28
28
  });
29
29
  const sourceTable = buildEntityTable("source-row", sourceEntity);