@cosmicdrift/kumiko-framework 0.353.0 → 0.354.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 (165) hide show
  1. package/dist/api/index.d.ts +1 -1
  2. package/dist/api/index.js +1 -1
  3. package/dist/api/redis-sse-broker.d.ts +1 -0
  4. package/dist/api/redis-sse-broker.js +16 -10
  5. package/dist/api/request-context.d.ts +1 -0
  6. package/dist/api/request-context.js +5 -0
  7. package/dist/api/routes.js +4 -2
  8. package/dist/api/server.js +28 -3
  9. package/dist/api/sse-broker.d.ts +7 -3
  10. package/dist/api/sse-route.js +2 -6
  11. package/dist/api/token-expiry-timer.d.ts +1 -0
  12. package/dist/api/token-expiry-timer.js +10 -0
  13. package/dist/bun-db/query.js +29 -2
  14. package/dist/crypto/event-pii.js +4 -2
  15. package/dist/crypto/kms-wiring.js +31 -5
  16. package/dist/db/eagerload.d.ts +8 -3
  17. package/dist/db/eagerload.js +85 -81
  18. package/dist/db/event-store-executor-context.d.ts +12 -0
  19. package/dist/db/event-store-executor-context.js +59 -18
  20. package/dist/db/event-store-executor-read.js +81 -31
  21. package/dist/db/event-store-executor-write.js +54 -3
  22. package/dist/db/index.d.ts +1 -1
  23. package/dist/db/migrate-generator.js +1 -1
  24. package/dist/db/queries/backfill-pii.d.ts +1 -0
  25. package/dist/db/queries/backfill-pii.js +11 -6
  26. package/dist/db/queries/event-consumer.d.ts +2 -3
  27. package/dist/db/queries/event-consumer.js +54 -43
  28. package/dist/db/queries/event-store.d.ts +5 -0
  29. package/dist/db/queries/event-store.js +51 -3
  30. package/dist/db/queries/pii-backfill-state.d.ts +8 -1
  31. package/dist/db/queries/pii-backfill-state.js +16 -5
  32. package/dist/db/queries/shadow-swap.js +32 -3
  33. package/dist/db/render-ddl.js +6 -6
  34. package/dist/db/table-builder.js +6 -7
  35. package/dist/db/tenant-db.d.ts +5 -2
  36. package/dist/db/tenant-db.js +47 -7
  37. package/dist/derivatives/variant-key.d.ts +1 -0
  38. package/dist/derivatives/variant-key.js +3 -0
  39. package/dist/engine/boot-validator/access-declarations.js +5 -0
  40. package/dist/engine/boot-validator/config-deps.js +3 -0
  41. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  42. package/dist/engine/boot-validator/entity-handler.js +40 -0
  43. package/dist/engine/boot-validator/handler-field-references.d.ts +2 -0
  44. package/dist/engine/boot-validator/handler-field-references.js +25 -0
  45. package/dist/engine/boot-validator/index.js +6 -1
  46. package/dist/engine/boot-validator/nav.js +3 -1
  47. package/dist/engine/boot-validator/ownership.d.ts +2 -1
  48. package/dist/engine/boot-validator/ownership.js +32 -1
  49. package/dist/engine/boot-validator/pii-retention.js +8 -1
  50. package/dist/engine/boot-validator/query-output-columns.js +9 -9
  51. package/dist/engine/boot-validator/screens.js +98 -7
  52. package/dist/engine/boot-validator/transfer-graph.d.ts +2 -1
  53. package/dist/engine/boot-validator/transfer-graph.js +8 -63
  54. package/dist/engine/boot-validator/url-prefill-fields.d.ts +1 -0
  55. package/dist/engine/boot-validator/url-prefill-fields.js +35 -0
  56. package/dist/engine/build-app-schema.js +3 -0
  57. package/dist/engine/changeset-changes.d.ts +1 -0
  58. package/dist/engine/changeset-changes.js +49 -33
  59. package/dist/engine/define-feature.js +11 -0
  60. package/dist/engine/entity-handlers.d.ts +3 -3
  61. package/dist/engine/entity-handlers.js +55 -34
  62. package/dist/engine/escape-hatch-reason.d.ts +2 -0
  63. package/dist/engine/escape-hatch-reason.js +8 -0
  64. package/dist/engine/extension-names.d.ts +8 -9
  65. package/dist/engine/extension-names.js +8 -9
  66. package/dist/engine/extensions/tenant-data.d.ts +3 -1
  67. package/dist/engine/extensions/tenant-resource.d.ts +9 -1
  68. package/dist/engine/factories.d.ts +1 -1
  69. package/dist/engine/feature-changelog.d.ts +2 -0
  70. package/dist/engine/feature-changelog.js +6 -0
  71. package/dist/engine/feature-config-events-jobs.js +41 -4
  72. package/dist/engine/handler-field-references.d.ts +3 -0
  73. package/dist/engine/handler-field-references.js +31 -0
  74. package/dist/engine/i18n-key.js +2 -3
  75. package/dist/engine/index.d.ts +8 -2
  76. package/dist/engine/index.js +6 -1
  77. package/dist/engine/ownership.js +3 -4
  78. package/dist/engine/project-app-schema-for-roles.js +3 -1
  79. package/dist/engine/registry-ingest.d.ts +1 -0
  80. package/dist/engine/registry-ingest.js +8 -3
  81. package/dist/engine/run-pipeline.js +12 -1
  82. package/dist/engine/schema-builder.js +35 -2
  83. package/dist/engine/screen-helpers.d.ts +13 -0
  84. package/dist/engine/screen-helpers.js +31 -0
  85. package/dist/engine/soft-delete-cleanup.js +3 -2
  86. package/dist/engine/steps/_read-source.d.ts +4 -0
  87. package/dist/engine/steps/_read-source.js +22 -0
  88. package/dist/engine/steps/describe-workflow-step-error.js +3 -1
  89. package/dist/engine/steps/read-find-many.js +2 -1
  90. package/dist/engine/steps/read-find-one.js +2 -1
  91. package/dist/engine/steps/workflow-step-error.d.ts +5 -0
  92. package/dist/engine/steps/workflow-step-error.js +13 -0
  93. package/dist/engine/transfer-adjacency.d.ts +24 -0
  94. package/dist/engine/transfer-adjacency.js +107 -0
  95. package/dist/entrypoint/index.d.ts +2 -0
  96. package/dist/entrypoint/index.js +2 -0
  97. package/dist/env/index.js +45 -23
  98. package/dist/errors/classes.d.ts +8 -0
  99. package/dist/errors/classes.js +13 -0
  100. package/dist/errors/index.d.ts +2 -1
  101. package/dist/errors/index.js +2 -1
  102. package/dist/errors/reasons.d.ts +1 -0
  103. package/dist/errors/reasons.js +3 -0
  104. package/dist/errors/subject-erased.d.ts +2 -0
  105. package/dist/errors/subject-erased.js +9 -0
  106. package/dist/event-store/admin-api.js +14 -10
  107. package/dist/event-store/event-store.d.ts +1 -0
  108. package/dist/event-store/event-store.js +14 -9
  109. package/dist/event-store/events-schema.js +2 -1
  110. package/dist/event-store/index.d.ts +1 -1
  111. package/dist/event-store/index.js +1 -1
  112. package/dist/files/file-handle.d.ts +1 -1
  113. package/dist/files/file-handle.js +30 -6
  114. package/dist/files/file-routes.js +10 -4
  115. package/dist/files/resolve-content-type.d.ts +2 -0
  116. package/dist/files/resolve-content-type.js +8 -0
  117. package/dist/i18n/required-surface-keys.d.ts +2 -0
  118. package/dist/i18n/required-surface-keys.js +8 -0
  119. package/dist/jobs/index.d.ts +1 -1
  120. package/dist/jobs/job-runner.d.ts +13 -0
  121. package/dist/jobs/job-runner.js +128 -22
  122. package/dist/migrations/pii-event-backfill.js +46 -25
  123. package/dist/observability/index.d.ts +1 -1
  124. package/dist/observability/index.js +1 -1
  125. package/dist/observability/standard-metrics.d.ts +4 -0
  126. package/dist/observability/standard-metrics.js +12 -0
  127. package/dist/pipeline/dispatch-shared.d.ts +2 -0
  128. package/dist/pipeline/dispatch-shared.js +74 -9
  129. package/dist/pipeline/dispatch-stream.d.ts +5 -1
  130. package/dist/pipeline/dispatch-stream.js +19 -12
  131. package/dist/pipeline/dispatcher.d.ts +3 -1
  132. package/dist/pipeline/dispatcher.js +10 -15
  133. package/dist/pipeline/event-consumer-state.js +0 -26
  134. package/dist/pipeline/event-dispatcher-admin.js +17 -11
  135. package/dist/pipeline/event-dispatcher-delivery.d.ts +1 -1
  136. package/dist/pipeline/event-dispatcher-delivery.js +3 -2
  137. package/dist/pipeline/event-dispatcher.js +48 -7
  138. package/dist/pipeline/index.d.ts +2 -0
  139. package/dist/pipeline/index.js +1 -0
  140. package/dist/pipeline/member-read-only-transaction.js +25 -5
  141. package/dist/pipeline/msp-rebuild.js +3 -3
  142. package/dist/pipeline/pending-gap-ranges.d.ts +1 -0
  143. package/dist/pipeline/pending-gap-ranges.js +22 -0
  144. package/dist/pipeline/system-hooks.js +4 -4
  145. package/dist/pipeline/system-identity-switch.d.ts +1 -1
  146. package/dist/pipeline/system-identity-switch.js +5 -4
  147. package/dist/pipeline/tenant-timezone-cache.js +5 -3
  148. package/dist/redis/cache-sync-topics.d.ts +1 -0
  149. package/dist/redis/cache-sync-topics.js +1 -0
  150. package/dist/redis/client.d.ts +1 -0
  151. package/dist/redis/client.js +7 -0
  152. package/dist/redis/index.d.ts +1 -1
  153. package/dist/redis/index.js +1 -1
  154. package/dist/redis/pubsub-signal.d.ts +1 -0
  155. package/dist/redis/pubsub-signal.js +3 -0
  156. package/dist/search/meilisearch-adapter.js +34 -4
  157. package/dist/stack/test-stack.d.ts +4 -1
  158. package/dist/stack/test-stack.js +9 -2
  159. package/dist/testing/e2e-generator.js +7 -2
  160. package/dist/ui-types/index.d.ts +3 -3
  161. package/dist/ui-types/index.js +2 -2
  162. package/dist/upgrade-cli.js +7 -2
  163. package/package.json +12 -4
  164. package/src/changes.json +209 -0
  165. package/src/scripts/codemod/migrate-cross-tenant.ts +2 -1
@@ -1,34 +1,34 @@
1
- // Tier 2.7e Server-Side Eagerload für Reference-Felder.
1
+ // Server-side eagerload for reference fields.
2
2
  //
3
- // Nach `executor.list`/`detail` scannen wir die zurückgelieferten
4
- // rows nach reference-Field-Values, sammeln pro Reference die UUIDs
5
- // (deduped), führen einen einzigen WHERE id IN (...)-Lookup pro
6
- // Referenced-Entity aus, und hängen die resolved Rows als
7
- // `_refs.<fieldName>` (single) bzw. `_refs.<fieldName>: Row[]`
8
- // (multiple) an die Original-Rows.
3
+ // After `executor.list`/`detail` we scan the returned rows for reference
4
+ // values, collect the UUIDs per reference (deduped), run one
5
+ // WHERE id IN (...) lookup per referenced entity, and attach the resolved rows
6
+ // as `_refs.<fieldName>` (single) or `_refs.<fieldName>: Row[]` (multiple).
9
7
  //
10
- // Tenant-Scope: TenantDb hat den Tenant-Filter eingebaut (mode:
11
- // "tenant"); der Lookup erbt das transparent. Cross-Feature-Refs
12
- // landen automatisch im selben Tenant — falls ein referenced Item
13
- // dem User nicht gehört, kommt es aus dem Lookup nicht zurück
14
- // (TenantDb filtert), und der Renderer fällt auf UUID zurück.
8
+ // Access scope: `_refs` only returns what the viewer could read on the target
9
+ // entity itself (tenant, access.read, parentRef gate, field-level read). An
10
+ // unreadable ref is absent exactly like a missing one, so there is no
11
+ // existence oracle; the renderer falls back to the UUID. The target detail
12
+ // handler's handler-level `access` is not evaluated (same as reference
13
+ // search/sort).
15
14
  //
16
- // Limit: kein expliziter limit auf den Lookup-SELECT — wir
17
- // fragen genau die UUIDs ab die in den main-Rows vorkommen, also
18
- // O(n) pro Page (bei pageSize:50 mit 2 ref-Spalten = max 100 IDs).
19
- // Render-Side limit:200-Workaround entfällt damit komplett.
15
+ // No explicit limit on the lookup SELECT: it asks for exactly the UUIDs present
16
+ // in the main rows, so it is O(n) per page.
20
17
  //
21
- // Diese Datei lebt im framework/db damit sie an einer Stelle
22
- // zwischen executor und entity-handlers gemounted ist; sie nutzt
23
- // keine framework-engine-Internals und kann auch von custom
24
- // query-handlern manuell aufgerufen werden.
18
+ // Custom handlers must pass the viewer explicitly so the parentRef gate is
19
+ // never skipped silently.
25
20
  import { requestContext } from "../api/request-context.js";
26
21
  import { collectPiiSubjectFields, configuredPiiSubjectKms, decryptPiiFieldValues, } from "../crypto/index.js";
27
22
  import { selectMany } from "../db/query.js";
28
- import { maskWriteOnlyFields } from "../engine/field-access.js";
23
+ import { filterReadFields } from "../engine/field-access.js";
24
+ import { buildOwnershipClause, combineClauses, shiftParams, tableNameOf, } from "../engine/ownership.js";
29
25
  import { parseRefTargetEntityName } from "../engine/parse-ref-target.js";
26
+ import { isUuid, SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
30
27
  import { collectEncryptedFieldNames, decryptEntityFieldValues, resolveEntityFieldEncryption, } from "./entity-field-encryption.js";
31
- import { buildEntityTable } from "./table-builder.js";
28
+ import { buildParentRefClause } from "./parent-ref-clause.js";
29
+ import { executeRawQueryRead } from "./queries/raw-sql.js";
30
+ import { buildEntityTable, physicalColumnName } from "./table-builder.js";
31
+ import { tenantDbRunner } from "./tenant-db-runner.js";
32
32
  function isReferenceField(field) {
33
33
  return field.type === "reference";
34
34
  }
@@ -46,43 +46,12 @@ export function collectReferenceFields(entity) {
46
46
  return out;
47
47
  }
48
48
  // Referenced rows are read via a raw selectMany, not the referenced entity's
49
- // own executor context (enrichWithReferences only gets an
50
- // EagerLoadEntityResolver — routing through buildExecutorContext per ref
51
- // would need table/searchAdapter/entityCache wiring for no reason). Mirrors
49
+ // own executor context (that would need table/searchAdapter/entityCache wiring
50
+ // per ref). Access is enforced separately by readableRefIds. Mirrors
52
51
  // event-store-executor-context's decryptForRead ordering: PII is the outer
53
52
  // layer, peel it before the envelope-encrypted fields, or the envelope
54
53
  // cipher chokes on a still-PII-wrapped string.
55
- //
56
- // Ownership guard (fw#1671): the ref lookup below is tenant-scoped only, not
57
- // ownership-scoped — this function has no SessionUser to evaluate
58
- // refEntity.access.read against. If the ref entity declares row-level
59
- // ownership at all, decrypting here would hand a same-tenant User A the
60
- // plaintext PII of a User B row they merely reference (e.g. a freely-settable
61
- // reference UUID), even though refEntity.access.read says "own" — the caller
62
- // never gets to run that ownership check. Fail closed: strip PII/encrypted
63
- // fields entirely instead of decrypting (or leaking ciphertext) when that
64
- // guarantee can't be evaluated here.
65
- function hasOwnershipScopedRead(refEntity) {
66
- const readMap = refEntity.access?.read;
67
- if (readMap === undefined)
68
- return false;
69
- // "all" means that role sees every row unrestricted — a map where every
70
- // rule is "all" carries no ownership restriction at all, so stripping
71
- // here would just silently drop PII/encrypted fields #1667 wants
72
- // decrypted, for no security benefit.
73
- return Object.values(readMap).some((rule) => rule !== "all");
74
- }
75
- async function decryptReferencedRow(row, refEntity, piiFields, encryptedFields, kms) {
76
- if (hasOwnershipScopedRead(refEntity)) {
77
- if (piiFields.length === 0 && encryptedFields.size === 0)
78
- return row;
79
- const out = { ...row };
80
- for (const field of piiFields)
81
- delete out[field];
82
- for (const field of encryptedFields)
83
- delete out[field];
84
- return out;
85
- }
54
+ async function decryptReferencedRow(row, piiFields, encryptedFields, kms) {
86
55
  let out = row;
87
56
  if (piiFields.length > 0 && kms) {
88
57
  out = await decryptPiiFieldValues(out, piiFields, kms, {
@@ -94,20 +63,45 @@ async function decryptReferencedRow(row, refEntity, piiFields, encryptedFields,
94
63
  }
95
64
  return out;
96
65
  }
66
+ // One probe query per ref field: the ids the viewer may read on the target
67
+ // entity. Raw SQL because ownership/parentRef clauses are SQL fragments; the
68
+ // table is unaliased since where-rules qualify columns with the table name.
69
+ async function resolveRefAccessScope(refEntity, refTable, refTableName, ids, db, viewer) {
70
+ const access = combineClauses(buildOwnershipClause(viewer.user, refEntity.access?.read, refTable), buildParentRefClause(refEntity, refTable, refTableName, viewer.user, db, viewer.parentVisibility, {
71
+ includeDeleted: false,
72
+ }));
73
+ if (access.kind === "empty")
74
+ return { kind: "none" };
75
+ if (access.kind === "pass")
76
+ return { kind: "unrestricted" };
77
+ // No read path exists for non-uuid ids, so nothing can be access-checked.
78
+ if (refEntity.idType !== undefined && refEntity.idType !== "uuid")
79
+ return { kind: "none" };
80
+ const params = [ids];
81
+ let tenantClause = "";
82
+ if (refTable["tenantId"] !== undefined && db.mode === "tenant") {
83
+ params.push(db.tenantId, SYSTEM_TENANT_ID);
84
+ tenantClause = ` AND "${physicalColumnName(refTable, "tenantId")}" IN ($2, $3)`;
85
+ }
86
+ const shifted = shiftParams({ sqlText: access.sqlText, params: access.params }, params.length);
87
+ params.push(...shifted.params);
88
+ const rows = await executeRawQueryRead(tenantDbRunner(db), `SELECT "id" FROM "${refTableName}" WHERE "id" = ANY($1::uuid[])${tenantClause} AND ${shifted.sqlText}`, params);
89
+ return { kind: "restricted", readableIds: new Set(rows.map((r) => r.id)) };
90
+ }
97
91
  // Per-row, not Promise.all: a single legacy/backfilled row without a valid
98
92
  // envelope (decryptEntityFieldValues throws hard on malformed ciphertext)
99
93
  // must not 500 the whole list request — the main rows the caller asked for
100
94
  // are unrelated to this one broken reference. Drop just that row from the
101
95
  // map; the renderer falls back to the raw UUID.
102
96
  //
103
- // piiFields/encryptedFields/kms are constant per refEntity (fw#1671) — the
97
+ // piiFields/encryptedFields/kms are constant per refEntity — the
104
98
  // caller computes them once and passes them in instead of recomputing per row.
105
- async function buildRefLookupMap(rawRefRows, refEntity, refEntityName, piiFields, encryptedFields, kms) {
99
+ async function buildRefLookupMap(rawRefRows, refEntity, refEntityName, viewer, piiFields, encryptedFields, kms) {
106
100
  const map = new Map();
107
101
  for (const r of rawRefRows) {
108
102
  let decrypted;
109
103
  try {
110
- decrypted = await decryptReferencedRow(r, refEntity, piiFields, encryptedFields, kms);
104
+ decrypted = await decryptReferencedRow(r, piiFields, encryptedFields, kms);
111
105
  }
112
106
  catch (e) {
113
107
  console.warn(`[eagerload] failed to decrypt referenced row entity=${refEntityName} id=${String(r["id"])}: ${e instanceof Error ? e.message : String(e)}`);
@@ -115,13 +109,25 @@ async function buildRefLookupMap(rawRefRows, refEntity, refEntityName, piiFields
115
109
  }
116
110
  const id = decrypted["id"];
117
111
  if (typeof id === "string")
118
- map.set(id, maskWriteOnlyFields(refEntity, decrypted));
112
+ map.set(id, filterReadFields(refEntity, decrypted, viewer.user));
119
113
  }
120
114
  return map;
121
115
  }
116
+ function collectRefIds(rows, rf) {
117
+ const ids = new Set();
118
+ for (const row of rows) {
119
+ const v = row[rf.fieldName];
120
+ const values = rf.multiple ? (Array.isArray(v) ? v : []) : [v];
121
+ for (const item of values) {
122
+ if (typeof item === "string" && item.length > 0)
123
+ ids.add(item);
124
+ }
125
+ }
126
+ return ids;
127
+ }
122
128
  /** Eagerload für eine Liste von Rows. Mutiert nicht — gibt eine
123
129
  * flache Kopie der Rows mit hinzugefügtem `_refs`-Property zurück. */
124
- export async function enrichWithReferences(rows, entity, resolveEntity, db) {
130
+ export async function enrichWithReferences(rows, entity, resolveEntity, db, viewer) {
125
131
  const refFields = collectReferenceFields(entity);
126
132
  if (refFields.length === 0 || rows.length === 0) {
127
133
  return rows.map((r) => ({ ...r }));
@@ -130,21 +136,7 @@ export async function enrichWithReferences(rows, entity, resolveEntity, db) {
130
136
  // einziger SELECT WHERE id IN (...). Maps werden parallel gebaut
131
137
  // damit die Lookups nicht serialisieren (Promise.all).
132
138
  const lookupMaps = await Promise.all(refFields.map(async (rf) => {
133
- const ids = new Set();
134
- for (const row of rows) {
135
- const v = row[rf.fieldName];
136
- if (rf.multiple) {
137
- if (Array.isArray(v)) {
138
- for (const item of v) {
139
- if (typeof item === "string" && item.length > 0)
140
- ids.add(item);
141
- }
142
- }
143
- }
144
- else if (typeof v === "string" && v.length > 0) {
145
- ids.add(v);
146
- }
147
- }
139
+ const ids = collectRefIds(rows, rf);
148
140
  if (ids.size === 0)
149
141
  return { fieldName: rf.fieldName, multiple: rf.multiple, map: new Map() };
150
142
  const refEntity = resolveEntity(rf.refEntityName);
@@ -156,12 +148,24 @@ export async function enrichWithReferences(rows, entity, resolveEntity, db) {
156
148
  return { fieldName: rf.fieldName, multiple: rf.multiple, map: new Map() };
157
149
  }
158
150
  const refTable = buildEntityTable(rf.refEntityName, refEntity);
159
- const idArray = [...ids];
160
- const rawRefRows = (await selectMany(db, refTable, { id: idArray }));
151
+ const refTableName = tableNameOf(refTable);
152
+ const usesUuidIds = refEntity.idType === undefined || refEntity.idType === "uuid";
153
+ // A malformed ref value must not 500 the whole list via the uuid cast.
154
+ const idArray = usesUuidIds ? [...ids].filter(isUuid) : [...ids];
155
+ const emptyLookup = { fieldName: rf.fieldName, multiple: rf.multiple, map: new Map() };
156
+ if (idArray.length === 0)
157
+ return emptyLookup;
158
+ const scope = await resolveRefAccessScope(refEntity, refTable, refTableName, idArray, db, viewer);
159
+ if (scope.kind === "none")
160
+ return emptyLookup;
161
+ const lookedUpRows = (await selectMany(db, refTable, { id: idArray }));
162
+ const rawRefRows = scope.kind === "restricted"
163
+ ? lookedUpRows.filter((r) => scope.readableIds.has(String(r["id"])))
164
+ : lookedUpRows;
161
165
  const piiFields = collectPiiSubjectFields(refEntity);
162
166
  const encryptedFields = collectEncryptedFieldNames(refEntity);
163
167
  const kms = configuredPiiSubjectKms();
164
- const map = await buildRefLookupMap(rawRefRows, refEntity, rf.refEntityName, piiFields, encryptedFields, kms);
168
+ const map = await buildRefLookupMap(rawRefRows, refEntity, rf.refEntityName, viewer, piiFields, encryptedFields, kms);
165
169
  return { fieldName: rf.fieldName, multiple: rf.multiple, map };
166
170
  }));
167
171
  return rows.map((row) => {
@@ -186,7 +190,7 @@ export async function enrichWithReferences(rows, entity, resolveEntity, db) {
186
190
  });
187
191
  }
188
192
  /** Single-Row-Variante für detail-Calls. */
189
- export async function enrichRowWithReferences(row, entity, resolveEntity, db) {
190
- const enriched = await enrichWithReferences([row], entity, resolveEntity, db);
193
+ export async function enrichRowWithReferences(row, entity, resolveEntity, db, viewer) {
194
+ const enriched = await enrichWithReferences([row], entity, resolveEntity, db, viewer);
191
195
  return enriched[0] ?? { ...row, _refs: {} };
192
196
  }
@@ -34,6 +34,18 @@ export type ExecutorContext = {
34
34
  sqlText: string;
35
35
  params: readonly unknown[];
36
36
  }) => Promise<Record<string, unknown>[]>;
37
+ readonly loadDetailSnapshot: (db: TenantDb, idWhere: WhereObject, ownership: {
38
+ kind: "pass";
39
+ } | {
40
+ kind: "empty";
41
+ } | {
42
+ kind: "sql";
43
+ sqlText: string;
44
+ params: readonly unknown[];
45
+ }, streamTenantId: TenantId, aggregateId: string) => Promise<{
46
+ readonly row: Record<string, unknown> | null;
47
+ readonly streamVersion: number;
48
+ }>;
37
49
  readonly loadExpectSnapshot: (db: TenantDb, id: EntityId, streamTenantId: TenantId, expectKeys: readonly string[]) => Promise<{
38
50
  readonly row: Record<string, unknown> | null;
39
51
  readonly streamVersion: number;
@@ -1,10 +1,10 @@
1
1
  import { KUMIKO_NAME_SYMBOL } from "@cosmicdrift/kumiko-types/schema-table-types";
2
2
  import { requestContext } from "../api/request-context.js";
3
- import { collectPiiSubjectFields, configuredPiiSubjectKms, decryptPiiFieldValues, encryptPiiFieldValues, } from "../crypto/index.js";
3
+ import { collectPiiSubjectFields, configuredPiiSubjectKms, decryptPiiFieldValues, encryptPiiFieldValues, KeyErasedError, } from "../crypto/index.js";
4
4
  import { executeRawQueryRead } from "../db/queries/raw-sql.js";
5
5
  import { shiftParams } from "../engine/ownership.js";
6
6
  import { SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
7
- import { InternalError, UniqueViolationError, writeFailure, } from "../errors/index.js";
7
+ import { InternalError, subjectErasedConflict, UniqueViolationError, writeFailure, } from "../errors/index.js";
8
8
  import { ArchivedStreamError, isStreamArchived } from "../event-store/index.js";
9
9
  import { assertUnreachable } from "../utils/index.js";
10
10
  import { rehydrateCompoundTypes } from "./compound-types.js";
@@ -175,12 +175,19 @@ export function buildExecutorContext(table, entity, options) {
175
175
  }
176
176
  const kms = piiKms();
177
177
  if (hasPiiFields && kms) {
178
- out = await encryptPiiFieldValues(out, entity, piiSubjectFields, kms, kmsContextFor(user), {
179
- tenantId: user.tenantId,
180
- entityName,
181
- ...(opts?.onlyKeys !== undefined && { onlyKeys: opts.onlyKeys }),
182
- ...(opts?.subjectSource !== undefined && { subjectSource: opts.subjectSource }),
183
- });
178
+ try {
179
+ out = await encryptPiiFieldValues(out, entity, piiSubjectFields, kms, kmsContextFor(user), {
180
+ tenantId: user.tenantId,
181
+ entityName,
182
+ ...(opts?.onlyKeys !== undefined && { onlyKeys: opts.onlyKeys }),
183
+ ...(opts?.subjectSource !== undefined && { subjectSource: opts.subjectSource }),
184
+ });
185
+ }
186
+ catch (e) {
187
+ if (e instanceof KeyErasedError)
188
+ throw subjectErasedConflict(e);
189
+ throw e;
190
+ }
184
191
  }
185
192
  return out;
186
193
  }
@@ -264,6 +271,16 @@ export function buildExecutorContext(table, entity, options) {
264
271
  // ownership has raw SQL — splice it into a raw query alongside the
265
272
  // idFilter + tenant-filter that TenantDb would have added.
266
273
  const tableName = String(table[KUMIKO_NAME_SYMBOL]);
274
+ const { whereParts, params } = buildRawIdWhere(db, idWhere, ownership);
275
+ const sqlText = `SELECT * FROM "${tableName}" WHERE ${whereParts.join(" AND ")} LIMIT 1`;
276
+ return [
277
+ ...(await executeRawQueryRead(tenantDbRunner(db), sqlText, params)),
278
+ ];
279
+ }
280
+ // Tenant filter + idFilter + ownership predicate as raw SQL, mirroring what
281
+ // TenantDb.fetchOne/readWhere add. idFilter only yields `id` and a boolean
282
+ // `isDeleted`, so no null/IS NULL branch is needed.
283
+ function buildRawIdWhere(db, idWhere, ownership) {
267
284
  const colSql = (field) => `"${table[field]?.name ?? toSnakeCase(field)}"`;
268
285
  const whereParts = [];
269
286
  const params = [];
@@ -280,14 +297,38 @@ export function buildExecutorContext(table, entity, options) {
280
297
  whereParts.push(`${colSql(field)} = $${params.length}`);
281
298
  }
282
299
  }
283
- const shifted = shiftParams(ownership, params.length);
284
- whereParts.push(shifted.sqlText);
285
- for (const p of shifted.params)
286
- params.push(p);
287
- const sqlText = `SELECT * FROM "${tableName}" WHERE ${whereParts.join(" AND ")} LIMIT 1`;
288
- return [
289
- ...(await executeRawQueryRead(tenantDbRunner(db), sqlText, params)),
290
- ];
300
+ if (ownership.kind === "sql") {
301
+ const shifted = shiftParams(ownership, params.length);
302
+ whereParts.push(shifted.sqlText);
303
+ for (const p of shifted.params)
304
+ params.push(p);
305
+ }
306
+ return { whereParts, params };
307
+ }
308
+ function streamVersionSubquerySql(aggregateIdParam, tenantIdParam) {
309
+ return (`(SELECT MAX("version") FROM "kumiko_events" WHERE "aggregate_id" = $${aggregateIdParam} ` +
310
+ `AND "tenant_id" = $${tenantIdParam}) AS "__streamVersion"`);
311
+ }
312
+ // Row and MAX(version) in ONE statement: two reads can pair a post-write
313
+ // version with a pre-write row, and update({version: detail.version}) would
314
+ // then pass the optimistic lock and overwrite the newer write (lost update).
315
+ async function loadDetailSnapshot(db, idWhere, ownership, streamTenantId, aggregateId) {
316
+ if (ownership.kind === "empty")
317
+ return { row: null, streamVersion: 0 };
318
+ const tableName = String(table[KUMIKO_NAME_SYMBOL]);
319
+ const { whereParts, params } = buildRawIdWhere(db, idWhere, ownership);
320
+ params.push(aggregateId, streamTenantId);
321
+ const sqlText = `SELECT *, ${streamVersionSubquerySql(params.length - 1, params.length)} ` +
322
+ `FROM "${tableName}" WHERE ${whereParts.join(" AND ")} LIMIT 1`;
323
+ const rows = await executeRawQueryRead(tenantDbRunner(db), sqlText, params);
324
+ const first = rows[0];
325
+ if (!first)
326
+ return { row: null, streamVersion: 0 };
327
+ const { __streamVersion, ...row } = first;
328
+ return {
329
+ row,
330
+ streamVersion: typeof __streamVersion === "number" ? __streamVersion : 0,
331
+ };
291
332
  }
292
333
  // Reads the `expect:` fields and the aggregate's MAX(version) in ONE statement:
293
334
  // projection rows are written after their event commits, so two reads can
@@ -323,8 +364,7 @@ export function buildExecutorContext(table, entity, options) {
323
364
  const streamAggregateIdx = params.length - 1;
324
365
  const streamTenantIdx = params.length;
325
366
  const sqlText = `SELECT ${selectCols.join(", ")}, ` +
326
- `(SELECT MAX("version") FROM "kumiko_events" WHERE "aggregate_id" = $${streamAggregateIdx} ` +
327
- `AND "tenant_id" = $${streamTenantIdx}) AS "__streamVersion" ` +
367
+ `${streamVersionSubquerySql(streamAggregateIdx, streamTenantIdx)} ` +
328
368
  `FROM "${tableName}" WHERE ${whereParts.join(" AND ")} LIMIT 1`;
329
369
  const rows = await executeRawQueryRead(tenantDbRunner(db), sqlText, params);
330
370
  const row = rows[0];
@@ -348,6 +388,7 @@ export function buildExecutorContext(table, entity, options) {
348
388
  loadById,
349
389
  assertStreamWritable,
350
390
  loadWithOwnership,
391
+ loadDetailSnapshot,
351
392
  loadExpectSnapshot,
352
393
  encryptForStorage,
353
394
  decryptForRead,
@@ -6,7 +6,7 @@ import { coerceRow, extractTableInfo } from "../db/query.js";
6
6
  import { buildOwnershipClause, combineClauses, normalizeAccessEntry, shiftParams, } from "../engine/ownership.js";
7
7
  import { SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
8
8
  import { UnprocessableError } from "../errors/index.js";
9
- import { getStreamVersion } from "../event-store/index.js";
9
+ import { getStreamVersion, getStreamVersions } from "../event-store/index.js";
10
10
  import { LIST_ROW_META_REFERENCES } from "../ui-types/list-row-meta.js";
11
11
  import { rehydrateCompoundTypes } from "./compound-types.js";
12
12
  import { decodeKeysetCursor, encodeCursor, encodeKeysetCursor } from "./cursor.js";
@@ -64,6 +64,9 @@ function toCursorSortText(value) {
64
64
  }
65
65
  return undefined;
66
66
  }
67
+ function withStreamVersion(row, streamVersion) {
68
+ return streamVersion > 0 ? { ...row, version: streamVersion } : row;
69
+ }
67
70
  // Keyset boundary for `ORDER BY <sort> <dir>, id ASC` under Postgres' DEFAULT
68
71
  // null ordering (ASC → NULLS LAST, DESC → NULLS FIRST). An explicit NULLS clause
69
72
  // would change the visible order and cost the sort its index.
@@ -227,6 +230,12 @@ function deriveParentNarrowTypes(entity, payload) {
227
230
  // targets match" would silently hide rows a full-cardinality match would
228
231
  // have found. Native text search on the entity's own fields still applies.
229
232
  const MAX_REFERENCE_SEARCH_IDS = 200;
233
+ // The hit ids become an `id IN (...)` filter beneath the SQL cursor/filters/sort, so the
234
+ // adapter must return the whole candidate set, not a page; matches Meilisearch's default
235
+ // maxTotalHits, which caps a response at exactly this many hits however high a larger limit is
236
+ // asked. A response that reaches the cap is therefore flagged `searchTruncated` rather than
237
+ // treated as complete (a cap of N+1 could never be observed).
238
+ const MAX_SEARCH_CANDIDATE_IDS = 1000;
230
239
  // Registry-declared `searchable` reference fields, plus the implicit
231
240
  // tenantId→tenant.name row-meta reference (fw#2660) — both resolve a text
232
241
  // search term against a target row's label instead of the raw FK column.
@@ -312,16 +321,28 @@ async function resolveReferenceMatchesViaSearchIndex(descriptor, searchTerm, tar
312
321
  if (ownership.kind === "empty")
313
322
  return undefined;
314
323
  const candidateIds = results.map((r) => String(r.entityId));
315
- if (ownership.kind === "pass") {
324
+ // The index is only trusted to name candidates: a stale or foreign-tenant document must
325
+ // never become a filter value, so tenant-scoped targets are always re-read from the DB.
326
+ const tenantScoped = targetTable["tenantId"] !== undefined && db.mode === "tenant";
327
+ if (ownership.kind === "pass" && !tenantScoped) {
316
328
  return { ownColumn: descriptor.ownColumn, ids: candidateIds };
317
329
  }
318
330
  const params = [...candidateIds];
319
331
  const idPlaceholders = candidateIds.map((_, i) => `$${i + 1}`);
320
- const shifted = shiftParams({ sqlText: ownership.sqlText, params: ownership.params }, params.length);
321
- const sql = `SELECT "id" FROM "${targetTableName}" WHERE "id" IN (${idPlaceholders.join(", ")}) ` +
322
- `AND ${shifted.sqlText}`;
323
- for (const p of shifted.params)
324
- params.push(p);
332
+ let tenantClause = "";
333
+ if (tenantScoped) {
334
+ params.push(db.tenantId, SYSTEM_TENANT_ID);
335
+ tenantClause = ` AND "${physicalColumnName(targetTable, "tenantId")}" IN ($${params.length - 1}, $${params.length})`;
336
+ }
337
+ let ownershipClause = "";
338
+ if (ownership.kind === "sql") {
339
+ const shifted = shiftParams({ sqlText: ownership.sqlText, params: ownership.params }, params.length);
340
+ for (const p of shifted.params)
341
+ params.push(p);
342
+ ownershipClause = ` AND ${shifted.sqlText}`;
343
+ }
344
+ const sql = `SELECT "id" FROM "${targetTableName}" WHERE "id" IN (${idPlaceholders.join(", ")})` +
345
+ `${tenantClause}${ownershipClause}`;
325
346
  const rows = await executeRawQueryRead(tenantDbRunner(db), sql, params);
326
347
  if (rows.length === 0)
327
348
  return undefined;
@@ -374,7 +395,7 @@ const SORT_LABEL_ALIAS = "__kumiko_sort_label";
374
395
  // undefined means "cannot order by this label", which drops the sort to plain
375
396
  // id order; falling back to the UUID column would look deliberate to the user
376
397
  // while being arbitrary, which the issue calls worse than no sorting.
377
- function buildReferenceSortExpr(outerTableName, ownColumnSql, descriptor, resolveEntity, db) {
398
+ function buildReferenceSortExpr(outerTableName, ownColumnSql, descriptor, resolveEntity, db, user) {
378
399
  const targetEntity = resolveEntity(descriptor.targetEntityName);
379
400
  if (targetEntity === undefined) {
380
401
  // skip: unknown target entity — registry inconsistency the boot-validator
@@ -391,6 +412,13 @@ function buildReferenceSortExpr(outerTableName, ownColumnSql, descriptor, resolv
391
412
  return undefined;
392
413
  }
393
414
  const targetTable = buildEntityTable(descriptor.targetEntityName, targetEntity);
415
+ // The order of labels the viewer may not read would leak them bit by bit, same as the search path.
416
+ const labelAccess = buildOwnershipClause(user, normalizeAccessEntry(targetEntity.fields[descriptor.labelField]?.access?.read), targetTable);
417
+ if (labelAccess.kind !== "pass")
418
+ return undefined;
419
+ const ownership = buildOwnershipClause(user, targetEntity.access?.read, targetTable);
420
+ if (ownership.kind === "empty")
421
+ return undefined;
394
422
  const targetTableName = String(targetTable[KUMIKO_NAME_SYMBOL]);
395
423
  const labelCol = physicalColumnName(targetTable, descriptor.labelField);
396
424
  const tenantScoped = targetTable["tenantId"] !== undefined && db.mode === "tenant";
@@ -408,12 +436,19 @@ function buildReferenceSortExpr(outerTableName, ownColumnSql, descriptor, resolv
408
436
  params.push(tenantId, SYSTEM_TENANT_ID);
409
437
  tenantClause = ` AND t."${tenantCol}" IN ($${params.length - 1}, $${params.length})`;
410
438
  }
439
+ let ownershipClause = "";
440
+ if (ownership.kind === "sql") {
441
+ const shifted = shiftParams({ sqlText: ownership.sqlText, params: ownership.params }, params.length);
442
+ for (const p of shifted.params)
443
+ params.push(p);
444
+ ownershipClause = ` AND ${shifted.sqlText}`;
445
+ }
411
446
  return (`(SELECT t."${labelCol}" FROM "${targetTableName}" t ` +
412
- `WHERE t."id" = "${outerTableName}".${ownColumnSql}${tenantClause}${deletedClause})`);
447
+ `WHERE t."id" = "${outerTableName}".${ownColumnSql}${tenantClause}${ownershipClause}${deletedClause})`);
413
448
  };
414
449
  }
415
450
  export function createReadVerbs(ctx) {
416
- const { table, entity, entityName, entityCache, searchAdapter, softDelete, streamTenantFor, idFilter, loadWithOwnership, decryptForRead, encryptForStorage, } = ctx;
451
+ const { table, entity, entityName, entityCache, searchAdapter, softDelete, streamTenantFor, idFilter, loadWithOwnership, loadDetailSnapshot, decryptForRead, encryptForStorage, } = ctx;
417
452
  return {
418
453
  // list + detail are unchanged from crud-executor — projections are the
419
454
  // read-model and serve these queries directly.
@@ -453,6 +488,7 @@ export function createReadVerbs(ctx) {
453
488
  // ctx.searchAdapter erst zur Laufzeit weil createEventStoreExecutor
454
489
  // beim Definition-Time noch keinen Server-Context hat).
455
490
  const effectiveSearchAdapter = searchAdapter ?? runtimeOptions?.searchAdapter;
491
+ let searchTruncated = false;
456
492
  if (payload.search) {
457
493
  const searchTerm = payload.search;
458
494
  // #2032 — a search term with no adapter wired must fail loud, not
@@ -471,7 +507,9 @@ export function createReadVerbs(ctx) {
471
507
  const searchTenantId = streamTenantFor(user);
472
508
  const results = await effectiveSearchAdapter.search(searchTenantId, searchTerm, {
473
509
  filterType: entityName,
510
+ limit: MAX_SEARCH_CANDIDATE_IDS,
474
511
  });
512
+ searchTruncated = results.length >= MAX_SEARCH_CANDIDATE_IDS;
475
513
  filterIds = results.map((r) => r.entityId);
476
514
  // fw#2660 — union in reference-column label matches: a searchable
477
515
  // reference field (or the implicit tenantId→tenant.name row-meta
@@ -522,7 +560,7 @@ export function createReadVerbs(ctx) {
522
560
  const descriptor = referenceSort?.fields.find((f) => f.fieldName === sortCandidate);
523
561
  referenceSortExpr =
524
562
  referenceSort && descriptor
525
- ? buildReferenceSortExpr(tableName, colSql(sortCandidate), descriptor, referenceSort.resolveEntity, db)
563
+ ? buildReferenceSortExpr(tableName, colSql(sortCandidate), descriptor, referenceSort.resolveEntity, db, user)
526
564
  : undefined;
527
565
  if (referenceSortExpr !== undefined)
528
566
  sortField = sortCandidate;
@@ -626,11 +664,17 @@ export function createReadVerbs(ctx) {
626
664
  // field (ciphertext leaked to the caller).
627
665
  const tableInfo = extractTableInfo(table);
628
666
  const encryptedRows = rawRows.map((r) => rehydrateCompoundTypes(coerceRow(r, tableInfo), entity));
629
- const rows = await Promise.all(encryptedRows.map((r) => decryptForRead(r)));
630
- // list rows carry the READ-ROW version (display-only), never an optimistic-lock
631
- // base — edit flows reload via detail(), which reconciles the stream version.
632
- // Cache the still-encrypted form: same at-rest guarantee as detail()'s
633
- // encryptForStorage round-trip, without paying a re-encrypt.
667
+ const decryptedRows = await Promise.all(encryptedRows.map((r) => decryptForRead(r)));
668
+ // Stream version is authoritative (see detail()): ctx.appendEvent bumps the
669
+ // stream without touching row.version, so the raw row version would hand
670
+ // clients a stale optimistic-lock base. Rows without events keep theirs.
671
+ const streamVersions = decryptedRows.length > 0
672
+ ? await getStreamVersions(runner, decryptedRows.map((r) => String(r["id"])), streamTenantFor(user))
673
+ : new Map();
674
+ const rows = decryptedRows.map((r) => withStreamVersion(r, streamVersions.get(String(r["id"])) ?? 0));
675
+ // Cache the still-encrypted raw rows (read-row version): same at-rest guarantee as
676
+ // detail()'s encryptForStorage round-trip, without a re-encrypt; detail() rejects
677
+ // a cached version that differs from the stream.
634
678
  if (entityCache && entityName && rows.length > 0) {
635
679
  await entityCache.mset(user.tenantId, entityName, encryptedRows.map((r) => ({ id: r["id"], data: r })));
636
680
  }
@@ -657,7 +701,12 @@ export function createReadVerbs(ctx) {
657
701
  const countRows = await executeRawQueryRead(runner, countSql, whereParams);
658
702
  total = countRows[0]?.count ?? 0;
659
703
  }
660
- return { rows, nextCursor, ...(total !== undefined && { total }) };
704
+ return {
705
+ rows,
706
+ nextCursor,
707
+ ...(total !== undefined && { total }),
708
+ ...(searchTruncated && { searchTruncated: true }),
709
+ };
661
710
  },
662
711
  async detail(payload, user, db, options) {
663
712
  const tableName = String(table[KUMIKO_NAME_SYMBOL]);
@@ -676,10 +725,8 @@ export function createReadVerbs(ctx) {
676
725
  // out the stale row.version dooms the next CRUD update built on it
677
726
  // (entityEdit loads detail.version as its optimistic-lock base) to a
678
727
  // guaranteed version_conflict.
679
- const withStreamVersion = async (row) => {
680
- const streamVersion = await getStreamVersion(tenantDbRunner(db), String(payload.id), streamTenantFor(user));
681
- return streamVersion > 0 ? { ...row, version: streamVersion } : row;
682
- };
728
+ // A cache hit only counts when its version equals the live stream version:
729
+ // a late entityCache.set() can resurrect an old row after the writer's del().
683
730
  if (entityCache && entityName) {
684
731
  const cached = await entityCache.get(user.tenantId, entityName, payload.id);
685
732
  if (cached) {
@@ -692,25 +739,28 @@ export function createReadVerbs(ctx) {
692
739
  if (checkRows.length === 0)
693
740
  return null;
694
741
  }
695
- // Cached rows are stored re-encrypted (see the `set` below) so an
696
- // `encrypted` field's plaintext never sits in a second at-rest
697
- // store (Redis) the field-encryption feature doesn't cover.
698
- return withStreamVersion(await decryptForRead(cached));
742
+ const streamVersion = await getStreamVersion(tenantDbRunner(db), String(payload.id), streamTenantFor(user));
743
+ if (!(streamVersion > 0 && cached["version"] !== streamVersion)) {
744
+ // Cached rows are stored re-encrypted (see the `set` below) so an
745
+ // `encrypted` field's plaintext never sits in a second at-rest
746
+ // store (Redis) the field-encryption feature doesn't cover.
747
+ return withStreamVersion(await decryptForRead(cached), streamVersion);
748
+ }
699
749
  }
700
750
  }
701
- const rows = await loadWithOwnership(db, idWhere, ownership);
702
- const raw = rows[0];
751
+ const snapshot = await loadDetailSnapshot(db, idWhere, ownership, streamTenantFor(user), String(payload.id));
752
+ const raw = snapshot.row;
703
753
  if (!raw)
704
754
  return null;
705
755
  // Same coerce-before-rehydrate/decrypt ordering as list() above — raw
706
- // is snake_case only on the ownership.kind==="sql" branch (raw SQL);
707
- // coerceRow is a no-op on the already-camelCase "pass" branch rows.
756
+ // is snake_case (raw SQL); coerceRow maps it to camelCase.
708
757
  const rowInfo = extractTableInfo(table);
709
758
  const coerced = await decryptForRead(rehydrateCompoundTypes(coerceRow(raw, rowInfo), entity));
759
+ const result = withStreamVersion(coerced, snapshot.streamVersion);
710
760
  if (entityCache && entityName) {
711
- await entityCache.set(user.tenantId, entityName, payload.id, await encryptForStorage(coerced, user));
761
+ await entityCache.set(user.tenantId, entityName, payload.id, await encryptForStorage(result, user));
712
762
  }
713
- return withStreamVersion(coerced);
763
+ return result;
714
764
  },
715
765
  };
716
766
  }