@cosmicdrift/kumiko-framework 0.386.0 → 0.387.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 (288) hide show
  1. package/LICENSE +21 -57
  2. package/README.md +2 -2
  3. package/agents/widgets.md +39 -39
  4. package/dist/api/api-constants.js +2 -2
  5. package/dist/api/auth-middleware.js +7 -7
  6. package/dist/api/auth-rate-limiters.js +7 -3
  7. package/dist/api/auth-routes-mfa.js +1 -1
  8. package/dist/api/auth-routes-session.js +1 -1
  9. package/dist/api/auth-routes-shared.js +7 -3
  10. package/dist/api/auth-routes-signup.js +1 -1
  11. package/dist/api/client-ip.js +3 -1
  12. package/dist/api/extra-route.js +1 -1
  13. package/dist/api/pii-leak-guard.js +4 -2
  14. package/dist/api/readiness.js +5 -5
  15. package/dist/api/redis-sse-broker.js +8 -6
  16. package/dist/api/routes.js +2 -2
  17. package/dist/api/server.js +34 -33
  18. package/dist/api/sse-broker.js +1 -1
  19. package/dist/api/sse-route.d.ts +13 -13
  20. package/dist/api/sse-route.js +15 -15
  21. package/dist/auth/index.js +4 -4
  22. package/dist/auth/roles.d.ts +4 -4
  23. package/dist/auth/roles.js +24 -24
  24. package/dist/compliance/duration-spec.js +18 -20
  25. package/dist/compliance/index.js +2 -3
  26. package/dist/compliance/override-schema.d.ts +4 -4
  27. package/dist/compliance/override-schema.js +19 -22
  28. package/dist/compliance/profiles.d.ts +26 -29
  29. package/dist/compliance/profiles.js +62 -69
  30. package/dist/compliance/sub-processors.d.ts +28 -30
  31. package/dist/compliance/sub-processors.js +17 -21
  32. package/dist/consumer-cli.js +4 -4
  33. package/dist/crypto/blind-index.js +18 -17
  34. package/dist/crypto/ciphertext-pattern.js +1 -1
  35. package/dist/crypto/event-pii.js +3 -3
  36. package/dist/crypto/kek-source.d.ts +1 -1
  37. package/dist/crypto/kek-source.js +3 -2
  38. package/dist/crypto/kms-adapter.d.ts +1 -1
  39. package/dist/crypto/kms-adapter.js +4 -6
  40. package/dist/crypto/kms-wiring.d.ts +1 -2
  41. package/dist/crypto/kms-wiring.js +2 -2
  42. package/dist/crypto/pg-kms-adapter.js +2 -6
  43. package/dist/crypto/pii-field-encryption.js +4 -4
  44. package/dist/crypto/subject-resolver.d.ts +1 -1
  45. package/dist/crypto/subject-resolver.js +2 -2
  46. package/dist/db/apply-entity-event.d.ts +6 -6
  47. package/dist/db/apply-entity-event.js +55 -55
  48. package/dist/db/blind-index-cleanup.js +7 -7
  49. package/dist/db/collect-table-metas.js +10 -11
  50. package/dist/db/compound-types.d.ts +1 -1
  51. package/dist/db/compound-types.js +12 -12
  52. package/dist/db/cursor.js +3 -4
  53. package/dist/db/dialect.d.ts +2 -2
  54. package/dist/db/dialect.js +2 -2
  55. package/dist/db/eagerload.d.ts +3 -3
  56. package/dist/db/eagerload.js +21 -12
  57. package/dist/db/entity-table-meta.d.ts +2 -2
  58. package/dist/db/entity-table-meta.js +4 -4
  59. package/dist/db/event-store-executor-context.d.ts +1 -1
  60. package/dist/db/event-store-executor-context.js +13 -13
  61. package/dist/db/event-store-executor-read.js +27 -28
  62. package/dist/db/event-store-executor-write.js +65 -70
  63. package/dist/db/located-timestamp.d.ts +11 -11
  64. package/dist/db/located-timestamp.js +29 -30
  65. package/dist/db/migrate-generator.d.ts +1 -1
  66. package/dist/db/migrate-generator.js +26 -19
  67. package/dist/db/migrate-runner.js +25 -25
  68. package/dist/db/money.d.ts +6 -6
  69. package/dist/db/money.js +29 -27
  70. package/dist/db/queries/backfill-pii.js +5 -5
  71. package/dist/db/queries/ddl.js +1 -1
  72. package/dist/db/queries/es-ops.d.ts +1 -1
  73. package/dist/db/queries/es-ops.js +1 -1
  74. package/dist/db/queries/event-consumer.js +3 -3
  75. package/dist/db/queries/event-store.js +4 -2
  76. package/dist/db/queries/projection-rebuild.js +3 -3
  77. package/dist/db/queries/raw-sql.d.ts +2 -2
  78. package/dist/db/queries/raw-sql.js +2 -2
  79. package/dist/db/queries/shadow-swap.js +19 -19
  80. package/dist/db/query.d.ts +1 -1
  81. package/dist/db/query.js +6 -6
  82. package/dist/db/rebuild-marker.js +20 -21
  83. package/dist/db/replay-migration-sql.js +2 -2
  84. package/dist/db/schema-inspection.js +4 -5
  85. package/dist/db/table-builder.d.ts +1 -2
  86. package/dist/db/table-builder.js +82 -82
  87. package/dist/db/tenant-db.d.ts +2 -2
  88. package/dist/db/tenant-db.js +9 -2
  89. package/dist/derivatives/delete-stored-file.js +5 -2
  90. package/dist/derivatives/derivatives-context.js +1 -1
  91. package/dist/engine/boot-validator/access-roles.js +4 -3
  92. package/dist/engine/boot-validator/api-ext.js +14 -14
  93. package/dist/engine/boot-validator/config-deps.js +7 -6
  94. package/dist/engine/boot-validator/detail-screens.js +3 -3
  95. package/dist/engine/boot-validator/entity-handler.js +60 -61
  96. package/dist/engine/boot-validator/gdpr-storage.js +4 -4
  97. package/dist/engine/boot-validator/i18n-keys.js +1 -1
  98. package/dist/engine/boot-validator/index.d.ts +2 -2
  99. package/dist/engine/boot-validator/index.js +20 -20
  100. package/dist/engine/boot-validator/nav.js +5 -5
  101. package/dist/engine/boot-validator/ownership.js +12 -9
  102. package/dist/engine/boot-validator/parent-ref.js +1 -1
  103. package/dist/engine/boot-validator/pii-retention.js +50 -57
  104. package/dist/engine/boot-validator/projection-list-screens.js +7 -7
  105. package/dist/engine/boot-validator/query-refs.js +1 -1
  106. package/dist/engine/boot-validator/screens-actions.js +15 -16
  107. package/dist/engine/boot-validator/screens-forms.js +10 -11
  108. package/dist/engine/boot-validator/screens-lists.js +14 -15
  109. package/dist/engine/boot-validator/screens.js +34 -40
  110. package/dist/engine/boot-validator/security-baseline.js +3 -2
  111. package/dist/engine/boot-validator/transfer-graph.js +2 -2
  112. package/dist/engine/boot-validator/workspaces.js +3 -3
  113. package/dist/engine/bridge-stub.js +3 -4
  114. package/dist/engine/build-app-schema.js +75 -75
  115. package/dist/engine/build-config-feature-schema.js +50 -50
  116. package/dist/engine/build-target.js +20 -23
  117. package/dist/engine/codemod/pipeline-codemod.js +20 -13
  118. package/dist/engine/config-helpers.js +4 -4
  119. package/dist/engine/constants.js +2 -2
  120. package/dist/engine/create-app.js +2 -2
  121. package/dist/engine/define-feature.js +12 -11
  122. package/dist/engine/define-handler.js +12 -11
  123. package/dist/engine/define-step.js +1 -1
  124. package/dist/engine/define-workflow.d.ts +5 -5
  125. package/dist/engine/define-workflow.js +6 -6
  126. package/dist/engine/entity-handlers.js +13 -14
  127. package/dist/engine/event-def.js +2 -2
  128. package/dist/engine/extension-names.d.ts +16 -17
  129. package/dist/engine/extension-names.js +37 -38
  130. package/dist/engine/extensions/escape-hatch-usage.js +1 -1
  131. package/dist/engine/extensions/tenant-data.js +2 -2
  132. package/dist/engine/extensions/user-data.d.ts +23 -24
  133. package/dist/engine/factories.d.ts +14 -13
  134. package/dist/engine/factories.js +38 -37
  135. package/dist/engine/feature-ast/entity-field-types.js +1 -1
  136. package/dist/engine/feature-ast/extractors/handlers.js +1 -1
  137. package/dist/engine/feature-ast/extractors/hooks.js +1 -1
  138. package/dist/engine/feature-ast/extractors/shared.d.ts +2 -3
  139. package/dist/engine/feature-ast/extractors/shared.js +2 -3
  140. package/dist/engine/feature-ast/parse.js +13 -13
  141. package/dist/engine/feature-ast/patch.js +1 -1
  142. package/dist/engine/feature-ast/pattern-change-schema.js +1 -1
  143. package/dist/engine/feature-ast/patterns.d.ts +2 -2
  144. package/dist/engine/feature-ast/render.js +2 -2
  145. package/dist/engine/feature-config-events-jobs.js +15 -12
  146. package/dist/engine/feature-entity-handlers.js +9 -5
  147. package/dist/engine/feature-manifest.d.ts +9 -9
  148. package/dist/engine/feature-manifest.js +6 -7
  149. package/dist/engine/feature-ui-extensions.d.ts +3 -3
  150. package/dist/engine/feature-ui-extensions.js +30 -22
  151. package/dist/engine/field-access.js +5 -6
  152. package/dist/engine/index.d.ts +1 -0
  153. package/dist/engine/index.js +4 -4
  154. package/dist/engine/object-form.d.ts +1 -0
  155. package/dist/engine/object-form.js +14 -4
  156. package/dist/engine/ownership.js +3 -3
  157. package/dist/engine/parse-ref-target.js +5 -7
  158. package/dist/engine/pattern-library/index.js +2 -2
  159. package/dist/engine/pattern-library/library.js +2 -2
  160. package/dist/engine/pattern-library/types.js +3 -3
  161. package/dist/engine/pipeline.js +2 -2
  162. package/dist/engine/project-app-schema-for-roles.js +1 -1
  163. package/dist/engine/projection-helpers.js +9 -9
  164. package/dist/engine/qualified-name.js +2 -2
  165. package/dist/engine/registry-facade.js +1 -1
  166. package/dist/engine/registry-ingest.js +12 -12
  167. package/dist/engine/registry-state.js +17 -19
  168. package/dist/engine/registry-validate.js +12 -12
  169. package/dist/engine/run-in.js +2 -2
  170. package/dist/engine/run-pipeline.js +7 -0
  171. package/dist/engine/schema-builder.js +40 -38
  172. package/dist/engine/screen-filter-ops.js +17 -20
  173. package/dist/engine/screen-helpers.js +4 -3
  174. package/dist/engine/state-machine.d.ts +12 -12
  175. package/dist/engine/state-machine.js +7 -7
  176. package/dist/engine/steps/_resolver-utils.js +4 -3
  177. package/dist/engine/steps/for-each.js +4 -4
  178. package/dist/engine/steps/unsafe-projection-delete.js +1 -1
  179. package/dist/engine/steps/unsafe-projection-upsert.js +4 -4
  180. package/dist/engine/steps/wait-for-event.js +4 -4
  181. package/dist/engine/steps/workflow-system-events.js +3 -3
  182. package/dist/engine/tier-resolver-extension.d.ts +29 -27
  183. package/dist/engine/tier-resolver-extension.js +26 -26
  184. package/dist/engine/transfer-adjacency.js +2 -2
  185. package/dist/engine/validate-projection-allowlist.js +7 -27
  186. package/dist/engine/walk-step-instances.d.ts +2 -0
  187. package/dist/engine/walk-step-instances.js +38 -0
  188. package/dist/engine/where-rule-lint.js +1 -1
  189. package/dist/engine/write-handler-qn-extract.js +1 -1
  190. package/dist/entrypoint/index.js +3 -3
  191. package/dist/env/index.d.ts +7 -6
  192. package/dist/env/index.js +10 -9
  193. package/dist/errors/transition-details.js +7 -7
  194. package/dist/errors/write-error-info.d.ts +6 -6
  195. package/dist/errors/write-error-info.js +6 -6
  196. package/dist/es-ops/context.d.ts +9 -9
  197. package/dist/es-ops/context.js +28 -28
  198. package/dist/es-ops/index.js +7 -9
  199. package/dist/es-ops/operations-schema.js +4 -10
  200. package/dist/es-ops/runner.d.ts +11 -11
  201. package/dist/es-ops/runner.js +49 -53
  202. package/dist/es-ops/types.d.ts +55 -55
  203. package/dist/es-ops/types.js +11 -11
  204. package/dist/event-store/admin-api.js +6 -7
  205. package/dist/event-store/errors.js +1 -1
  206. package/dist/event-store/event-store.js +2 -2
  207. package/dist/event-store/rebuild-dead-letter.js +1 -1
  208. package/dist/files/feature.js +9 -10
  209. package/dist/files/file-handle.js +5 -6
  210. package/dist/files/file-ref-entity.js +15 -15
  211. package/dist/files/file-ref-table.js +7 -8
  212. package/dist/files/file-routes.d.ts +7 -0
  213. package/dist/files/file-routes.js +11 -5
  214. package/dist/files/in-memory-provider.js +8 -9
  215. package/dist/files/local-provider.js +18 -25
  216. package/dist/files/provider-resolver.d.ts +7 -7
  217. package/dist/files/storage-tracking.js +2 -2
  218. package/dist/files/zip-stream.d.ts +12 -19
  219. package/dist/files/zip-stream.js +44 -63
  220. package/dist/i18n/required-surface-keys.d.ts +1 -1
  221. package/dist/i18n/required-surface-keys.js +8 -8
  222. package/dist/jobs/job-runner-helpers.js +8 -2
  223. package/dist/jobs/job-runner-schedulers.d.ts +1 -1
  224. package/dist/jobs/job-runner-schedulers.js +3 -4
  225. package/dist/jobs/job-runner.js +13 -15
  226. package/dist/lifecycle/lifecycle.js +3 -5
  227. package/dist/lifecycle/signal-handlers.js +5 -4
  228. package/dist/logging/utils.d.ts +2 -1
  229. package/dist/logging/utils.js +38 -10
  230. package/dist/migrations/index.js +1 -1
  231. package/dist/migrations/kumiko-drift.js +14 -13
  232. package/dist/migrations/pending-rebuilds.d.ts +21 -20
  233. package/dist/migrations/pending-rebuilds.js +27 -27
  234. package/dist/migrations/projection-table-index.d.ts +3 -3
  235. package/dist/migrations/projection-table-index.js +8 -8
  236. package/dist/observability/metric-validator.js +1 -1
  237. package/dist/observability/metrics-handle.js +3 -3
  238. package/dist/pipeline/append-event-core.js +11 -2
  239. package/dist/pipeline/cascade-handler.d.ts +1 -1
  240. package/dist/pipeline/dispatch-batch.js +2 -2
  241. package/dist/pipeline/dispatch-query.js +10 -10
  242. package/dist/pipeline/dispatch-shared.d.ts +1 -0
  243. package/dist/pipeline/dispatch-shared.js +15 -17
  244. package/dist/pipeline/dispatch-stream.js +1 -1
  245. package/dist/pipeline/dispatch-write.js +3 -3
  246. package/dist/pipeline/dispatcher-utils.d.ts +1 -1
  247. package/dist/pipeline/dispatcher-utils.js +1 -1
  248. package/dist/pipeline/event-consumer-state.js +2 -2
  249. package/dist/pipeline/event-dispatcher-delivery.js +1 -1
  250. package/dist/pipeline/event-dispatcher.js +17 -28
  251. package/dist/pipeline/irreversible-operation-gate.js +1 -1
  252. package/dist/pipeline/lifecycle-pipeline.js +9 -9
  253. package/dist/pipeline/member-read-only-transaction.js +1 -3
  254. package/dist/pipeline/msp-rebuild.js +1 -1
  255. package/dist/pipeline/projection-rebuild.js +13 -12
  256. package/dist/pipeline/system-hooks.js +39 -41
  257. package/dist/pipeline/system-identity-switch.js +1 -3
  258. package/dist/pipeline/tenant-timezone-cache.js +1 -1
  259. package/dist/random/generate.d.ts +29 -29
  260. package/dist/random/generate.js +30 -30
  261. package/dist/random/words.js +20 -20
  262. package/dist/rate-limit/middleware.js +9 -5
  263. package/dist/redis/client.js +2 -2
  264. package/dist/redis/pubsub-signal.js +14 -17
  265. package/dist/schema-cli.js +116 -22
  266. package/dist/search/index.js +4 -4
  267. package/dist/search/purge-subject.js +4 -5
  268. package/dist/search/reindex-entity.js +6 -9
  269. package/dist/time/boot-tz-warning.d.ts +2 -2
  270. package/dist/time/boot-tz-warning.js +8 -8
  271. package/dist/time/iana.js +10 -10
  272. package/dist/time/index.js +9 -9
  273. package/dist/time/legacy-date.d.ts +2 -2
  274. package/dist/time/legacy-date.js +6 -8
  275. package/dist/time/polyfill.js +1 -1
  276. package/dist/time/tz-context.d.ts +1 -1
  277. package/dist/time/tz-context.js +1 -1
  278. package/dist/ui-types/app-schema.js +7 -7
  279. package/dist/ui-types/list-row-meta.js +4 -4
  280. package/dist/upgrade-cli.js +10 -9
  281. package/dist/utils/compare.js +1 -1
  282. package/package.json +4 -4
  283. package/src/changes.json +48 -0
  284. package/src/scripts/codemod/crypto-shredding-testing-move.ts +2 -2
  285. package/src/scripts/codemod/migrate-cross-tenant.ts +1 -1
  286. package/src/scripts/codemod/migrate-open-to-all.ts +1 -1
  287. package/src/scripts/codemod/pii-personal-migration.ts +17 -8
  288. package/src/scripts/codemod/unprocessable-error-details-reason.ts +2 -2
@@ -24,12 +24,15 @@ import { filterReadFields } from "../engine/field-access.js";
24
24
  import { buildOwnershipClause, combineClauses, shiftParams, tableNameOf, } from "../engine/ownership.js";
25
25
  import { parseRefTargetEntityName } from "../engine/parse-ref-target.js";
26
26
  import { isUuid, SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
27
+ import { scrubLogValue } from "../logging/scrub.js";
28
+ import { createFallbackLogger } from "../logging/utils.js";
27
29
  import { collectEncryptedFieldNames, decryptEntityFieldValues, resolveEntityFieldEncryption, } from "./entity-field-encryption.js";
28
30
  import { buildParentRefClause } from "./parent-ref-clause.js";
29
31
  import { toPostgresArrayLiteral } from "./queries/postgres-array-literal.js";
30
32
  import { executeRawQueryRead } from "./queries/raw-sql.js";
31
33
  import { buildEntityTable, physicalColumnName } from "./table-builder.js";
32
34
  import { tenantDbRunner } from "./tenant-db-runner.js";
35
+ const log = createFallbackLogger("eagerload");
33
36
  function isReferenceField(field) {
34
37
  return field.type === "reference";
35
38
  }
@@ -105,7 +108,11 @@ async function buildRefLookupMap(rawRefRows, refEntity, refEntityName, viewer, p
105
108
  decrypted = await decryptReferencedRow(r, piiFields, encryptedFields, kms);
106
109
  }
107
110
  catch (e) {
108
- console.warn(`[eagerload] failed to decrypt referenced row entity=${refEntityName} id=${String(r["id"])}: ${e instanceof Error ? e.message : String(e)}`);
111
+ log.warn("failed to decrypt referenced row", {
112
+ entity: refEntityName,
113
+ id: String(r["id"]),
114
+ err: scrubLogValue(e),
115
+ });
109
116
  continue;
110
117
  }
111
118
  const id = decrypted["id"];
@@ -126,26 +133,26 @@ function collectRefIds(rows, rf) {
126
133
  }
127
134
  return ids;
128
135
  }
129
- /** Eagerload für eine Liste von Rows. Mutiert nicht — gibt eine
130
- * flache Kopie der Rows mit hinzugefügtem `_refs`-Property zurück. */
136
+ /** Eagerloads a list of rows without mutating them; returns shallow copies
137
+ * with an added `_refs` property. */
131
138
  export async function enrichWithReferences(rows, entity, resolveEntity, db, viewer) {
132
139
  const refFields = collectReferenceFields(entity);
133
140
  if (refFields.length === 0 || rows.length === 0) {
134
141
  return rows.map((r) => ({ ...r }));
135
142
  }
136
- // Pro reference-Field: deduped Set der IDs sammeln, dann ein
137
- // einziger SELECT WHERE id IN (...). Maps werden parallel gebaut
138
- // damit die Lookups nicht serialisieren (Promise.all).
143
+ // For each reference field, collect IDs in a deduplicated Set, then run one
144
+ // SELECT WHERE id IN (...). Build the maps in parallel so the lookups do
145
+ // not run serially (Promise.all).
139
146
  const lookupMaps = await Promise.all(refFields.map(async (rf) => {
140
147
  const ids = collectRefIds(rows, rf);
141
148
  if (ids.size === 0)
142
149
  return { fieldName: rf.fieldName, multiple: rf.multiple, map: new Map() };
143
150
  const refEntity = resolveEntity(rf.refEntityName);
144
151
  if (refEntity === undefined) {
145
- // Author-Fehler oder Race-Condition (entity gerade umbenannt
146
- // ohne registry-Reload). Boot-Validator hat das normalerweise
147
- // gepinnt; Runtime-Defense: leere Map → Renderer fällt auf
148
- // UUID zurück, kein Crash.
152
+ // Authoring error or race condition (the entity was renamed without a
153
+ // registry reload). The boot validator normally catches this;
154
+ // as a runtime safeguard, an empty map makes the renderer fall back
155
+ // to the UUID instead of crashing.
149
156
  return { fieldName: rf.fieldName, multiple: rf.multiple, map: new Map() };
150
157
  }
151
158
  const refTable = buildEntityTable(rf.refEntityName, refEntity);
@@ -159,7 +166,9 @@ export async function enrichWithReferences(rows, entity, resolveEntity, db, view
159
166
  const scope = await resolveRefAccessScope(refEntity, refTable, refTableName, idArray, db, viewer);
160
167
  if (scope.kind === "none")
161
168
  return emptyLookup;
162
- const lookedUpRows = (await selectMany(db, refTable, { id: idArray }));
169
+ const lookedUpRows = await selectMany(db, refTable, {
170
+ id: idArray,
171
+ });
163
172
  const rawRefRows = scope.kind === "restricted"
164
173
  ? lookedUpRows.filter((r) => scope.readableIds.has(String(r["id"])))
165
174
  : lookedUpRows;
@@ -190,7 +199,7 @@ export async function enrichWithReferences(rows, entity, resolveEntity, db, view
190
199
  return { ...row, _refs: refs };
191
200
  });
192
201
  }
193
- /** Single-Row-Variante für detail-Calls. */
202
+ /** Single-row variant for detail calls. */
194
203
  export async function enrichRowWithReferences(row, entity, resolveEntity, db, viewer) {
195
204
  const enriched = await enrichWithReferences([row], entity, resolveEntity, db, viewer);
196
205
  return enriched[0] ?? { ...row, _refs: {} };
@@ -11,7 +11,7 @@ export declare function resolveTableName(entityName: string, entity: EntityDefin
11
11
  * (convention: `store_*`) — or build columns by hand with `defineUnmanagedTable`.
12
12
  *
13
13
  * Named `derive*` (not `build*Meta`) so it is not mistaken for the unmanaged
14
- * escape hatch (#1208).
14
+ * escape hatch.
15
15
  */
16
16
  export declare function deriveEntityTableMeta(entityName: string, entity: EntityDefinition, options: BuildEntityTableMetaOptions & {
17
17
  readonly source: "unmanaged";
@@ -20,7 +20,7 @@ export declare function deriveEntityTableMeta(entityName: string, entity: Entity
20
20
  export declare function assertBackingTableSuperset(entityName: string, fieldMeta: EntityTableMeta, tableMeta: EntityTableMeta): void;
21
21
  /**
22
22
  * Hand-built EntityTableMeta for direct-write stores (no entity base columns).
23
- * Prefer a `store_*` table name; `read_` is reserved for managed projections (#1220).
23
+ * Prefer a `store_*` table name; `read_` is reserved for managed projections.
24
24
  *
25
25
  * Escape hatch, not a shortcut: no audit trail, no automatic tenant_id index,
26
26
  * no softDelete — the app author owns tenant-scoping and retention for this
@@ -173,7 +173,7 @@ function fieldToColumnMeta(name, field, entity) {
173
173
  return [{ name: snake, pgType: "jsonb", notNull: true, defaultSql: "'{}'::jsonb" }];
174
174
  case "date":
175
175
  // Real PG `date` — Temporal.PlainDate, no time-of-day/timezone
176
- // component. See plainDate() in dialect.ts (kumiko-framework#1924).
176
+ // component. See plainDate() in dialect.ts.
177
177
  return [{ name: snake, pgType: "date", notNull: field.required === true }];
178
178
  case "timestamp":
179
179
  return [{ name: snake, pgType: "timestamptz", notNull: field.required === true }];
@@ -299,7 +299,7 @@ export function deriveEntityTableMeta(entityName, entity, options) {
299
299
  const indexName = def.name ?? `${tableName}_${cols.join("_")}_${suffix}`;
300
300
  const explicitWhereSql = sqlExpressionText(def.where);
301
301
  // Soft-deleted rows keep their value, so a full unique index would
302
- // permanently block reuse; kept in lock-step with table-builder.ts (framework#2593).
302
+ // permanently block reuse; kept in lock-step with table-builder.ts.
303
303
  const whereSql = explicitWhereSql ??
304
304
  (def.where === undefined && def.unique === true && entity.softDelete === true
305
305
  ? SOFT_DELETE_LIVE_ROW_PREDICATE
@@ -323,7 +323,7 @@ export function deriveEntityTableMeta(entityName, entity, options) {
323
323
  .map((c) => `"${c}" IS NOT NULL`);
324
324
  // Soft-deleted rows keep their bidx hash — without this, a restored
325
325
  // row can't reuse a value a soft-deleted sibling still holds
326
- // (framework#2464). Kept in lock-step with table-builder.ts.
326
+ // Kept in lock-step with table-builder.ts.
327
327
  const whereParts = entity.softDelete === true
328
328
  ? [...notNullParts, SOFT_DELETE_LIVE_ROW_PREDICATE]
329
329
  : notNullParts;
@@ -384,7 +384,7 @@ function columnsByNameMeta(meta) {
384
384
  }
385
385
  /**
386
386
  * Hand-built EntityTableMeta for direct-write stores (no entity base columns).
387
- * Prefer a `store_*` table name; `read_` is reserved for managed projections (#1220).
387
+ * Prefer a `store_*` table name; `read_` is reserved for managed projections.
388
388
  *
389
389
  * Escape hatch, not a shortcut: no audit trail, no automatic tenant_id index,
390
390
  * no softDelete — the app author owns tenant-scoping and retention for this
@@ -7,7 +7,7 @@ import type { SearchAdapter } from "../search/types.ts";
7
7
  import type { TableColumns } from "./dialect.ts";
8
8
  import type { EventStoreExecutorOptions } from "./event-store-executor.ts";
9
9
  import type { TenantDb } from "./tenant-db.ts";
10
- export type Table = TableColumns<any>;
10
+ export type Table = TableColumns;
11
11
  export declare function buildFilterWhere(field: string, op: "eq" | "ne" | "lt" | "gt" | "lte" | "gte" | "in", value: unknown): WhereObject | null;
12
12
  export declare function ownershipSubject(user: SessionUser, streamTenantId: TenantId | undefined): SessionUser;
13
13
  export declare function isForeignTenantOnGlobalEntity(entity: EntityDefinition, tenantIdValue: unknown): boolean;
@@ -13,10 +13,10 @@ import { constraintOf, isUniqueViolation } from "./pg-error.js";
13
13
  import { toSnakeCase } from "./table-builder.js";
14
14
  import { columnHandleName, schemaTableName } from "./table-introspection.js";
15
15
  import { tenantDbRunner } from "./tenant-db-runner.js";
16
- // Screen-Filter (Tier 2.7c) — Op-Mapping als Where-Operator. Boot-
17
- // Validator pinst field-Existenz + filterable + op-vs-Type-Compat.
18
- // `op` ist auf {eq,ne,lt,gt,lte,gte,in} normalisiert; "in" mit
19
- // empty-array ist explizit no-match.
16
+ // Screen filter: maps an operator to a WhereObject condition.
17
+ // The boot validator checks field existence, filterability, and operator/type compatibility.
18
+ // `op` is normalized to {eq,ne,lt,gt,lte,gte,in}; "in" with an
19
+ // empty array explicitly matches nothing.
20
20
  export function buildFilterWhere(field, op, value) {
21
21
  switch (op) {
22
22
  case "eq":
@@ -70,11 +70,11 @@ function scalarDefault(field) {
70
70
  return undefined;
71
71
  }
72
72
  }
73
- // F8 helper: PG-23505 (unique-violation) catched aus applyEntityEvent
74
- // (create + update Pfade) → WriteFailure(UniqueViolationError 409).
75
- // Andere Errors propagieren via re-throw. Lokal extrahiert weil das
76
- // Pattern an zwei Stellen im executor lebt — der Caller wrap't den
77
- // applyEntityEvent-call in try-catch und delegiert das Mapping hierher.
73
+ // Maps PG-23505 (unique-violation) caught from applyEntityEvent
74
+ // (create and update paths) to WriteFailure(UniqueViolationError 409).
75
+ // Other errors propagate via re-throw. Extracted because this pattern
76
+ // occurs in two places in the executor; the caller wraps the
77
+ // applyEntityEvent call in try-catch and delegates the mapping here.
78
78
  //
79
79
  // Returns WriteFailure on match, null otherwise (caller re-throws).
80
80
  export function tryMapUniqueViolation(e, entityName) {
@@ -161,8 +161,8 @@ export function buildExecutorContext(table, entity, options) {
161
161
  }
162
162
  // Pre-compute the set of sensitive field names once. The event log stores
163
163
  // these fields as table ciphertext (boot validates sensitive ⇒ pii |
164
- // encrypted, #967) — the set only strips the caller-facing event echo so
165
- // responses never carry the value (#820).
164
+ // encrypted) — the set only strips the caller-facing event echo so
165
+ // responses never carry the value.
166
166
  const sensitiveFields = new Set();
167
167
  for (const [name, field] of Object.entries(entity.fields)) {
168
168
  if ("sensitive" in field && field.sensitive === true) {
@@ -184,8 +184,8 @@ export function buildExecutorContext(table, entity, options) {
184
184
  return resolveEntityFieldEncryption();
185
185
  }
186
186
  // No adapter configured = crypto-shredding off; pii fields stay plaintext
187
- // (pre-#724 behavior). The hard boot gate ships with the prod-grade
188
- // PgKmsAdapter (phase E).
187
+ // (previous behavior). The hard boot gate ships with the prod-grade
188
+ // PgKmsAdapter.
189
189
  function piiKms() {
190
190
  return options.kms ?? configuredPiiSubjectKms();
191
191
  }
@@ -17,9 +17,9 @@ import { buildEntityTable, physicalColumnName } from "./table-builder.js";
17
17
  import { schemaTableName } from "./table-introspection.js";
18
18
  import { tenantDbRunner } from "./tenant-db-runner.js";
19
19
  // The two read verbs (list/detail) of the event-store-executor. Split out
20
- // of event-store-executor.ts (#1005, Welle 2) — behavior-preserving
21
- // relocation, not a redesign: unchanged from the original, now behind an
22
- // explicit ExecutorContext instead of the factory's local scope.
20
+ // of event-store-executor.ts — behavior-preserving relocation, not a redesign:
21
+ // unchanged from the original, now behind an explicit ExecutorContext instead
22
+ // of the factory's local scope.
23
23
  // Defense-in-depth pagination guard. The handler boundary (entityListSchema)
24
24
  // already validates limit/offset, but the executor is public API for custom
25
25
  // handlers that pass their payload straight through — a non-integer limit
@@ -90,7 +90,7 @@ function keysetBoundarySql(sortCol, idCol, cursor, descending, params) {
90
90
  }
91
91
  // multiSelect stores its options as a jsonb array — a filter value is one
92
92
  // option, not the whole array, so eq/ne/in must check array containment
93
- // (`@>`) instead of scalar `=`/`<>`/`IN` against the jsonb column (fw#2490).
93
+ // (`@>`) instead of scalar `=`/`<>`/`IN` against the jsonb column.
94
94
  // lt/gt/lte/gte have no containment analogue; the boot-validator already
95
95
  // blocks them for screen-declared filters (screen-filter-ops.ts
96
96
  // EQUALITY_ONLY), but a client-supplied facet filter reaches here
@@ -144,7 +144,7 @@ function applyScreenFilter(table, colSql, whereSql, params, f) {
144
144
  return;
145
145
  }
146
146
  for (const [field, value] of Object.entries(screen)) {
147
- // #2015: `x <> NULL` is never true — mirror buildWhereClause's IS [NOT] NULL handling in db/query.ts.
147
+ // `x <> NULL` is never true — mirror buildWhereClause's IS [NOT] NULL handling in db/query.ts.
148
148
  if (value === null) {
149
149
  whereSql.push(`${colSql(field)} IS NULL`);
150
150
  }
@@ -175,7 +175,7 @@ function applyScreenFilter(table, colSql, whereSql, params, f) {
175
175
  }
176
176
  }
177
177
  else {
178
- // Blind-index OR rewrite (#818), lock-step with buildWhereClause
178
+ // Blind-index OR rewrite, lock-step with buildWhereClause
179
179
  // in db/query.ts — equality on lookupable fields matches the
180
180
  // plaintext arm OR the HMAC arm.
181
181
  const bidxKey = configuredBlindIndexKey();
@@ -225,7 +225,7 @@ function deriveParentNarrowTypes(entity, payload) {
225
225
  }
226
226
  return narrowed;
227
227
  }
228
- // fw#2660 — above this many target-row matches, drop the reference clause
228
+ // Above this many target-row matches, drop the reference clause
229
229
  // entirely instead of truncating: an arbitrary 200-row slice of "which
230
230
  // targets match" would silently hide rows a full-cardinality match would
231
231
  // have found. Native text search on the entity's own fields still applies.
@@ -237,7 +237,7 @@ const MAX_REFERENCE_SEARCH_IDS = 200;
237
237
  // treated as complete (a cap of N+1 could never be observed).
238
238
  const MAX_SEARCH_CANDIDATE_IDS = 1000;
239
239
  // Registry-declared `searchable` reference fields, plus the implicit
240
- // tenantId→tenant.name row-meta reference (fw#2660) — both resolve a text
240
+ // tenantId→tenant.name row-meta reference — both resolve a text
241
241
  // search term against a target row's label instead of the raw FK column.
242
242
  function collectReferenceSearchDescriptors(table, referenceSearch, dbMode) {
243
243
  if (!referenceSearch)
@@ -264,9 +264,9 @@ function isSoftDeletedTargetHidden(targetEntity, targetTable) {
264
264
  return targetEntity.softDelete === true && targetTable["isDeleted"] !== undefined;
265
265
  }
266
266
  // Tenant-scoped ILIKE lookup against one reference target — never raw/
267
- // unscoped SQL, a match in a foreign tenant must never surface a row here
268
- // (fw#2660's hard constraint). Mirrors the main table's own tenant filter
269
- // (see the `table["tenantId"]` check further down in list()).
267
+ // unscoped SQL, a match in a foreign tenant must never surface a row here.
268
+ // Mirrors the main table's own tenant filter (see the `table["tenantId"]`
269
+ // check further down in list()).
270
270
  async function resolveReferenceMatchesViaIlike(descriptor, searchTerm, targetEntity, targetTable, targetTableName, user, db) {
271
271
  // The label must not leak through match counts: a viewer who cannot read the
272
272
  // target row or its label field would otherwise probe names by searching.
@@ -388,14 +388,14 @@ function referenceTargetTable(name, entity) {
388
388
  // cursor carries the label that was ordered by instead of the FK's UUID.
389
389
  const SORT_LABEL_ALIAS = "__kumiko_sort_label";
390
390
  const STREAM_VERSION_ALIAS = "__streamVersion";
391
- // fw#2741 — resolve a `sortable: true` reference field's label inline for
391
+ // Resolve a `sortable: true` reference field's label inline for
392
392
  // ORDER BY. A correlated scalar subquery, not a join: the result set keeps one
393
393
  // row per entity row, the read model stays untouched, and the lookup rides the
394
394
  // target's primary key. Tenant-scoped exactly like the search path — a foreign
395
395
  // tenant's label must never influence this tenant's ordering.
396
396
  // undefined means "cannot order by this label", which drops the sort to plain
397
397
  // id order; falling back to the UUID column would look deliberate to the user
398
- // while being arbitrary, which the issue calls worse than no sorting.
398
+ // while being arbitrary, which is worse than no sorting.
399
399
  function buildReferenceSortExpr(outerTableName, ownColumnSql, descriptor, resolveEntity, db, user) {
400
400
  const targetEntity = resolveEntity(descriptor.targetEntityName);
401
401
  if (targetEntity === undefined) {
@@ -479,20 +479,19 @@ export function createReadVerbs(ctx) {
479
479
  const colSql = (field) => `"${physicalCol(field)}"`;
480
480
  // Field-level read access gates filtering and sorting the same way it
481
481
  // gates the projected row: a caller who cannot read a field must not be
482
- // able to probe its values through result counts or ordering (fw#2629).
483
- // Hoisted above the search block (fw#2660) — a searchable-reference
484
- // clause is the same probe surface as a filter/sort and must be gated
485
- // the same way, before the reference-match query even runs.
482
+ // able to probe its values through result counts or ordering.
483
+ // Hoisted above the search block — a searchable-reference clause is the
484
+ // same probe surface as a filter/sort and must be gated the same way,
485
+ // before the reference-match query even runs.
486
486
  const fieldReadClause = (field) => buildOwnershipClause(user, normalizeAccessEntry(entity.fields[field]?.access?.read), table);
487
- // Build-Time options.searchAdapter gewinnt; runtime-Override ist
488
- // Fallback für die defaultEntityQueryHandler-Pipe (die nutzt den
489
- // ctx.searchAdapter erst zur Laufzeit weil createEventStoreExecutor
490
- // beim Definition-Time noch keinen Server-Context hat).
487
+ // The build-time options.searchAdapter takes precedence; use the runtime override
488
+ // as a fallback because defaultEntityQueryHandler receives the search adapter only
489
+ // at runtime, when createEventStoreExecutor has no server context yet.
491
490
  const effectiveSearchAdapter = searchAdapter ?? runtimeOptions?.searchAdapter;
492
491
  let searchTruncated = false;
493
492
  if (payload.search) {
494
493
  const searchTerm = payload.search;
495
- // #2032 — a search term with no adapter wired must fail loud, not
494
+ // A search term with no adapter wired must fail loud, not
496
495
  // silently return the unfiltered list dressed up as a search result.
497
496
  if (!effectiveSearchAdapter) {
498
497
  throw new UnprocessableError("search_adapter_not_wired", {
@@ -504,7 +503,7 @@ export function createReadVerbs(ctx) {
504
503
  }
505
504
  // Same choke-point as writes/index: systemStream docs live under
506
505
  // SYSTEM_TENANT_ID; everything else under the session tenant — db.mode
507
- // must not invent a second predicate (kumiko-framework#2412).
506
+ // must not invent a second predicate.
508
507
  const searchTenantId = streamTenantFor(user);
509
508
  const results = await effectiveSearchAdapter.search(searchTenantId, searchTerm, {
510
509
  filterType: entityName,
@@ -512,14 +511,14 @@ export function createReadVerbs(ctx) {
512
511
  });
513
512
  searchTruncated = results.length >= MAX_SEARCH_CANDIDATE_IDS;
514
513
  filterIds = results.map((r) => r.entityId);
515
- // fw#2660 — union in reference-column label matches: a searchable
514
+ // Union in reference-column label matches: a searchable
516
515
  // reference field (or the implicit tenantId→tenant.name row-meta
517
516
  // reference) whose target row's labelField matches the search term
518
517
  // also counts as a hit on this entity, not just a raw text-field hit.
519
518
  const referenceSearch = runtimeOptions?.referenceSearch;
520
- // fw#2629 parity: a caller who cannot read the reference column itself
521
- // must not be able to infer its value by matching the target's label —
522
- // same probe surface as filtering/sorting on it directly.
519
+ // A caller who cannot read the reference column itself must not be able
520
+ // to infer its value by matching the target's label — same probe
521
+ // surface as filtering/sorting on it directly.
523
522
  const descriptors = collectReferenceSearchDescriptors(table, referenceSearch, db.mode).filter((d) => fieldReadClause(d.ownColumn).kind === "pass");
524
523
  const referenceMatches = referenceSearch
525
524
  ? (await Promise.all(descriptors.map((d) => resolveReferenceMatches(d, searchTerm, referenceSearch.resolveEntity, db, user, effectiveSearchAdapter)))).filter((m) => m !== undefined)
@@ -556,7 +555,7 @@ export function createReadVerbs(ctx) {
556
555
  ? payload.sort
557
556
  : undefined;
558
557
  const sortDescending = payload.sortDirection === "desc";
559
- // fw#2741 — a reference column stores a UUID, so it is never sorted by
558
+ // A reference column stores a UUID, so it is never sorted by
560
559
  // directly: either the field opted into `sortable` and we order by the
561
560
  // target row's label, or the sort is dropped to plain id order.
562
561
  let sortField;
@@ -36,7 +36,7 @@ async function projectEntityEvent(db, runner, event) {
36
36
  await runInSavepointIfSupported(runner, (sp) => runProjectionsForEvent(event, registry, sp));
37
37
  }
38
38
  // The five write verbs (create/update/delete/forget/restore) of the event-
39
- // store-executor. Split out of event-store-executor.ts (#1005, Welle 2) —
39
+ // store-executor. Split out of event-store-executor.ts —
40
40
  // behavior-preserving relocation, not a redesign: every closure below is
41
41
  // unchanged from the original, just relocated behind an explicit
42
42
  // ExecutorContext instead of capturing the factory's local scope directly.
@@ -61,10 +61,10 @@ function selectOptionNotAvailableFailure(entries) {
61
61
  }));
62
62
  }
63
63
  // preSave runs before ownership checks: authorization must evaluate the row
64
- // as it will actually be persisted, including hook-derived fields
65
- // (kumiko-framework#1672). A throwing hook is an app-author bug (bad
66
- // business rule, not a framework fault) — map it to a clean writeFailure
67
- // instead of letting it propagate as an internal_error 500.
64
+ // as it will actually be persisted, including hook-derived fields. A throwing
65
+ // hook is an app-author bug (bad business rule, not a framework fault) — map it
66
+ // to a clean writeFailure instead of letting it propagate as an internal_error
67
+ // 500.
68
68
  async function runPreSave(preSave, changes, previous, isNew, entityName, action, outputSchema) {
69
69
  if (!preSave)
70
70
  return { data: changes };
@@ -72,7 +72,7 @@ async function runPreSave(preSave, changes, previous, isNew, entityName, action,
72
72
  const hookResult = await preSave(changes, previous, isNew);
73
73
  // A hook that echoes `id`/`version` back (e.g. `{ ...changes, id: x }`)
74
74
  // must not leak them into the persisted row — aggregateId already comes
75
- // from generateId()/the loaded row, not from hook output (fw#1685).
75
+ // from generateId()/the loaded row, not from hook output.
76
76
  const { id: _hookId, version: _hookVersion, ...safe } = hookResult;
77
77
  // Hook output skips the handler's input validation yet goes straight into the projection.
78
78
  // Validated only, not replaced by the parse result: hooks may set fields in storage form.
@@ -245,12 +245,11 @@ export function createWriteVerbs(ctx) {
245
245
  // per declared field. Role-level was already checked by the
246
246
  // dispatcher; here we enforce ownership-rules against the new row.
247
247
  //
248
- // Which fields get checked is scoped to the pre-hook payload (fw#1685)
248
+ // Which fields get checked is scoped to the pre-hook payload
249
249
  // — a hook-derived field the user never submitted must not be
250
250
  // field-ownership-checked against the user. The rule for a checked
251
251
  // field is still evaluated against the full post-hook row (`data`) —
252
- // an ownership rule can reference a column only a hook populates
253
- // (kumiko-framework#1672).
252
+ // an ownership rule can reference a column only a hook populates.
254
253
  const fieldDeniedCreate = checkWriteFieldOwnership(entity, applyDefaults(payloadWithoutId), ownershipSubject(user, streamTenantId), undefined, data);
255
254
  if (fieldDeniedCreate) {
256
255
  return writeFailure(new UnprocessableError("ownership_denied", {
@@ -267,39 +266,37 @@ export function createWriteVerbs(ctx) {
267
266
  const unavailableOnCreate = selectOptionNotAvailableFailure(findUnavailableSelectOptions(entity.fields, data));
268
267
  if (unavailableOnCreate)
269
268
  return unavailableOnCreate;
270
- // Alle Compound-Types (locatedTimestamp, money, ...) gehen durch
271
- // dieselbe Pipeline. Caller schickt combined API-Form, Framework
272
- // speichert flat DB-Form. Siehe db/compound-types.ts.
273
- // subjectSource carries the freshly minted aggregateId: the create
274
- // payload has no id column, but a pii:true self-subject resolves from it.
269
+ // All compound types (locatedTimestamp, money, ...) use the same pipeline: the
270
+ // caller sends the combined API form, the framework stores the flat DB form (see
271
+ // db/compound-types.ts).
272
+ // subjectSource carries the freshly minted aggregateId: the create payload has no id
273
+ // column, but a pii:true self-subject resolves from it.
275
274
  const flatCreateData = flattenCompoundTypes(data, entity);
276
275
  const flatData = await encryptForStorage(flatCreateData, user, {
277
276
  subjectSource: { ...flatCreateData, id: aggregateId },
278
277
  keyTenantId: streamTenantId,
279
278
  });
280
- // 1. Append event (same TX as the projection write — both must succeed
281
- // or both roll back; the dispatcher wraps both in one transaction).
282
- // flatData is already table ciphertext for pii/encrypted fields, so
283
- // the immutable log never sees plaintext and replay reproduces the
284
- // row byte-identically (#967).
279
+ // 1. Append the event in the same transaction as the projection write — both must
280
+ // succeed or both roll back; the dispatcher wraps them in one transaction.
281
+ // flatData is already table ciphertext for pii/encrypted fields, so the immutable
282
+ // log never sees plaintext and replay reproduces the row byte-for-byte.
285
283
  //
286
- // `expectedVersion: 0` heißt: stream existiert noch nicht. Bei
287
- // deterministic-aggregate-id-Patterns (z.B. uuidv5(tenantId|naturalKey))
288
- // ist es legitim dass create kollidiert — selbe id, schon vorhandener
289
- // stream → version_conflict statt internal_error. Update hat den
290
- // selben catch (siehe line 493+).
284
+ // `expectedVersion: 0` means the stream does not yet exist. With deterministic
285
+ // aggregate-ID patterns (for example, uuidv5(tenantId|naturalKey)), a create can
286
+ // legitimately collide — the same ID has an existing stream — so return
287
+ // version_conflict instead of internal_error. Update uses the same catch.
291
288
  let event;
292
289
  try {
293
290
  // Savepoint-scoped: postgres.js poisons the WHOLE surrounding
294
291
  // begin() once any statement inside it errors, even if the JS error
295
- // is caught (kumiko-framework#1778) — a losing concurrent create's
296
- // unique-violation would otherwise abort the caller's outer
297
- // transaction and surface as internal_error at commit time instead
298
- // of the version_conflict this catch classifies. runInSavepointIfSupported
299
- // confines the failed INSERT to a nested scope that rolls back on
300
- // its own (same pattern as ctx.tryAppendEvent), and falls back to a
301
- // plain call when the runner is a bare pool connection with no active
302
- // transaction to poison (seeds/tests calling the executor directly).
292
+ // is caught — a losing concurrent create's unique-violation
293
+ // would otherwise abort the caller's outer transaction and surface as
294
+ // internal_error at commit time instead of the version_conflict this
295
+ // catch classifies. runInSavepointIfSupported confines the failed
296
+ // INSERT to a nested scope that rolls back on its own (same pattern as
297
+ // ctx.tryAppendEvent), and falls back to a plain call when the runner
298
+ // is a bare pool connection with no active transaction to poison
299
+ // (seeds/tests calling the executor directly).
303
300
  event = await runInSavepointIfSupported(runner, async (sp) => append(sp, {
304
301
  aggregateId,
305
302
  aggregateType: entityName,
@@ -325,22 +322,20 @@ export function createWriteVerbs(ctx) {
325
322
  }
326
323
  throw e;
327
324
  }
328
- // 2. Update projection via applyEntityEvent — derselbe Code-Pfad den
329
- // rebuildProjection für Replay nutzt, mit demselben StoredEvent →
330
- // Live==Rebuild by-construction (#967).
325
+ // 2. Update the projection through applyEntityEvent — the same code path used by
326
+ // rebuildProjection for replay, with the same StoredEvent, so live and rebuild
327
+ // produce the same result by construction.
331
328
  //
332
- // F8-Patch: app-level unique-violations (z.B. (tenantId, email)
333
- // auf User-Entity, (tenantId, slug) auf Article) werfen pg-23505
334
- // aus der projection-INSERT. Ohne den catch propagiert das als
335
- // unhandled exception → 500 internal_error. Map auf
336
- // UniqueViolationError 409 damit Designer/Frontend einen sauberen
337
- // "duplicate" zeigen können statt cryptic "internal server error".
338
- // Savepoint like the append above: an unclassified DB error out of the
339
- // projection INSERT would otherwise poison the enclosing tx (postgres-js
340
- // rejects the whole begin() once any statement in it failed), so a later
341
- // write sharing that tx — a sibling write after a failed nested one —
342
- // throws raw as a 500 instead of failing cleanly. The savepoint rolls the
343
- // failed INSERT back; the error still propagates and stays catchable.
329
+ // Application-level unique violations (for example, (tenantId, email) on a User
330
+ // entity or (tenantId, slug) on an Article) produce pg-23505 from the projection
331
+ // INSERT. Without this catch, the error propagates as an unhandled exception and
332
+ // becomes a 500 internal_error. Map it to UniqueViolationError 409 so the
333
+ // designer/frontend can show a clear "duplicate" message instead of a cryptic
334
+ // "internal server error".
335
+ // Savepoint like the append above: an unclassified DB error from the projection
336
+ // INSERT would otherwise poison the enclosing transaction (postgres-js rejects the
337
+ // whole begin() once any statement in it fails). The savepoint rolls back the failed
338
+ // INSERT while allowing the error to propagate and remain catchable.
344
339
  let result;
345
340
  try {
346
341
  result = await runInSavepointIfSupported(withExecutorManagedWrites(runner), async (sp) => applyEntityEvent(event, table, entity, sp));
@@ -355,8 +350,8 @@ export function createWriteVerbs(ctx) {
355
350
  return writeFailure(new InternalError({ message: "projection insert returned no row" }));
356
351
  }
357
352
  const row = result.row;
358
- // Read-Side Auto-Convert: DB-Form → API-combined-Form für alle
359
- // Compound-Types in einem Pass.
353
+ // Read-side conversion: DB form → combined API form for all
354
+ // compound types in one pass.
360
355
  const projection = await decryptForRead(rehydrateCompoundTypes(row, entity));
361
356
  if (entityCache && entityName) {
362
357
  await entityCache.del(streamTenantId ?? user.tenantId, entityName, aggregateId);
@@ -376,7 +371,7 @@ export function createWriteVerbs(ctx) {
376
371
  isNew: true,
377
372
  entityName,
378
373
  // Persisted event carries ciphertext by design — the caller-facing
379
- // echo must be plaintext like every other response field (#820).
374
+ // echo must be plaintext like every other response field.
380
375
  event: echoEvent,
381
376
  }),
382
377
  };
@@ -430,7 +425,7 @@ export function createWriteVerbs(ctx) {
430
425
  // touch on this specific row.
431
426
  //
432
427
  // Which fields get checked is scoped to `payload.changes` (the user's
433
- // actual submission), NOT `changes` (post-preSave-hook data, fw#1685)
428
+ // actual submission), NOT `changes` (post-preSave-hook data)
434
429
  // — a hook-derived field the user never submitted (e.g. a system hook
435
430
  // setting `assignedTo`) has no business being field-ownership-checked
436
431
  // against the *user*, and doing so rejects writes the user is fully
@@ -438,7 +433,7 @@ export function createWriteVerbs(ctx) {
438
433
  // already runs on this same pre-hook payload for consistency. The rule
439
434
  // for a checked field is still evaluated against the full post-hook
440
435
  // row (`changes` merged onto `previous`) — an ownership rule can
441
- // reference a column only a hook populates (kumiko-framework#1672).
436
+ // reference a column only a hook populates.
442
437
  const fieldDeniedUpdate = checkWriteFieldOwnership(entity, payload.changes, ownershipSubject(user, streamTenantId), previous, changes);
443
438
  if (fieldDeniedUpdate) {
444
439
  return writeFailure(new UnprocessableError("ownership_denied", {
@@ -497,16 +492,17 @@ export function createWriteVerbs(ctx) {
497
492
  }
498
493
  }
499
494
  try {
500
- // Compound-Types Auto-Convert (alle in einem Pass).
495
+ // Compound-type conversion in one pass.
501
496
  // subjectSource: partial changes may carry a pii field without its
502
- // ownerField — the merged row still names the subject.
503
- // skipUnchanged (#464) is opt-in: an identical resubmitted key is dropped before
504
- // encryption so pii/encrypted fields get no fresh ciphertext for no real change.
505
- // Direct callers (KEK rotation, #494 backfill) rely on the default to force a re-encrypt.
497
+ // ownerField — the merged row still identifies the subject.
498
+ // skipUnchanged is opt-in: an identical resubmitted key is dropped before
499
+ // encryption so pii/encrypted fields receive no fresh ciphertext when nothing
500
+ // actually changed.
501
+ // Direct callers (KEK rotation, backfill) rely on the default to force re-encryption.
506
502
  const submittedChanges = updateOptions?.skipUnchanged
507
503
  ? Object.fromEntries(Object.entries(changes).filter(([key, value]) => !isUnchangedValue(value, previous[key])))
508
504
  : changes;
509
- // fw#3670: crypto-shredding follows the current owner, so personal
505
+ // Crypto-shredding follows the current owner, so personal
510
506
  // fields not in this update are re-written under the new owner's key.
511
507
  // Owner -> absent is not re-keyed; the value stays under the old owner.
512
508
  const changesToPersist = {
@@ -529,8 +525,8 @@ export function createWriteVerbs(ctx) {
529
525
  // re-encrypt it before it's persisted so plaintext of pii/encrypted
530
526
  // fields doesn't land in the immutable log (flatChanges is already
531
527
  // ciphertext from encryptForStorage above).
532
- // Owner chain for the ownerless-then-assigned row: previous owner, then the
533
- // owner this event sets, then whenAbsent (fw#3654).
528
+ // Owner chain for the ownerless-then-assigned row: previous owner, then
529
+ // the owner this event sets, then whenAbsent.
534
530
  const encryptedPrevious = await encryptForStorage(previous, user, {
535
531
  keyTenantId: streamTenantId,
536
532
  ownerFallback: submittedChanges,
@@ -552,17 +548,16 @@ export function createWriteVerbs(ctx) {
552
548
  },
553
549
  metadata: buildEventMetadata(user),
554
550
  }));
555
- // Live==Rebuild via applyEntityEvent mit demselben StoredEvent —
556
- // apply liest nur `changes`, und die sind live wie im Replay
557
- // identischer Ciphertext (#967).
551
+ // Live and rebuild use applyEntityEvent with the same StoredEvent —
552
+ // apply reads only `changes`, which contain identical ciphertext in live writes
553
+ // and replays.
558
554
  //
559
- // F8-Patch: dasselbe unique-violation-handling wie im create-Pfad
560
- // — ein update das einen unique-Index verletzt (z.B. email-update
561
- // auf einen schon-existierenden Wert) wird mit 409 unique_violation
562
- // statt 500 internal_error rückgemeldet.
563
- // Savepoint like the create path: a raw DB error out of the projection
564
- // UPDATE poisons the enclosing tx otherwise, so a sibling write after a
565
- // failed nested one throws 500 instead of failing cleanly.
555
+ // An update that violates a unique index (for example, an email update to an
556
+ // already-existing value) is reported as 409 unique_violation instead of 500
557
+ // internal_error.
558
+ // Savepoint like the create path: a raw DB error from the projection UPDATE would
559
+ // otherwise poison the enclosing transaction, so a sibling write after a failed
560
+ // nested one would throw 500 instead of failing cleanly.
566
561
  let result;
567
562
  try {
568
563
  result = await runInSavepointIfSupported(withExecutorManagedWrites(runner), async (sp) => applyEntityEvent(event, table, entity, sp));