@cosmicdrift/kumiko-framework 0.353.0 → 0.354.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (161) 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 +67 -25
  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 +4 -0
  29. package/dist/db/queries/event-store.js +40 -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/screen-helpers.d.ts +13 -0
  83. package/dist/engine/screen-helpers.js +31 -0
  84. package/dist/engine/soft-delete-cleanup.js +3 -2
  85. package/dist/engine/steps/_read-source.d.ts +4 -0
  86. package/dist/engine/steps/_read-source.js +22 -0
  87. package/dist/engine/steps/describe-workflow-step-error.js +3 -1
  88. package/dist/engine/steps/read-find-many.js +2 -1
  89. package/dist/engine/steps/read-find-one.js +2 -1
  90. package/dist/engine/steps/workflow-step-error.d.ts +5 -0
  91. package/dist/engine/steps/workflow-step-error.js +13 -0
  92. package/dist/engine/transfer-adjacency.d.ts +24 -0
  93. package/dist/engine/transfer-adjacency.js +107 -0
  94. package/dist/entrypoint/index.d.ts +2 -0
  95. package/dist/entrypoint/index.js +2 -0
  96. package/dist/env/index.js +45 -23
  97. package/dist/errors/classes.d.ts +8 -0
  98. package/dist/errors/classes.js +13 -0
  99. package/dist/errors/index.d.ts +2 -1
  100. package/dist/errors/index.js +2 -1
  101. package/dist/errors/reasons.d.ts +1 -0
  102. package/dist/errors/reasons.js +3 -0
  103. package/dist/errors/subject-erased.d.ts +2 -0
  104. package/dist/errors/subject-erased.js +9 -0
  105. package/dist/event-store/admin-api.js +14 -10
  106. package/dist/event-store/event-store.js +11 -9
  107. package/dist/event-store/events-schema.js +2 -1
  108. package/dist/files/file-handle.d.ts +1 -1
  109. package/dist/files/file-handle.js +30 -6
  110. package/dist/files/file-routes.js +10 -4
  111. package/dist/files/resolve-content-type.d.ts +2 -0
  112. package/dist/files/resolve-content-type.js +8 -0
  113. package/dist/i18n/required-surface-keys.d.ts +2 -0
  114. package/dist/i18n/required-surface-keys.js +8 -0
  115. package/dist/jobs/index.d.ts +1 -1
  116. package/dist/jobs/job-runner.d.ts +13 -0
  117. package/dist/jobs/job-runner.js +128 -22
  118. package/dist/migrations/pii-event-backfill.js +46 -25
  119. package/dist/observability/index.d.ts +1 -1
  120. package/dist/observability/index.js +1 -1
  121. package/dist/observability/standard-metrics.d.ts +4 -0
  122. package/dist/observability/standard-metrics.js +12 -0
  123. package/dist/pipeline/dispatch-shared.d.ts +2 -0
  124. package/dist/pipeline/dispatch-shared.js +74 -9
  125. package/dist/pipeline/dispatch-stream.d.ts +5 -1
  126. package/dist/pipeline/dispatch-stream.js +19 -12
  127. package/dist/pipeline/dispatcher.d.ts +3 -1
  128. package/dist/pipeline/dispatcher.js +10 -15
  129. package/dist/pipeline/event-consumer-state.js +0 -26
  130. package/dist/pipeline/event-dispatcher-admin.js +17 -11
  131. package/dist/pipeline/event-dispatcher-delivery.d.ts +1 -1
  132. package/dist/pipeline/event-dispatcher-delivery.js +3 -2
  133. package/dist/pipeline/event-dispatcher.js +48 -7
  134. package/dist/pipeline/index.d.ts +2 -0
  135. package/dist/pipeline/index.js +1 -0
  136. package/dist/pipeline/member-read-only-transaction.js +25 -5
  137. package/dist/pipeline/msp-rebuild.js +3 -3
  138. package/dist/pipeline/pending-gap-ranges.d.ts +1 -0
  139. package/dist/pipeline/pending-gap-ranges.js +22 -0
  140. package/dist/pipeline/system-hooks.js +4 -4
  141. package/dist/pipeline/system-identity-switch.d.ts +1 -1
  142. package/dist/pipeline/system-identity-switch.js +5 -4
  143. package/dist/pipeline/tenant-timezone-cache.js +5 -3
  144. package/dist/redis/cache-sync-topics.d.ts +1 -0
  145. package/dist/redis/cache-sync-topics.js +1 -0
  146. package/dist/redis/client.d.ts +1 -0
  147. package/dist/redis/client.js +7 -0
  148. package/dist/redis/index.d.ts +1 -1
  149. package/dist/redis/index.js +1 -1
  150. package/dist/redis/pubsub-signal.d.ts +1 -0
  151. package/dist/redis/pubsub-signal.js +3 -0
  152. package/dist/search/meilisearch-adapter.js +34 -4
  153. package/dist/stack/test-stack.d.ts +4 -1
  154. package/dist/stack/test-stack.js +9 -2
  155. package/dist/testing/e2e-generator.js +7 -2
  156. package/dist/ui-types/index.d.ts +3 -3
  157. package/dist/ui-types/index.js +2 -2
  158. package/dist/upgrade-cli.js +7 -2
  159. package/package.json +12 -4
  160. package/src/changes.json +198 -0
  161. 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,
@@ -227,6 +227,12 @@ function deriveParentNarrowTypes(entity, payload) {
227
227
  // targets match" would silently hide rows a full-cardinality match would
228
228
  // have found. Native text search on the entity's own fields still applies.
229
229
  const MAX_REFERENCE_SEARCH_IDS = 200;
230
+ // The hit ids become an `id IN (...)` filter beneath the SQL cursor/filters/sort, so the
231
+ // adapter must return the whole candidate set, not a page; matches Meilisearch's default
232
+ // maxTotalHits, which caps a response at exactly this many hits however high a larger limit is
233
+ // asked. A response that reaches the cap is therefore flagged `searchTruncated` rather than
234
+ // treated as complete (a cap of N+1 could never be observed).
235
+ const MAX_SEARCH_CANDIDATE_IDS = 1000;
230
236
  // Registry-declared `searchable` reference fields, plus the implicit
231
237
  // tenantId→tenant.name row-meta reference (fw#2660) — both resolve a text
232
238
  // search term against a target row's label instead of the raw FK column.
@@ -312,16 +318,28 @@ async function resolveReferenceMatchesViaSearchIndex(descriptor, searchTerm, tar
312
318
  if (ownership.kind === "empty")
313
319
  return undefined;
314
320
  const candidateIds = results.map((r) => String(r.entityId));
315
- if (ownership.kind === "pass") {
321
+ // The index is only trusted to name candidates: a stale or foreign-tenant document must
322
+ // never become a filter value, so tenant-scoped targets are always re-read from the DB.
323
+ const tenantScoped = targetTable["tenantId"] !== undefined && db.mode === "tenant";
324
+ if (ownership.kind === "pass" && !tenantScoped) {
316
325
  return { ownColumn: descriptor.ownColumn, ids: candidateIds };
317
326
  }
318
327
  const params = [...candidateIds];
319
328
  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);
329
+ let tenantClause = "";
330
+ if (tenantScoped) {
331
+ params.push(db.tenantId, SYSTEM_TENANT_ID);
332
+ tenantClause = ` AND "${physicalColumnName(targetTable, "tenantId")}" IN ($${params.length - 1}, $${params.length})`;
333
+ }
334
+ let ownershipClause = "";
335
+ if (ownership.kind === "sql") {
336
+ const shifted = shiftParams({ sqlText: ownership.sqlText, params: ownership.params }, params.length);
337
+ for (const p of shifted.params)
338
+ params.push(p);
339
+ ownershipClause = ` AND ${shifted.sqlText}`;
340
+ }
341
+ const sql = `SELECT "id" FROM "${targetTableName}" WHERE "id" IN (${idPlaceholders.join(", ")})` +
342
+ `${tenantClause}${ownershipClause}`;
325
343
  const rows = await executeRawQueryRead(tenantDbRunner(db), sql, params);
326
344
  if (rows.length === 0)
327
345
  return undefined;
@@ -374,7 +392,7 @@ const SORT_LABEL_ALIAS = "__kumiko_sort_label";
374
392
  // undefined means "cannot order by this label", which drops the sort to plain
375
393
  // id order; falling back to the UUID column would look deliberate to the user
376
394
  // while being arbitrary, which the issue calls worse than no sorting.
377
- function buildReferenceSortExpr(outerTableName, ownColumnSql, descriptor, resolveEntity, db) {
395
+ function buildReferenceSortExpr(outerTableName, ownColumnSql, descriptor, resolveEntity, db, user) {
378
396
  const targetEntity = resolveEntity(descriptor.targetEntityName);
379
397
  if (targetEntity === undefined) {
380
398
  // skip: unknown target entity — registry inconsistency the boot-validator
@@ -391,6 +409,13 @@ function buildReferenceSortExpr(outerTableName, ownColumnSql, descriptor, resolv
391
409
  return undefined;
392
410
  }
393
411
  const targetTable = buildEntityTable(descriptor.targetEntityName, targetEntity);
412
+ // The order of labels the viewer may not read would leak them bit by bit, same as the search path.
413
+ const labelAccess = buildOwnershipClause(user, normalizeAccessEntry(targetEntity.fields[descriptor.labelField]?.access?.read), targetTable);
414
+ if (labelAccess.kind !== "pass")
415
+ return undefined;
416
+ const ownership = buildOwnershipClause(user, targetEntity.access?.read, targetTable);
417
+ if (ownership.kind === "empty")
418
+ return undefined;
394
419
  const targetTableName = String(targetTable[KUMIKO_NAME_SYMBOL]);
395
420
  const labelCol = physicalColumnName(targetTable, descriptor.labelField);
396
421
  const tenantScoped = targetTable["tenantId"] !== undefined && db.mode === "tenant";
@@ -408,12 +433,19 @@ function buildReferenceSortExpr(outerTableName, ownColumnSql, descriptor, resolv
408
433
  params.push(tenantId, SYSTEM_TENANT_ID);
409
434
  tenantClause = ` AND t."${tenantCol}" IN ($${params.length - 1}, $${params.length})`;
410
435
  }
436
+ let ownershipClause = "";
437
+ if (ownership.kind === "sql") {
438
+ const shifted = shiftParams({ sqlText: ownership.sqlText, params: ownership.params }, params.length);
439
+ for (const p of shifted.params)
440
+ params.push(p);
441
+ ownershipClause = ` AND ${shifted.sqlText}`;
442
+ }
411
443
  return (`(SELECT t."${labelCol}" FROM "${targetTableName}" t ` +
412
- `WHERE t."id" = "${outerTableName}".${ownColumnSql}${tenantClause}${deletedClause})`);
444
+ `WHERE t."id" = "${outerTableName}".${ownColumnSql}${tenantClause}${ownershipClause}${deletedClause})`);
413
445
  };
414
446
  }
415
447
  export function createReadVerbs(ctx) {
416
- const { table, entity, entityName, entityCache, searchAdapter, softDelete, streamTenantFor, idFilter, loadWithOwnership, decryptForRead, encryptForStorage, } = ctx;
448
+ const { table, entity, entityName, entityCache, searchAdapter, softDelete, streamTenantFor, idFilter, loadWithOwnership, loadDetailSnapshot, decryptForRead, encryptForStorage, } = ctx;
417
449
  return {
418
450
  // list + detail are unchanged from crud-executor — projections are the
419
451
  // read-model and serve these queries directly.
@@ -453,6 +485,7 @@ export function createReadVerbs(ctx) {
453
485
  // ctx.searchAdapter erst zur Laufzeit weil createEventStoreExecutor
454
486
  // beim Definition-Time noch keinen Server-Context hat).
455
487
  const effectiveSearchAdapter = searchAdapter ?? runtimeOptions?.searchAdapter;
488
+ let searchTruncated = false;
456
489
  if (payload.search) {
457
490
  const searchTerm = payload.search;
458
491
  // #2032 — a search term with no adapter wired must fail loud, not
@@ -471,7 +504,9 @@ export function createReadVerbs(ctx) {
471
504
  const searchTenantId = streamTenantFor(user);
472
505
  const results = await effectiveSearchAdapter.search(searchTenantId, searchTerm, {
473
506
  filterType: entityName,
507
+ limit: MAX_SEARCH_CANDIDATE_IDS,
474
508
  });
509
+ searchTruncated = results.length >= MAX_SEARCH_CANDIDATE_IDS;
475
510
  filterIds = results.map((r) => r.entityId);
476
511
  // fw#2660 — union in reference-column label matches: a searchable
477
512
  // reference field (or the implicit tenantId→tenant.name row-meta
@@ -522,7 +557,7 @@ export function createReadVerbs(ctx) {
522
557
  const descriptor = referenceSort?.fields.find((f) => f.fieldName === sortCandidate);
523
558
  referenceSortExpr =
524
559
  referenceSort && descriptor
525
- ? buildReferenceSortExpr(tableName, colSql(sortCandidate), descriptor, referenceSort.resolveEntity, db)
560
+ ? buildReferenceSortExpr(tableName, colSql(sortCandidate), descriptor, referenceSort.resolveEntity, db, user)
526
561
  : undefined;
527
562
  if (referenceSortExpr !== undefined)
528
563
  sortField = sortCandidate;
@@ -657,7 +692,12 @@ export function createReadVerbs(ctx) {
657
692
  const countRows = await executeRawQueryRead(runner, countSql, whereParams);
658
693
  total = countRows[0]?.count ?? 0;
659
694
  }
660
- return { rows, nextCursor, ...(total !== undefined && { total }) };
695
+ return {
696
+ rows,
697
+ nextCursor,
698
+ ...(total !== undefined && { total }),
699
+ ...(searchTruncated && { searchTruncated: true }),
700
+ };
661
701
  },
662
702
  async detail(payload, user, db, options) {
663
703
  const tableName = String(table[KUMIKO_NAME_SYMBOL]);
@@ -676,10 +716,9 @@ export function createReadVerbs(ctx) {
676
716
  // out the stale row.version dooms the next CRUD update built on it
677
717
  // (entityEdit loads detail.version as its optimistic-lock base) to a
678
718
  // 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
- };
719
+ // A cache hit only counts when its version equals the live stream version:
720
+ // a late entityCache.set() can resurrect an old row after the writer's del().
721
+ const withVersion = (row, streamVersion) => (streamVersion > 0 ? { ...row, version: streamVersion } : row);
683
722
  if (entityCache && entityName) {
684
723
  const cached = await entityCache.get(user.tenantId, entityName, payload.id);
685
724
  if (cached) {
@@ -692,25 +731,28 @@ export function createReadVerbs(ctx) {
692
731
  if (checkRows.length === 0)
693
732
  return null;
694
733
  }
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));
734
+ const streamVersion = await getStreamVersion(tenantDbRunner(db), String(payload.id), streamTenantFor(user));
735
+ if (!(streamVersion > 0 && cached["version"] !== streamVersion)) {
736
+ // Cached rows are stored re-encrypted (see the `set` below) so an
737
+ // `encrypted` field's plaintext never sits in a second at-rest
738
+ // store (Redis) the field-encryption feature doesn't cover.
739
+ return withVersion(await decryptForRead(cached), streamVersion);
740
+ }
699
741
  }
700
742
  }
701
- const rows = await loadWithOwnership(db, idWhere, ownership);
702
- const raw = rows[0];
743
+ const snapshot = await loadDetailSnapshot(db, idWhere, ownership, streamTenantFor(user), String(payload.id));
744
+ const raw = snapshot.row;
703
745
  if (!raw)
704
746
  return null;
705
747
  // 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.
748
+ // is snake_case (raw SQL); coerceRow maps it to camelCase.
708
749
  const rowInfo = extractTableInfo(table);
709
750
  const coerced = await decryptForRead(rehydrateCompoundTypes(coerceRow(raw, rowInfo), entity));
751
+ const result = withVersion(coerced, snapshot.streamVersion);
710
752
  if (entityCache && entityName) {
711
- await entityCache.set(user.tenantId, entityName, payload.id, await encryptForStorage(coerced, user));
753
+ await entityCache.set(user.tenantId, entityName, payload.id, await encryptForStorage(result, user));
712
754
  }
713
- return withStreamVersion(coerced);
755
+ return result;
714
756
  },
715
757
  };
716
758
  }