@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
@@ -1,11 +1,11 @@
1
1
  import { asRawClient, unsafeReadRetrying } from "../query.js";
2
2
  /** Escape hatch for caller-built SQL that may write (or lock). No closed-connection retry —
3
- * retrying an ambiguous write risks double-apply (#1358). Prefer {@link executeRawQueryRead}
3
+ * retrying an ambiguous write risks double-apply. Prefer {@link executeRawQueryRead}
4
4
  * for SELECT-only paths. */
5
5
  export async function executeRawQuery(db, sqlText, params = []) {
6
6
  return await asRawClient(db).unsafe(sqlText, params);
7
7
  }
8
- /** SELECT-only escape hatch with the #1163 closed-connection retry. Do not pass
8
+ /** SELECT-only escape hatch with the closed-connection retry. Do not pass
9
9
  * INSERT/UPDATE/DELETE — retry re-executes the statement. */
10
10
  // @wrapper-known semantic-alias
11
11
  export async function executeRawQueryRead(db, sqlText, params = []) {
@@ -64,10 +64,10 @@ export function rebuildMetaOrThrow(table, projectionName) {
64
64
  return meta;
65
65
  }
66
66
  // Fence against a rebuild running with a registry that does not match the
67
- // migrated live schema (#835): during a rolling deploy, a pod still running
67
+ // migrated live schema: during a rolling deploy, a pod still running
68
68
  // the previous build can pick up an async rebuild job; its shadow — built from
69
69
  // the stale EntityTableMeta — would swap away a freshly-migrated column
70
- // (recurrence class of #494). Compares COLUMN NAMES only; a type-/nullability-
70
+ // (a recurring failure). Compares COLUMN NAMES only; a type-/nullability-
71
71
  // only drift passes (schema regression, not data loss — the boot gate of the
72
72
  // next deploy catches it). A missing live table is fine: nothing to wipe.
73
73
  // Must run BEFORE buildShadowTable; columnNamesOf pins table_schema='public',
@@ -93,7 +93,7 @@ export async function assertLiveColumnsMatchMeta(db, meta, projectionName) {
93
93
  "Rebuilding would swap away the difference. Likely cause: this pod runs a build whose registry is behind (or ahead of) the applied migrations — rolling deploy in progress? — or DDL was applied by hand. " +
94
94
  "Rebuild aborted; retry from a pod whose code matches the migrated schema.");
95
95
  }
96
- // EntityTableMeta carries no RLS/policies, so the swap would silently drop them (#2907).
96
+ // EntityTableMeta carries no RLS/policies, so the swap would silently drop them.
97
97
  export async function assertLiveTableHasNoRowLevelSecurity(tx, tableName, projectionName) {
98
98
  const rows = await asRawClient(tx).unsafe(`SELECT c.relrowsecurity AS rls_enabled, c.relforcerowsecurity AS rls_forced,
99
99
  (SELECT count(*)::int FROM pg_policy p WHERE p.polrelid = c.oid) AS policy_count
@@ -158,7 +158,7 @@ const UNREACHABLE_SAMPLE_LIMIT = 20;
158
158
  // Runs INSIDE the rebuild tx, under the fence, before swapShadowIntoLive. A live
159
159
  // row whose aggregate id has NO event in the projection's source streams is
160
160
  // UNREACHABLE: no replay can ever reconstruct it, so the swap would silently
161
- // drop it. That is the #498 ghost — a row direct-inserted without ever emitting
161
+ // drop it. That is a ghost row direct-inserted without ever emitting
162
162
  // a .created event. The static CI guard cannot see it in data that already
163
163
  // exists in production, or on table identifiers it couldn't resolve; this
164
164
  // catches it at cutover and aborts (tx rolls back, live untouched).
@@ -169,14 +169,14 @@ const UNREACHABLE_SAMPLE_LIMIT = 20;
169
169
  // - a blind-index column recomputed to NULL after the subject's key is
170
170
  // shredded (GDPR erase) — the NULL is the intended end state;
171
171
  // - a `sensitive` column stripped from the event log by design;
172
- // - an archived stream that stops replaying (fw#832) — the row's wipe is the
172
+ // - an archived stream that stops replaying — the row's wipe is the
173
173
  // intended tombstone behavior, reported via backfill's `failed` list;
174
174
  // - a legacy column direct-written before its handler emitted events, healed
175
- // by the #494 backfill-then-rebuild flow.
175
+ // by the backfill-then-rebuild flow.
176
176
  // Checking event existence INCLUDING archived streams leaves every one of them
177
177
  // alone: those rows all have a real event, so they are not ghosts. Column-level
178
- // drift is a SEPARATE, non-blocking check — see countColumnDrift below (#916,
179
- // resolves the #722 open question: observe, don't block).
178
+ // drift is a SEPARATE, non-blocking check — see countColumnDrift below,
179
+ // which resolves the open question: observe, don't block.
180
180
  //
181
181
  // Implicit projections only (caller-gated). aggregate_id and the entity id are
182
182
  // both uuid, so the anti-join probes the events index without a cast.
@@ -298,8 +298,8 @@ async function assertBlindIndexKeyMatchesLive(raw, tableName, quotedTable, bidxC
298
298
  `untouched.`);
299
299
  }
300
300
  // The bidx column is schema-driven, not key-driven: it exists NULL in a
301
- // plaintext install and in the fw#1610 case (KMS configured, no index key),
302
- // both of which are correct as-is. Only a POPULATED column with no key
301
+ // plaintext install and in the case where KMS is configured but no index key
302
+ // is present, both of which are correct as-is. Only a POPULATED column with no key
303
303
  // configured in THIS process means the rebuild is about to overwrite proof
304
304
  // of a real index with NULL — that's the one provable data-loss case.
305
305
  export async function assertNoBlindIndexLoss(tx, tableName, meta, projectionName) {
@@ -331,29 +331,29 @@ export async function assertNoBlindIndexLoss(tx, tableName, meta, projectionName
331
331
  // live-vs-shadow divergence. A blind-index column (`<field>_bidx`) is
332
332
  // recomputed to NULL on GDPR key-shredding; the NULL is the intended end
333
333
  // state, not drift. Everything else that legitimately diverges (archived
334
- // streams, #494 backfill) either never reaches this comparison (archived rows
334
+ // streams, backfill) either never reaches this comparison (archived rows
335
335
  // are absent from the shadow entirely, see swapShadowIntoLive) or IS real
336
- // column drift that the #494 backfill-then-rebuild flow relies on replay to
336
+ // column drift that the backfill-then-rebuild flow relies on replay to
337
337
  // heal — reporting it (without blocking) is correct, not a false positive.
338
338
  const COLUMN_DRIFT_SAMPLE_LIMIT = 20;
339
339
  // Runs INSIDE the rebuild tx, in the same slot as assertNoUnreachableLiveRows
340
340
  // (after replay settles, before swapShadowIntoLive). Non-blocking counterpart
341
341
  // to the ghost-row guard: reports live rows whose column values differ from
342
- // the freshly-replayed shadow, WITHOUT aborting the swap (#916, resolves the
343
- // #722 open question in favor of observe-not-block).
342
+ // the freshly-replayed shadow, WITHOUT aborting the swap (it resolves the
343
+ // open question in favor of observe-not-block).
344
344
  //
345
345
  // Why non-blocking: a legacy column direct-written before its handler emitted
346
- // events (#494) diverges from replay by design — that divergence is exactly
346
+ // events diverges from replay by design — that divergence is exactly
347
347
  // what the backfill-then-rebuild flow relies on replay to heal. Failing hard
348
348
  // here would make rebuild mutually exclusive with that shipped healing path.
349
- // There is no reliable metadata to distinguish "#494 healing in progress" from
349
+ // There is no reliable metadata to distinguish "healing in progress" from
350
350
  // "someone else corrupted this row" short of an open-ended per-column policy
351
351
  // blocklist — wrong-by-default whenever a class is missed. So: surface it,
352
352
  // don't police it. The caller logs the result; ops decides.
353
353
  //
354
- // Caveat: sensitive CUSTOM fields still diverge until #972 (Subject-DEK
355
- // design) — regular sensitive fields carry event-payload ciphertext parity
356
- // post-#973 and don't drift. Both are reported the same as any other column
354
+ // Caveat: sensitive CUSTOM fields still diverge until the Subject-DEK
355
+ // design — regular sensitive fields carry event-payload ciphertext parity
356
+ // post-design and don't drift. Both are reported the same as any other column
357
357
  // drift; this is deliberate (see module comment above), not an oversight.
358
358
  //
359
359
  // Relies on assertLiveColumnsMatchMeta having already run: live/shadow/meta
@@ -24,7 +24,7 @@ export declare function hasBegin(client: RawClient): client is RawClientWithBegi
24
24
  export declare function asRawClient(db: unknown): RawClient;
25
25
  export declare function runnerCanBeginTransaction(db: unknown): boolean;
26
26
  export declare function runInSavepoint<T>(tx: ExecutorManagedRunner, fn: (sp: ExecutorManagedRunner) => Promise<T>): Promise<T>;
27
- export declare function runInSavepoint<T>(tx: unknown, fn: (sp: unknown) => Promise<T>): Promise<T>;
27
+ export declare function runInSavepoint<T>(tx: unknown, fn: (sp: DbRunner) => Promise<T>): Promise<T>;
28
28
  export declare function runInSavepointIfSupported<T>(db: ExecutorManagedRunner, fn: (sp: ExecutorManagedRunner) => Promise<T>): Promise<T>;
29
29
  export declare function runInSavepointIfSupported<T>(db: unknown, fn: (sp: DbRunner) => Promise<T>): Promise<T>;
30
30
  export type AnyDb = DbRunner | unknown;
package/dist/db/query.js CHANGED
@@ -13,7 +13,7 @@
13
13
  //
14
14
  // `table` is an EntityTableMeta; the legacy table objects carrying it under a Symbol are accepted too.
15
15
  // Static import (not getTemporal()): coercion works without an installed
16
- // global and returns the same Temporal classes as the rest of the repo (#1480).
16
+ // global and returns the same Temporal classes as the rest of the repo.
17
17
  import { Temporal } from "@cosmicdrift/kumiko-types/temporal";
18
18
  import { isOneOf, propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
19
19
  import { requestContext } from "../api/request-context.js";
@@ -279,7 +279,7 @@ function isTemporalPlainDateTimeLike(v) {
279
279
  "toPlainDate" in v &&
280
280
  typeof v.toPlainDate === "function");
281
281
  }
282
- // Verified empirically (kumiko-framework#1924): a `date`
282
+ // Verified empirically: a `date`
283
283
  // column comes back as a native JS Date, but ALWAYS anchored at UTC
284
284
  // midnight regardless of process TZ — epochMilliseconds is stable across
285
285
  // zones, only the local wall-clock read of that Date would drift the day
@@ -339,7 +339,7 @@ function coerceDriverRow(row, info) {
339
339
  const out = {};
340
340
  let changed = false;
341
341
  for (const key of Object.keys(row)) {
342
- // Blind-index columns (#818) are pure lookup artifacts — the
342
+ // Blind-index columns are pure lookup artifacts — the
343
343
  // deterministic HMAC must never leak into caller/API responses.
344
344
  if ((key.endsWith("_bidx") || key.endsWith("Bidx")) && info.hasColumn(key)) {
345
345
  changed = true;
@@ -420,7 +420,7 @@ function isSqlExpression(v) {
420
420
  // as-is — no Instant detour. Postgres parses a DATE literal without ever
421
421
  // consulting the session timezone, so this is TZ-independent by construction
422
422
  // (verified empirically — see plainDateFromDriver above for the read side).
423
- // Compat cushion: pre-#1924 code (this field used to be an Instant) may still
423
+ // Compat cushion: code (this field used to be an Instant) may still
424
424
  // hand a Temporal.Instant into a where-filter or write on a `date` column —
425
425
  // same UTC anchor as plainDateFromDriver's read side, so a filter built with
426
426
  // `Temporal.Now.instant()` keeps working instead of binding a raw Instant the
@@ -515,7 +515,7 @@ function buildWhereClause(info, where, startIndex, qualifier) {
515
515
  // multiSelect (and any other jsonb-array-of-scalars column) stores an
516
516
  // array — a filter value is one option, not the whole array, so eq/ne/in
517
517
  // must check array containment (`@>`) instead of scalar `=`/`<>`/`IN`
518
- // against the jsonb column, which Postgres rejects outright (fw#2490).
518
+ // against the jsonb column, which Postgres rejects outright.
519
519
  // Mirrors event-store-executor-read.ts's applyFilter — keep in lock-step.
520
520
  function jsonbContainsAny(col, candidates) {
521
521
  if (candidates.length === 0)
@@ -703,7 +703,7 @@ function buildWhereClause(info, where, startIndex, qualifier) {
703
703
  conditions.push(`${ref(col)} = ${p.literal}`);
704
704
  }
705
705
  else {
706
- // Blind-index OR rewrite (#818): if the column has a bidx
706
+ // Blind-index OR rewrite: if the column has a bidx
707
707
  // counterpart (suffix convention `<col>_bidx`, framework-reserved)
708
708
  // and a key is configured, equality matches both arms —
709
709
  // plaintext legacy rows via the plaintext arm, encrypted rows
@@ -1,20 +1,18 @@
1
- // Rebuild-Marker für den drizzle-freien Migrations-Pfad.
1
+ // Rebuild marker for the drizzle-free migration path.
2
2
  //
3
- // `kumiko schema generate` schreibt neben jedes `NNNN_<name>.sql` einen
4
- // Sibling-Marker `NNNN_<name>.rebuild.json`, der die in dieser Migration
5
- // geänderten/neu angelegten Tabellen listet. `kumiko schema apply` liest den
6
- // Marker für jede frisch applizierte Migration und rebuildet die betroffenen
7
- // Projektionen.
3
+ // `kumiko schema generate` writes a sibling marker
4
+ // `NNNN_<name>.rebuild.json` next to each `NNNN_<name>.sql`, listing the tables
5
+ // changed or created by that migration. `kumiko schema apply` reads the marker
6
+ // for each newly applied migration and rebuilds the affected projections.
8
7
  //
9
- // Bewusst nur **Tabellennamen** (kein Projection-Name): der Generator ist
10
- // registry-frei (kennt die App-Projektionen nicht). Die Auflösung
11
- // Tabelle→Projection passiert app-seitig beim Apply via
12
- // `buildProjectionTableIndex(registry)`. Tabellen ohne zugehörige Projektion
13
- // werden dort einfach übersprungen.
8
+ // Deliberately stores only **table names** (not projection names): the generator
9
+ // is registry-free and does not know the app's projections. The table-to-
10
+ // projection resolution happens in the app during apply via
11
+ // `buildProjectionTableIndex(registry)`. Tables without a corresponding
12
+ // projection are simply skipped there.
14
13
  //
15
- // Marker werden zum generate-Zeitpunkt aus dem strukturierten `SchemaDiff`
16
- // geschrieben — nicht beim Apply aus dem (ggf. hand-editierten) SQL
17
- // re-derived.
14
+ // Markers are written at generate time from the structured `SchemaDiff`, rather
15
+ // than being re-derived during apply from the possibly hand-edited SQL.
18
16
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
19
17
  import { join } from "node:path";
20
18
  import { managedChangeRequiresRecreate } from "./migrate-generator.js";
@@ -29,12 +27,12 @@ function markerPathFor(migrationsDir, migrationId) {
29
27
  // (managedChangeRequiresRecreate: drop/NOT-NULL-w/o-default/unique-index/type-
30
28
  // or nullability-change) — then the table is emptied and must be re-derived from
31
29
  // events. A pure additive nullable column is an in-place `ADD COLUMN` ALTER that
32
- // already brings the table to the target state (same logic #181 applied to
30
+ // already brings the table to the target state (same logic applied to
33
31
  // index-/default-only changes). Rebuilding it anyway is wasted replay AND — the
34
32
  // bug this closes — a co-triggered rebuild can drop the fresh column (rebuild
35
33
  // runs from the rebuilding process's registry meta; on a rolling deploy an older
36
34
  // pod's meta lacks the column → shadow-swap wipes it → phantom migration + boot-
37
- // drift crash; see 0008_add_pending_deletion_request_id / #494 / #835).
35
+ // drift crash).
38
36
  //
39
37
  // If a NEW additive column genuinely needs value-backfill from historical events
40
38
  // (a column DERIVED from existing event fields), opt in explicitly by hand-adding
@@ -60,18 +58,19 @@ export function rebuildTablesFromDiff(diff) {
60
58
  }
61
59
  return [...names].sort();
62
60
  }
63
- // Schreibt `<sqlFilename ohne .sql>.rebuild.json`. Leere Tabellen-Liste →
64
- // kein Marker (z.B. reine Drop-Migration).
61
+ // Writes `<sqlFilename without .sql>.rebuild.json`. An empty table list writes no
62
+ // marker (for example, a drop-only migration).
65
63
  export function writeRebuildMarker(migrationsDir, sqlFilename, tables) {
66
- // skip: leere Tabellen-Liste → kein Marker (z.B. reine Drop-Migration).
64
+ // skip: empty table list means no marker is written (for example, for a
65
+ // migration that only drops tables).
67
66
  if (tables.length === 0)
68
67
  return;
69
68
  const migrationId = sqlFilename.replace(/\.sql$/, "");
70
69
  const marker = { version: MARKER_VERSION, tables };
71
70
  writeFileSync(markerPathFor(migrationsDir, migrationId), `${JSON.stringify(marker, null, 2)}\n`);
72
71
  }
73
- // Liest die Tabellen-Liste für eine applizierte Migration. Kein Marker /
74
- // kaputtes File → leere Liste (Migration ohne Projection-Impact).
72
+ // Reads the table list for an applied migration. A missing or malformed file
73
+ // yields an empty list (the migration has no projection impact).
75
74
  export function readRebuildMarker(migrationsDir, migrationId) {
76
75
  const path = markerPathFor(migrationsDir, migrationId);
77
76
  if (!existsSync(path))
@@ -70,7 +70,7 @@ function parseColumnNames(body) {
70
70
  // through to the fail-loud check as "unparsed". RENAME TO / RENAME COLUMN
71
71
  // are not listed here: they change identity and are applied explicitly.
72
72
  const SHAPE_NEUTRAL_ALTER_CLAUSE_RE = new RegExp([
73
- `ALTER COLUMN\\s+${IDENT}\\s+TYPE\\b`, // #1085 int/bigint-catchup fixes
73
+ `ALTER COLUMN\\s+${IDENT}\\s+TYPE\\b`, // int/bigint-catchup fixes
74
74
  `ALTER COLUMN\\s+${IDENT}\\s+(SET|DROP)\\s+NOT NULL\\b`,
75
75
  `ALTER COLUMN\\s+${IDENT}\\s+(SET DEFAULT\\b|DROP DEFAULT\\b)`,
76
76
  `(ADD|DROP)\\s+CONSTRAINT\\s+${IDENT}`,
@@ -269,7 +269,7 @@ function applyStatement(schema, statement, context) {
269
269
  // Reuses the real runner's file-discovery + statement-splitting
270
270
  // (loadMigrationsFromDir) instead of a second copy, so any future change to
271
271
  // sub-directory handling, numeric sort order, or the .sql filter can't
272
- // silently drift between the runner and this replay (#1522/9).
272
+ // silently drift between the runner and this replay.
273
273
  export function replayMigrationsDir(migrationsDir) {
274
274
  const schema = new Map();
275
275
  const migrations = loadMigrationsFromDir(migrationsDir, expandDestructiveMarkers);
@@ -26,11 +26,10 @@ function resolveUnsafeClient(db) {
26
26
  // await unsafePushTables(db, { events: eventsTable });
27
27
  export async function tableExists(db, fullyQualifiedName) {
28
28
  const unsafe = resolveUnsafeClient(db);
29
- // quote_ident-Round-trip auf SQL-Seite: ohne Quotes folded postgres
30
- // unquoted identifier case-insensitiv (myWidget → mywidget), während die
31
- // generierte DDL den Namen via quoteIdent("myWidget") → "myWidget" case-
32
- // preserved schreibt. quote_ident sorgt für identische Quotierung beidseits.
33
- // Schema-qualifizierte Namen (`public.events`) werden per Split einzeln quotet.
29
+ // SQL-side quote_ident round-trip: unquoted PostgreSQL identifiers are folded case-insensitively
30
+ // (`myWidget` → `mywidget`), while generated DDL preserves the case via
31
+ // `quoteIdent("myWidget")` → `"myWidget"`. quote_ident keeps both sides quoted identically.
32
+ // Schema-qualified names (`public.events`) are quoted one component at a time after splitting.
34
33
  const dotIdx = fullyQualifiedName.indexOf(".");
35
34
  const [sql, params] = dotIdx >= 0
36
35
  ? [
@@ -196,8 +196,7 @@ export type EntityRow<E extends EntityDefinition = EntityDefinition> = string ex
196
196
  export type { ExecutorManagedRunner, ExecutorManagedWriteCapability, Writable, WritableSchemaTable, WritableTable, } from "@cosmicdrift/kumiko-types/executor-brand";
197
197
  export type { EntityTenancy, TenancyBrand } from "@cosmicdrift/kumiko-types/tenancy-brand";
198
198
  type EntityTableTenancy<E extends EntityDefinition> = [NonNullable<E["tenancy"]>] extends [never] ? "tenant" : [NonNullable<E["tenancy"]>] extends ["global"] ? "global" : "tenant";
199
- export type EntityTable<E extends EntityDefinition = EntityDefinition> = TableColumns<// biome-ignore lint/suspicious/noExplicitAny: drizzle's internal table-config stays generic; we layer typed columns on top via the intersection below.
200
- any> & BaseColumnsType<E> & SoftDeleteColumnsType & ColumnsForEntity<E["fields"]> & TenancyBrand<EntityTableTenancy<E>>;
199
+ export type EntityTable<E extends EntityDefinition = EntityDefinition> = TableColumns & BaseColumnsType<E> & SoftDeleteColumnsType & ColumnsForEntity<E["fields"]> & TenancyBrand<EntityTableTenancy<E>>;
201
200
  export declare function buildBaseColumns(softDelete: boolean, idType?: "serial" | "uuid"): {
202
201
  id: ColumnBuilder<string> | ColumnBuilder<number>;
203
202
  tenantId: ColumnBuilder<string>;
@@ -16,29 +16,28 @@ export function physicalColumnName(table, field) {
16
16
  // Returns column(s) for a field. Most fields return a single entry,
17
17
  // money returns two (amount + currency).
18
18
  //
19
- // `required: true` auf einem FieldDefinition mappt **immer** auf .notNull()
20
- // in der DB-Spalte. Die alte Implementation hat das nur für reference-
21
- // fields gemacht — text/select/number/etc. waren stillschweigend nullable
22
- // in der DB, auch wenn der API-Validator required erzwungen hat. Folge:
23
- // hand-written PgTable-Definitionen mussten daneben mit .notNull() pflegen,
24
- // was zu Doppel-Definitionen + Schema-Drift führte. Jetzt ist r.entity
25
- // die einzige Wahrheit.
19
+ // `required: true` on a FieldDefinition always maps to .notNull() in the
20
+ // database column. Previously this applied only to reference fields, while
21
+ // text/select/number/etc. remained silently nullable even when the API
22
+ // validator enforced required. That forced hand-written PgTable definitions
23
+ // to add .notNull(), causing duplicate definitions and schema drift. Now
24
+ // r.entity is the single source of truth.
26
25
  function fieldToColumns(name, field, entity) {
27
26
  const snakeName = toSnakeCase(name);
28
27
  switch (field.type) {
29
28
  case "text":
30
29
  case "longText": {
31
- // Beide mappen auf PG `text` (unbounded). Unterschied lebt nur
32
- // im Type-Layer: longText hat kein sortable/searchable/filterable
33
- // (Sprint 5b vorab). Reihenfolge default() VOR notNull(): drizzle's
34
- // column-builder chained beides; ohne default() hat die generierte
35
- // SQL keinen DEFAULT-clause (bricht ALTER TABLE ADD COLUMN auf
36
- // existing rows). longText hat heute kein default-feld im type,
37
- // aber der check `field.default !== undefined` ist defensive.
30
+ // Both map to PG `text` (unbounded). The difference exists only in the
31
+ // type layer: longText has no sortable/searchable/filterable support.
32
+ // The order default() before notNull() matters: the column builder
33
+ // chains both; without default(), the generated SQL has no DEFAULT
34
+ // clause, which breaks ALTER TABLE ADD COLUMN on existing rows.
35
+ // longText currently has no default field in its type, but checking
36
+ // `field.default !== undefined` is defensive.
38
37
  const base = text(snakeName);
39
38
  const withDefault = "default" in field && field.default !== undefined ? base.default(field.default) : base;
40
39
  const mainCol = { [name]: field.required ? withDefault.notNull() : withDefault };
41
- // lookupable → nullable HMAC-Blind-Index-Spalte (lock-step mit
40
+ // lookupable → nullable HMAC blind-index column (kept in lockstep with
42
41
  // fieldToColumnMeta in entity-table-meta.ts).
43
42
  if (field.type === "text" && field.lookupable === true) {
44
43
  return { ...mainCol, [`${name}Bidx`]: text(`${snakeName}_bidx`) };
@@ -54,23 +53,23 @@ function fieldToColumns(name, field, entity) {
54
53
  : boolean(snakeName),
55
54
  };
56
55
  case "select": {
57
- // default() durchreichen — entity-table-meta rendert deklarierte
58
- // defaults, dieser Builder MUSS lock-step bleiben (sonst trägt das
59
- // Meta am buildEntityTable-Objekt eine andere Spalte als die
60
- // Migration, #255-Follow-up-Befund).
56
+ // Pass default() through because entity-table-meta renders declared
57
+ // defaults. This builder must stay in lockstep; otherwise the metadata
58
+ // on the buildEntityTable object describes a different column than the
59
+ // migration.
61
60
  const base = text(snakeName);
62
61
  const col = field.default !== undefined ? base.default(field.default) : base;
63
62
  return { [name]: field.required ? col.notNull() : col };
64
63
  }
65
64
  case "multiSelect":
66
- // jsonb-Array<string> mit Default `[]` und immer NOT NULL.
65
+ // jsonb string array with default `[]`, always NOT NULL.
67
66
  //
68
- // Der `required`-Flag auf MultiSelectFieldDef wird hier bewusst
69
- // ignoriert: Mit Default `[]` ist das Feld strukturell never-null
70
- // (Insert ohne Wert → leeres Array, nicht NULL). Read-Side-Code
71
- // braucht keinen null-check, das ist API-Garantie. Wer "wirklich
72
- // null" will (= "Feld noch nie gesetzt") nutzt einen separaten
73
- // Status-Field oder ein optional-typed reference statt eines
67
+ // The `required` flag on MultiSelectFieldDef is intentionally ignored:
68
+ // with default `[]`, the field is structurally never null (an insert
69
+ // without a value produces an empty array, not NULL). Read-side code
70
+ // therefore needs no null check; this is an API guarantee. Code that
71
+ // needs actual null (meaning the field has never been set) should use
72
+ // a separate status field or an optional-typed reference instead of a
74
73
  // multi-select.
75
74
  return { [name]: jsonb(snakeName).default([]).notNull() };
76
75
  case "number": {
@@ -84,19 +83,18 @@ function fieldToColumns(name, field, entity) {
84
83
  return { [name]: field.required ? col.notNull() : col };
85
84
  }
86
85
  case "bigInt": {
87
- // 64-bit-Integer fuer Audit-Counter, Byte-Sizes, Cumulative-Sums.
88
- // mode:"number" liefert JS-`number` (sicher bis 2^53 ≈ 9 PB) statt
89
- // JS-`bigint` — JSON-serialisierbar, Frontend-tauglich. Wer >2^53
90
- // braucht (Astronomie-Astronomie), nutzt einen Text-Field mit
91
- // eigenem Codec.
86
+ // 64-bit integer for audit counters, byte sizes, and cumulative sums.
87
+ // mode:"number" produces a JS `number` (safe up to 2^53 ≈ 9 PB) rather
88
+ // than JS `bigint`, making it JSON-serializable and frontend-friendly.
89
+ // Values above 2^53 should use a text field with a custom codec.
92
90
  const base = bigint(snakeName, { mode: "number" });
93
91
  const col = field.default !== undefined ? base.default(field.default) : base;
94
92
  return { [name]: field.required ? col.notNull() : col };
95
93
  }
96
94
  case "reference":
97
- // Tier 2.7e-3: FK-Style UUID-Spalte. Multi-Mode (Tier 2.7e-Multi)
98
- // speichert UUIDs als jsonb-Array<string>. Single-Mode bleibt
99
- // klassische UUID-Spalte (NOT NULL nur bei required).
95
+ // FK-style UUID column. Multi-mode stores UUIDs as a jsonb string array;
96
+ // single-mode remains a conventional UUID column (NOT NULL only when
97
+ // required).
100
98
  if (field.multiple === true) {
101
99
  return { [name]: jsonb(snakeName).default([]).notNull() };
102
100
  }
@@ -104,11 +102,12 @@ function fieldToColumns(name, field, entity) {
104
102
  [name]: field.required ? uuid(snakeName).notNull() : uuid(snakeName),
105
103
  };
106
104
  case "money":
107
- // BIGINT storing the integer minor unit (cents for EUR, yen for JPY —
108
- // the currency column tells you which). INTEGER would cap at ~21 M EUR
109
- // which is too tight for B2B invoices, property values or balance
110
- // aggregates. BIGINT handles up to ~90 trillion EUR safely in JS.
111
- // Currency hat immer einen Default, ist also strukturell .notNull().
105
+ // BIGINT stores the integer minor unit (cents for EUR, yen for JPY —
106
+ // the currency column identifies which). INTEGER would cap at about
107
+ // 21 M EUR, which is too low for B2B invoices, property values, or
108
+ // balance aggregates. BIGINT safely handles up to about 90 trillion
109
+ // EUR in JS.
110
+ // Currency always has a default, so it is structurally .notNull().
112
111
  return {
113
112
  [name]: field.required ? moneyAmount(snakeName).notNull() : moneyAmount(snakeName),
114
113
  [`${name}Currency`]: text(`${snakeName}_currency`)
@@ -116,47 +115,47 @@ function fieldToColumns(name, field, entity) {
116
115
  .notNull(),
117
116
  };
118
117
  case "embedded":
119
- // jsonb mit default `{}` und immer NOT NULL — analog zu multiSelect.
120
- // `required` wird bewusst ignoriert weil der Default das Feld
121
- // strukturell never-null macht. Wer optional-embedded möchte (=
122
- // "Feld komplett weglassen können") modelliert das über ein
123
- // wrapper-feld mit boolean-flag oder discriminierte-union.
118
+ // jsonb with default `{}`, always NOT NULL, analogous to multiSelect.
119
+ // `required` is intentionally ignored because the default makes the
120
+ // field structurally never null. To make embedded data optional (so
121
+ // the field can be omitted entirely), model it with a wrapper field
122
+ // and boolean flag or a discriminated union.
124
123
  // multiple → the value is a list of rows, so the empty default is `[]`.
125
124
  return field.multiple === true
126
125
  ? { [name]: jsonb(snakeName).default([]).notNull() }
127
126
  : { [name]: jsonb(snakeName).default({}).notNull() };
128
127
  case "jsonb":
129
- // Free-form jsonb — keys nicht schema-validated. Default `{}`, NOT NULL
130
- // (analog zu embedded). Use-case: custom-fields-Bundle's host-entity-
131
- // `customFields`-Spalte (tenant-definierte dynamische keys).
128
+ // Free-form jsonb — keys are not schema-validated. Default `{}`, NOT NULL
129
+ // (analogous to embedded). Use case: the host entity's custom-fields
130
+ // bundle in the `customFields` column, whose keys are tenant-defined.
132
131
  return { [name]: jsonb(snakeName).default({}).notNull() };
133
132
  case "date": {
134
133
  // Real PG `date` — no time-of-day, no timezone. Temporal.PlainDate
135
134
  // end-to-end (see plainDate() in dialect.ts). Was aliased on instant()
136
- // = TIMESTAMPTZ until kumiko-framework#1924 (TZ-dependent round-trip).
135
+ // = TIMESTAMPTZ (TZ-dependent round-trip).
137
136
  const col = plainDate(snakeName);
138
137
  return { [name]: field.required ? col.notNull() : col };
139
138
  }
140
139
  case "timestamp": {
141
- // UTC-Instant — gespeichert als TIMESTAMPTZ in PG, gelesen/geschrieben
142
- // als Temporal.Instant via instant() customType (siehe dialect.ts).
143
- // Sprint F: Single-Mode-Welt — Caller-Code kennt nur Temporal.Instant,
144
- // nie JS-Date. Auch Vergleiche (lte/gt/orderBy) akzeptieren Instants
145
- // direkt, kein .toString()-Cast nötig.
140
+ // UTC instant — stored as TIMESTAMPTZ in PG and read/written as
141
+ // Temporal.Instant through the instant() customType (see dialect.ts).
142
+ // Callers use only Temporal.Instant, never JS Date. Comparisons
143
+ // (lte/gt/orderBy) also accept Instants directly, so no .toString()
144
+ // cast is needed.
146
145
  const col = instant(snakeName);
147
146
  return { [name]: field.required ? col.notNull() : col };
148
147
  }
149
148
  case "tz": {
150
- // IANA-Zonenname als TEXT — Validierung über Zod-Schema (kommt im
151
- // Validator-Schritt). Snake-Convention: `pickup_tz`.
149
+ // IANA time-zone name as TEXT — validated through the Zod schema in the
150
+ // validator step. Snake-case convention: `pickup_tz`.
152
151
  const col = text(snakeName);
153
152
  return { [name]: field.required ? col.notNull() : col };
154
153
  }
155
154
  case "locatedTimestamp": {
156
- // ZWEI Spalten als atomares Pair: <name>_utc TIMESTAMPTZ + <name>_tz TEXT.
157
- // _utc ist instant() (Temporal.Instant), _tz ist text (IANA-Name).
158
- // Auto-Convert (at+tz → utc beim Insert; utc+tz → at beim Read) wird
159
- // im Executor verdrahtet (Phase C). required propagiert auf beide.
155
+ // Two columns form an atomic pair: <name>_utc TIMESTAMPTZ + <name>_tz TEXT.
156
+ // _utc is instant() (Temporal.Instant), and _tz is text (IANA name).
157
+ // The executor wires automatic conversion (at+tz → utc on insert;
158
+ // utc+tz → at on read). required propagates to both columns.
160
159
  const utc = instant(`${snakeName}_utc`);
161
160
  const tz = text(`${snakeName}_tz`);
162
161
  return {
@@ -188,12 +187,12 @@ function fieldToColumns(name, field, entity) {
188
187
  * Derives a table name from an entity name:
189
188
  * 1. camelCase → snake_case (e.g. "memberTask" → "member_task")
190
189
  * 2. Simple English pluralization (category→categories, status→statuses, task→tasks)
191
- * 3. `read_` prefix — markiert die Tabelle als Event-Sourced-Read-Model,
192
- * damit im DB-Tool sofort erkennbar ist dass App-Code nicht direkt
193
- * reinschreibt. Event-Store (kumiko_events) + Framework-State
194
- * (kumiko_*) haben ihren eigenen Prefix, normale App-Side-Tables
195
- * (ohne ES-Anbindung) haben keinen — die drei Kategorien sind damit
196
- * im Tabellenbrowser unterscheidbar.
190
+ * 3. `read_` prefix — marks the table as an event-sourced read model so
191
+ * it is immediately distinguishable in database tools and application
192
+ * code is not expected to write to it directly. The event store
193
+ * (`kumiko_events`) and framework state (`kumiko_*`) use their own
194
+ * prefixes; ordinary app-side tables without event-sourcing have none.
195
+ * The three categories are therefore distinguishable in the table browser.
197
196
  */
198
197
  const ES_PLURAL_SUFFIXES = ["s", "sh", "ch", "x"];
199
198
  export const READ_MODEL_PREFIX = "read_";
@@ -219,12 +218,13 @@ export function buildBaseColumns(softDelete, idType = "uuid") {
219
218
  id: idColumn,
220
219
  tenantId: uuid("tenant_id").notNull(),
221
220
  version: integer("version").default(1).notNull(),
222
- // Sprint F: Temporal.Instant durchgängig (siehe instant() in dialect.ts).
223
- // Vorher mode default "date" → Inkonsistenz mit user-defined timestamp
224
- // Felder (mode "string"). Jetzt: ein Mode für alle Timestamps.
221
+ // Temporal.Instant is used throughout (see instant() in dialect.ts).
222
+ // Previously the default mode was "date", which was inconsistent with
223
+ // user-defined timestamp fields (mode "string"). A single mode is now
224
+ // used for all timestamps.
225
225
  // customType doesn't expose Drizzle's `defaultNow()` shortcut — use raw
226
- // SQL so PG sets the value on insert and we don't need to pass an
227
- // Instant from JS for every row create.
226
+ // SQL so PG sets the value on insert and callers need not pass an
227
+ // Instant for every row creation.
228
228
  insertedAt: instant("inserted_at").default(sql `now()`).notNull(),
229
229
  modifiedAt: instant("modified_at"),
230
230
  // User-IDs are stringified UUIDs post-ES migration. Text (not uuid) so the
@@ -295,9 +295,9 @@ export function buildEntityTable(entityName, entity, options) {
295
295
  }
296
296
  // Default table name derived from entityName (e.g. "memberTask" → "read_member_tasks")
297
297
  const baseTableName = entity.table ?? toTableName(entityName);
298
- // featureName-prefix wird zwischen read_ und den base-Namen geschoben,
299
- // damit alle read-models einheitlich mit `read_` starten — egal ob
300
- // featureName gesetzt ist oder nicht. Beispiel:
298
+ // The featureName prefix is inserted between read_ and the base name so
299
+ // all read models consistently start with `read_`, whether or not
300
+ // featureName is set. Examples:
301
301
  // featureName="shop", base="read_orders" → "read_shop_orders"
302
302
  // featureName=undef, base="read_orders" → "read_orders"
303
303
  // featureName="shop", base="orders" (no read_) → "shop_orders"
@@ -349,7 +349,7 @@ export function buildEntityTable(entityName, entity, options) {
349
349
  indexes[`${tableName}_${toSnakeCase(fieldName)}_idx`] = index(`${tableName}_${toSnakeCase(fieldName)}_idx`).on(column);
350
350
  }
351
351
  }
352
- // lookupable-Felder: Index auf der bidx-Spalte (lock-step mit
352
+ // lookupable fields: index the bidx column (kept in lockstep with
353
353
  // deriveEntityTableMeta).
354
354
  const bidxFieldByField = new Map();
355
355
  for (const [name, field] of Object.entries(entity.fields)) {
@@ -362,10 +362,10 @@ export function buildEntityTable(entityName, entity, options) {
362
362
  indexes[idxName] = index(idxName).on(column);
363
363
  }
364
364
  }
365
- // entity.indexes = composite/unique-Indices die der Author explizit
366
- // deklariert hat. Spalten werden via field-name (camelCase) angesprochen,
367
- // der Index-Name folgt der Convention <table>_<col1>_<col2>_<unique|idx>
368
- // — Override via index.name möglich.
365
+ // entity.indexes contains composite/unique indexes explicitly declared by
366
+ // the author. Columns are addressed by field name (camelCase), and the
367
+ // index name follows <table>_<col1>_<col2>_<unique|idx>; index.name can
368
+ // override the convention.
369
369
  for (const def of entity.indexes ?? []) {
370
370
  const cols = def.columns
371
371
  .map((fieldName) => tHandle[fieldName])
@@ -388,12 +388,12 @@ export function buildEntityTable(entityName, entity, options) {
388
388
  }
389
389
  else if (def.unique === true && entity.softDelete === true) {
390
390
  // Soft-deleted rows keep their value, so a full unique index would
391
- // permanently block reuse; kept in lock-step with entity-table-meta.ts (framework#2593).
391
+ // permanently block reuse; kept in lock-step with entity-table-meta.ts.
392
392
  chain = chain.where(sql.raw(SOFT_DELETE_LIVE_ROW_PREDICATE));
393
393
  }
394
394
  indexes[indexName] = chain;
395
- // Partielles bidx-Pendant für unique-Indices über lookupable-Spalten
396
- // (lock-step mit deriveEntityTableMeta).
395
+ // Partial bidx counterpart for unique indexes over lookupable fields
396
+ // (kept in lockstep with deriveEntityTableMeta).
397
397
  if (def.unique === true && def.where === undefined) {
398
398
  const bidxFieldNames = def.columns.map((c) => bidxFieldByField.get(c) ?? c);
399
399
  if (bidxFieldNames.some((c, i) => c !== def.columns[i])) {
@@ -406,7 +406,7 @@ export function buildEntityTable(entityName, entity, options) {
406
406
  .map((c) => `"${toSnakeCase(c)}" IS NOT NULL`);
407
407
  // Soft-deleted rows keep their bidx hash — without this, a
408
408
  // restored row can't reuse a value a soft-deleted sibling still
409
- // holds (framework#2464).
409
+ // holds.
410
410
  const whereText = (entity.softDelete === true
411
411
  ? [...notNullParts, SOFT_DELETE_LIVE_ROW_PREDICATE]
412
412
  : notNullParts).join(" AND ");