@cosmicdrift/kumiko-framework 0.363.0 → 0.365.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 (281) hide show
  1. package/AGENTS.md +338 -0
  2. package/agents/examples/access-control.md +355 -0
  3. package/agents/examples/anonymous-access-multitenant.md +446 -0
  4. package/agents/examples/anonymous-access.md +288 -0
  5. package/agents/examples/apex-landing.md +528 -0
  6. package/agents/examples/apex-surface-auth.md +355 -0
  7. package/agents/examples/api-worker-split.md +434 -0
  8. package/agents/examples/auth-claims.md +351 -0
  9. package/agents/examples/auth-foundation-providers.md +155 -0
  10. package/agents/examples/auth-mfa.md +319 -0
  11. package/agents/examples/basic-entity.md +420 -0
  12. package/agents/examples/boot-check.md +122 -0
  13. package/agents/examples/chat-channels.md +254 -0
  14. package/agents/examples/compliance-profiles-demo.md +307 -0
  15. package/agents/examples/cross-feature-events.md +317 -0
  16. package/agents/examples/crypto-shredding-hr.md +314 -0
  17. package/agents/examples/currencies-global.md +609 -0
  18. package/agents/examples/currencies-per-tenant.md +453 -0
  19. package/agents/examples/custom-fields-basic.md +315 -0
  20. package/agents/examples/custom-handlers.md +444 -0
  21. package/agents/examples/dashboard-stats.md +229 -0
  22. package/agents/examples/delivery-notifications.md +432 -0
  23. package/agents/examples/designer-demo.md +86 -0
  24. package/agents/examples/e2e-generator.md +211 -0
  25. package/agents/examples/embedded-entity-form.md +598 -0
  26. package/agents/examples/embedded.md +649 -0
  27. package/agents/examples/encrypted-tenant-config.md +414 -0
  28. package/agents/examples/error-contract.md +535 -0
  29. package/agents/examples/event-sourcing.md +1287 -0
  30. package/agents/examples/feature-toggles.md +274 -0
  31. package/agents/examples/field-access.md +271 -0
  32. package/agents/examples/files-post-processing.md +433 -0
  33. package/agents/examples/i18n.md +166 -0
  34. package/agents/examples/idempotency.md +179 -0
  35. package/agents/examples/inbound-mail-triage.md +315 -0
  36. package/agents/examples/lane-routing.md +246 -0
  37. package/agents/examples/ledger-banking.md +215 -0
  38. package/agents/examples/ledger-invoicing.md +236 -0
  39. package/agents/examples/legal-pages.md +393 -0
  40. package/agents/examples/lifecycle-hooks.md +368 -0
  41. package/agents/examples/managed-config.md +428 -0
  42. package/agents/examples/managed-pages.md +353 -0
  43. package/agents/examples/notes-history-basic.md +346 -0
  44. package/agents/examples/ownership.md +404 -0
  45. package/agents/examples/pdf-audit-export.md +306 -0
  46. package/agents/examples/pipeline-basics.md +792 -0
  47. package/agents/examples/public-share-token.md +351 -0
  48. package/agents/examples/rate-limiting.md +211 -0
  49. package/agents/examples/realtime-sse.md +187 -0
  50. package/agents/examples/record-detail-layout.md +297 -0
  51. package/agents/examples/reference-data.md +151 -0
  52. package/agents/examples/relations.md +280 -0
  53. package/agents/examples/screens-nav.md +353 -0
  54. package/agents/examples/search.md +233 -0
  55. package/agents/examples/secrets-demo.md +417 -0
  56. package/agents/examples/security-baseline.md +187 -0
  57. package/agents/examples/seed-migration.md +99 -0
  58. package/agents/examples/session-revocation.md +250 -0
  59. package/agents/examples/state-machine.md +920 -0
  60. package/agents/examples/tags-basic.md +401 -0
  61. package/agents/examples/tenant-isolation.md +173 -0
  62. package/agents/examples/tenant-settings.md +275 -0
  63. package/agents/examples/tier-admin.md +347 -0
  64. package/agents/examples/timezones.md +366 -0
  65. package/agents/examples/user-data-rights.md +418 -0
  66. package/agents/examples/user-profile.md +226 -0
  67. package/agents/examples/webhook-step.md +597 -0
  68. package/agents/examples/wizard-form.md +379 -0
  69. package/agents/examples/workflow-engine.md +1731 -0
  70. package/agents/examples/writeform-section.md +300 -0
  71. package/agents/examples.md +75 -0
  72. package/agents/features.md +768 -0
  73. package/agents/widgets.md +181 -0
  74. package/dist/api/auth-middleware.d.ts +2 -0
  75. package/dist/api/auth-middleware.js +7 -0
  76. package/dist/api/auth-routes.d.ts +0 -1
  77. package/dist/api/auth-routes.js +49 -46
  78. package/dist/api/extra-route.d.ts +3 -0
  79. package/dist/api/index.d.ts +2 -2
  80. package/dist/api/index.js +1 -1
  81. package/dist/api/routes.js +46 -17
  82. package/dist/api/server.d.ts +4 -3
  83. package/dist/api/server.js +39 -10
  84. package/dist/api/session-cookies.d.ts +1 -0
  85. package/dist/api/session-cookies.js +20 -5
  86. package/dist/api/sse-route.js +16 -1
  87. package/dist/bun-db/connection.js +3 -16
  88. package/dist/bun-db/index.d.ts +1 -1
  89. package/dist/bun-db/index.js +1 -1
  90. package/dist/bun-db/query.d.ts +15 -3
  91. package/dist/bun-db/query.js +76 -24
  92. package/dist/crypto/owner-transition-rekey.d.ts +1 -0
  93. package/dist/crypto/owner-transition-rekey.js +7 -3
  94. package/dist/crypto/pii-field-encryption.d.ts +1 -0
  95. package/dist/crypto/pii-field-encryption.js +9 -2
  96. package/dist/crypto/subject-resolver.d.ts +1 -0
  97. package/dist/crypto/subject-resolver.js +7 -0
  98. package/dist/db/api.d.ts +4 -3
  99. package/dist/db/apply-entity-event.js +26 -2
  100. package/dist/db/blind-index-cleanup.js +2 -1
  101. package/dist/db/bun-provider.js +9 -18
  102. package/dist/db/collect-table-metas.js +11 -7
  103. package/dist/db/dialect.d.ts +1 -1
  104. package/dist/db/dialect.js +2 -0
  105. package/dist/db/eagerload.js +4 -3
  106. package/dist/db/event-store-executor-context.d.ts +6 -6
  107. package/dist/db/event-store-executor-context.js +25 -22
  108. package/dist/db/event-store-executor-read.js +31 -16
  109. package/dist/db/event-store-executor-write.d.ts +1 -1
  110. package/dist/db/event-store-executor-write.js +84 -63
  111. package/dist/db/index.d.ts +2 -1
  112. package/dist/db/index.js +2 -1
  113. package/dist/db/lock-guard.d.ts +4 -0
  114. package/dist/db/lock-guard.js +14 -0
  115. package/dist/db/migrate-generator.js +1 -1
  116. package/dist/db/migrate-runner.d.ts +1 -0
  117. package/dist/db/migrate-runner.js +64 -38
  118. package/dist/db/queries/backfill-pii.js +63 -25
  119. package/dist/db/queries/event-consumer.d.ts +6 -6
  120. package/dist/db/queries/event-consumer.js +2 -1
  121. package/dist/db/queries/event-store-transfer.js +15 -4
  122. package/dist/db/queries/event-store.d.ts +1 -1
  123. package/dist/db/queries/event-store.js +11 -10
  124. package/dist/db/queries/pii-backfill-state.js +3 -2
  125. package/dist/db/queries/postgres-array-literal.d.ts +2 -0
  126. package/dist/db/queries/postgres-array-literal.js +6 -0
  127. package/dist/db/queries/projection-rebuild.d.ts +1 -1
  128. package/dist/db/queries/projection-rebuild.js +3 -2
  129. package/dist/db/queries/shadow-swap.d.ts +1 -1
  130. package/dist/db/queries/shadow-swap.js +12 -8
  131. package/dist/db/query.d.ts +1 -1
  132. package/dist/db/query.js +1 -1
  133. package/dist/db/retired-framework-tables.js +22 -4
  134. package/dist/db/table-builder.js +6 -2
  135. package/dist/db/tenant-db-runner.d.ts +4 -0
  136. package/dist/db/tenant-db-runner.js +17 -0
  137. package/dist/db/tenant-db.d.ts +4 -0
  138. package/dist/db/tenant-db.js +50 -5
  139. package/dist/derivatives/delete-stored-file.d.ts +4 -0
  140. package/dist/derivatives/delete-stored-file.js +9 -0
  141. package/dist/derivatives/derivatives-context.js +1 -1
  142. package/dist/derivatives/index.d.ts +1 -1
  143. package/dist/derivatives/index.js +1 -1
  144. package/dist/engine/boot-validator/access-declarations.js +2 -2
  145. package/dist/engine/boot-validator/api-ext.js +3 -3
  146. package/dist/engine/boot-validator/boot-check.js +1 -1
  147. package/dist/engine/boot-validator/config-deps.js +13 -13
  148. package/dist/engine/boot-validator/detail-screens.js +1 -1
  149. package/dist/engine/boot-validator/entity-handler.d.ts +1 -0
  150. package/dist/engine/boot-validator/entity-handler.js +87 -35
  151. package/dist/engine/boot-validator/entity-list-screens.js +2 -2
  152. package/dist/engine/boot-validator/handler-field-references.js +19 -9
  153. package/dist/engine/boot-validator/i18n-keys.js +2 -2
  154. package/dist/engine/boot-validator/nav.js +1 -1
  155. package/dist/engine/boot-validator/pii-retention.js +8 -8
  156. package/dist/engine/boot-validator/projection-list-screens.js +5 -5
  157. package/dist/engine/boot-validator/query-output-columns.js +7 -7
  158. package/dist/engine/boot-validator/query-refs.js +14 -2
  159. package/dist/engine/boot-validator/screens.d.ts +2 -1
  160. package/dist/engine/boot-validator/screens.js +153 -95
  161. package/dist/engine/boot-validator/url-prefill-fields.js +54 -35
  162. package/dist/engine/dedupe-features.js +31 -4
  163. package/dist/engine/define-handler.d.ts +2 -2
  164. package/dist/engine/define-handler.js +8 -1
  165. package/dist/engine/entity-handlers.js +14 -7
  166. package/dist/engine/feature-ast/extractors/handlers.js +2 -0
  167. package/dist/engine/feature-ast/pattern-change-schema.d.ts +2 -0
  168. package/dist/engine/feature-ast/pattern-change-schema.js +1 -0
  169. package/dist/engine/feature-ast/render.js +10 -2
  170. package/dist/engine/feature-entity-handlers.d.ts +2 -2
  171. package/dist/engine/feature-entity-handlers.js +12 -3
  172. package/dist/engine/handler-field-references.d.ts +21 -2
  173. package/dist/engine/handler-field-references.js +55 -17
  174. package/dist/engine/index.d.ts +3 -3
  175. package/dist/engine/index.js +1 -1
  176. package/dist/engine/schema-builder.js +2 -1
  177. package/dist/engine/screen-helpers.d.ts +1 -0
  178. package/dist/engine/screen-helpers.js +2 -2
  179. package/dist/engine/soft-delete-cleanup.js +10 -3
  180. package/dist/engine/steps/_read-source.js +5 -2
  181. package/dist/engine/steps/retry.js +2 -1
  182. package/dist/engine/steps/unsafe-projection-delete.js +2 -2
  183. package/dist/engine/steps/unsafe-projection-upsert.js +2 -2
  184. package/dist/engine/template-variables.d.ts +3 -0
  185. package/dist/engine/template-variables.js +3 -0
  186. package/dist/engine/tier-resolver-extension.d.ts +1 -0
  187. package/dist/engine/tier-resolver-extension.js +6 -0
  188. package/dist/engine/types/index.d.ts +1 -1
  189. package/dist/entrypoint/index.d.ts +2 -2
  190. package/dist/env/_zod-introspect.d.ts +1 -0
  191. package/dist/errors/classes.js +1 -0
  192. package/dist/errors/docs-url.js +6 -6
  193. package/dist/errors/index.d.ts +1 -1
  194. package/dist/errors/index.js +1 -1
  195. package/dist/errors/kumiko-error.d.ts +2 -0
  196. package/dist/errors/kumiko-error.js +2 -0
  197. package/dist/errors/subject-erased.d.ts +1 -0
  198. package/dist/errors/subject-erased.js +6 -0
  199. package/dist/errors/write-error-info.js +2 -0
  200. package/dist/event-store/admin-api.js +3 -2
  201. package/dist/event-store/errors.d.ts +9 -0
  202. package/dist/event-store/errors.js +20 -0
  203. package/dist/event-store/event-store.d.ts +6 -0
  204. package/dist/event-store/event-store.js +28 -6
  205. package/dist/event-store/index.d.ts +1 -1
  206. package/dist/event-store/index.js +1 -1
  207. package/dist/event-store/tenant-db-append.js +2 -2
  208. package/dist/files/assert-file-refs-belong-to-tenant.d.ts +4 -5
  209. package/dist/files/assert-file-refs-belong-to-tenant.js +7 -15
  210. package/dist/files/bind-pending-file-refs.d.ts +2 -0
  211. package/dist/files/bind-pending-file-refs.js +3 -3
  212. package/dist/files/bind-referenced-files.write.d.ts +1 -1
  213. package/dist/files/bind-referenced-files.write.js +22 -13
  214. package/dist/files/create-file-ref-from-bytes.d.ts +6 -3
  215. package/dist/files/create-file-ref-from-bytes.js +67 -4
  216. package/dist/files/create-file-ref.d.ts +24 -1
  217. package/dist/files/create-file-ref.js +67 -25
  218. package/dist/files/dispose-removed-file-refs.js +21 -6
  219. package/dist/files/feature.d.ts +1 -0
  220. package/dist/files/feature.js +2 -1
  221. package/dist/files/file-handle.d.ts +1 -1
  222. package/dist/files/file-handle.js +12 -0
  223. package/dist/files/file-routes.js +10 -6
  224. package/dist/files/index.d.ts +3 -3
  225. package/dist/files/index.js +2 -2
  226. package/dist/files/load-save-file-refs.d.ts +10 -0
  227. package/dist/files/load-save-file-refs.js +47 -0
  228. package/dist/files/purge-expired-file-ref-bytes.d.ts +4 -1
  229. package/dist/files/purge-expired-file-ref-bytes.js +46 -21
  230. package/dist/files/resolve-content-type.js +40 -4
  231. package/dist/files/storage-tracking.js +5 -4
  232. package/dist/files/types.js +5 -8
  233. package/dist/files/zip-entry-names.d.ts +1 -0
  234. package/dist/files/zip-entry-names.js +46 -0
  235. package/dist/jobs/index.d.ts +1 -1
  236. package/dist/jobs/job-runner.d.ts +5 -1
  237. package/dist/jobs/job-runner.js +124 -37
  238. package/dist/observability/escape-hatch-report.d.ts +2 -1
  239. package/dist/observability/escape-hatch-report.js +48 -7
  240. package/dist/observability/index.d.ts +1 -1
  241. package/dist/observability/index.js +1 -1
  242. package/dist/observability/metrics-handle.d.ts +1 -0
  243. package/dist/observability/metrics-handle.js +7 -0
  244. package/dist/observability/standard-metrics.js +4 -4
  245. package/dist/pipeline/cascade-handler.js +5 -2
  246. package/dist/pipeline/dispatch-batch.js +79 -31
  247. package/dist/pipeline/dispatch-query.d.ts +2 -1
  248. package/dist/pipeline/dispatch-query.js +6 -6
  249. package/dist/pipeline/dispatch-shared.d.ts +7 -2
  250. package/dist/pipeline/dispatch-shared.js +136 -48
  251. package/dist/pipeline/dispatch-stream.js +10 -4
  252. package/dist/pipeline/dispatch-write.js +34 -22
  253. package/dist/pipeline/dispatcher.js +3 -1
  254. package/dist/pipeline/event-dispatcher.d.ts +2 -2
  255. package/dist/pipeline/event-dispatcher.js +11 -9
  256. package/dist/pipeline/event-retention.d.ts +1 -0
  257. package/dist/pipeline/event-retention.js +7 -1
  258. package/dist/pipeline/index.d.ts +1 -0
  259. package/dist/pipeline/index.js +1 -0
  260. package/dist/pipeline/notify-scope.js +3 -1
  261. package/dist/pipeline/pre-transaction-reservations.d.ts +3 -1
  262. package/dist/pipeline/pre-transaction-reservations.js +29 -9
  263. package/dist/rate-limit/bucket.d.ts +3 -3
  264. package/dist/rate-limit/bucket.js +14 -4
  265. package/dist/redis/cache-sync-bus.js +9 -3
  266. package/dist/schema-cli.js +12 -1
  267. package/dist/search/meilisearch-adapter.js +36 -9
  268. package/dist/stack/db.d.ts +2 -2
  269. package/dist/stack/test-stack.js +2 -1
  270. package/dist/testing/index.d.ts +1 -0
  271. package/dist/testing/index.js +1 -0
  272. package/dist/testing/multipart-helper.d.ts +6 -0
  273. package/dist/testing/multipart-helper.js +1 -1
  274. package/dist/testing/zip-fixture.d.ts +1 -0
  275. package/dist/testing/zip-fixture.js +33 -0
  276. package/dist/ui-types/card-column-roles.js +4 -2
  277. package/dist/ui-types/index.d.ts +1 -1
  278. package/dist/ui-types/index.js +1 -1
  279. package/dist/upgrade-cli.js +46 -13
  280. package/package.json +7 -5
  281. package/src/changes.json +512 -8
@@ -2,20 +2,21 @@ import { checkWriteFieldOwnership } from "../engine/field-access.js";
2
2
  import { instructionFieldNames } from "../engine/instruction-fields.js";
3
3
  import { userCanCreateFieldRow, userCanWriteFieldRow } from "../engine/ownership.js";
4
4
  import { buildInsertSchema, buildUpdateSchema } from "../engine/schema-builder.js";
5
- import { findUnavailableSelectOptions, isRecordLocked, } from "../engine/screen-helpers.js";
6
- import { SYSTEM_ROLE, SYSTEM_USER_ID } from "../engine/system-user.js";
7
- import { AccessDeniedError, VersionConflictError as FrameworkVersionConflict, IdempotentReplayError, InternalError, NotFoundError, PreconditionFailedError, RecordLockedError, UnprocessableError, writeFailure, } from "../errors/index.js";
5
+ import { findUnavailableSelectOptions, isRecordLocked, isUnchangedValue, } from "../engine/screen-helpers.js";
6
+ import { AccessDeniedError, VersionConflictError as FrameworkVersionConflict, IdempotentReplayError, InternalError, isSubjectErasedConflict, NotFoundError, PreconditionFailedError, RecordLockedError, UnprocessableError, writeFailure, } from "../errors/index.js";
8
7
  import { append, IdempotentAppendConflictError as EventStoreIdempotentAppendConflict, VersionConflictError as EventStoreVersionConflict, getStreamVersion, } from "../event-store/index.js";
9
8
  import { assertInstructionFieldWriteAllowed, assertIrreversibleOperationAllowed, isIrreversibleEntityVerb, } from "../pipeline/irreversible-operation-gate.js";
10
9
  import { hasLiveProjectionsForEvent, runProjectionsForEvent, } from "../pipeline/projections-runner.js";
11
10
  import { generateId } from "../utils/index.js";
12
11
  import { applyEntityEvent } from "./apply-entity-event.js";
13
12
  import { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types.js";
13
+ import { WritePurposes, } from "./event-store-executor.js";
14
14
  import { buildEventMetadata, entityEventName, isForeignTenantOnGlobalEntity, ownershipSubject, tryMapUniqueViolation, } from "./event-store-executor-context.js";
15
+ import { isFrameworkSystemUser, isLockGuardBypassed } from "./lock-guard.js";
15
16
  import { projectionRegistryOf } from "./projection-registry-binding.js";
16
17
  import { runInSavepointIfSupported } from "./query.js";
17
18
  import { assertPersonalDataWrite, tableNameOf } from "./tenant-db.js";
18
- import { tenantDbRunner } from "./tenant-db-runner.js";
19
+ import { tenantDbWriteRunner } from "./tenant-db-runner.js";
19
20
  // Custom projections for a write that did not go through the dispatcher (hook or
20
21
  // job writing a second aggregate). Savepoint like applyEntityEvent: a throwing
21
22
  // projection fails the write and the caller's transaction rolls it back.
@@ -29,30 +30,11 @@ async function projectEntityEvent(db, runner, event) {
29
30
  return;
30
31
  await runInSavepointIfSupported(runner, (sp) => runProjectionsForEvent(event, registry, sp));
31
32
  }
32
- // Art. 17 erasure runs as the framework operator, not as a row owner; a
33
- // per-role ownership map can never cover it, and a silent deny means the
34
- // erasure never happened.
35
- function isFrameworkSystemUser(user) {
36
- return user.id === SYSTEM_USER_ID && user.roles.includes(SYSTEM_ROLE);
37
- }
38
33
  // The five write verbs (create/update/delete/forget/restore) of the event-
39
34
  // store-executor. Split out of event-store-executor.ts (#1005, Welle 2) —
40
35
  // behavior-preserving relocation, not a redesign: every closure below is
41
36
  // unchanged from the original, just relocated behind an explicit
42
37
  // ExecutorContext instead of capturing the factory's local scope directly.
43
- // updateOptions.skipUnchanged (#464) — opt-in: a resubmitted-but-identical
44
- // key is dropped before encryption so pii/encrypted fields don't get a fresh
45
- // AEAD ciphertext (new nonce) for no real change, and the event's `changes`
46
- // don't carry a phantom diff. Opt-in, not the executor's default, because
47
- // direct executor.update() callers rely on the current behavior to FORCE a
48
- // re-encrypt of an unchanged plaintext — KEK-rotation (auth-mfa reencrypt.job)
49
- // and the user-data-rights #494 backfill both resubmit the current value on
50
- // purpose to land a fresh event/ciphertext. Only the generic entity update
51
- // handler (entity-handlers.ts) sets this flag; those two call their own
52
- // executor directly and never see it.
53
- function isUnchangedValue(a, b) {
54
- return JSON.stringify(a) === JSON.stringify(b);
55
- }
56
38
  function selectOptionNotAvailableFailure(entries) {
57
39
  const [first] = entries;
58
40
  if (!first)
@@ -126,13 +108,16 @@ export function createWriteVerbs(ctx) {
126
108
  // statutory deletion must reach locked rows, and forget (Art. 17) never checks the lock
127
109
  // at all. The options are server-code only; no request field is ever copied into them.
128
110
  function recordLockedFailure(row, id, user, options) {
129
- if (options?.unsafeSkipLockGuard === true ||
130
- (options?.writePurpose !== undefined && isFrameworkSystemUser(user)) ||
131
- !isRecordLocked(entity.lockedWhen, row)) {
111
+ if (isLockGuardBypassed(user, options) || !isRecordLocked(entity.lockedWhen, row)) {
132
112
  return undefined;
133
113
  }
134
114
  return writeFailure(new RecordLockedError({ entityName, entityId: id }));
135
115
  }
116
+ // Erasure writes run after the subject key is gone; they store the erased
117
+ // sentinel for that subject's fields instead of failing on re-encrypt.
118
+ function erasureSentinelOption(options) {
119
+ return options?.writePurpose === WritePurposes.erasure ? { onErasedSubject: "sentinel" } : {};
120
+ }
136
121
  const eventVersion = entity.eventVersion ?? 1;
137
122
  let insertSchema;
138
123
  let updateSchema;
@@ -157,6 +142,45 @@ export function createWriteVerbs(ctx) {
157
142
  }
158
143
  return { row: row };
159
144
  }
145
+ // A forgotten subject has no key any more: restore re-encrypts the stored row and reports
146
+ // that as a failure result like any other write conflict instead of throwing.
147
+ async function appendRestoredEvent(runner, user, id, previousPlain, streamTenantId, currentVersion) {
148
+ try {
149
+ const previous = await encryptForStorage(previousPlain, user, {
150
+ keyTenantId: streamTenantId,
151
+ });
152
+ const event = await runInSavepointIfSupported(runner, (sp) => append(sp, {
153
+ aggregateId: String(id),
154
+ aggregateType: entityName,
155
+ tenantId: streamTenantFor(user, streamTenantId),
156
+ expectedVersion: currentVersion,
157
+ type: entityEventName(entityName, "restored"),
158
+ eventVersion,
159
+ payload: { previous },
160
+ metadata: buildEventMetadata(user),
161
+ }));
162
+ return { event };
163
+ }
164
+ catch (e) {
165
+ if (isSubjectErasedConflict(e))
166
+ return { failure: writeFailure(e) };
167
+ if (e instanceof EventStoreVersionConflict) {
168
+ return {
169
+ failure: writeFailure(new FrameworkVersionConflict({
170
+ entityId: id,
171
+ expectedVersion: currentVersion,
172
+ currentVersion: -1,
173
+ })),
174
+ };
175
+ }
176
+ if (e instanceof EventStoreIdempotentAppendConflict) {
177
+ return {
178
+ failure: writeFailure(new IdempotentReplayError({ idempotencyKey: e.idempotencyKey })),
179
+ };
180
+ }
181
+ throw e;
182
+ }
183
+ }
160
184
  async function expectedStreamVersion(db, id, streamTenant, expect) {
161
185
  const snapshot = await loadExpectSnapshot(db, id, streamTenant, Object.keys(expect));
162
186
  const row = snapshot.row;
@@ -180,7 +204,7 @@ export function createWriteVerbs(ctx) {
180
204
  const streamTenantMismatch = streamTenantCreateFailure(db, payload, streamTenantId);
181
205
  if (streamTenantMismatch)
182
206
  return streamTenantMismatch;
183
- const runner = tenantDbRunner(db);
207
+ const runner = tenantDbWriteRunner(db, "ctx.db executor create");
184
208
  // Respect an explicit id in the payload (seed pattern, SCIM import). Without
185
209
  // one the framework mints a fresh UUIDv7 via generateId. Strip it out of the
186
210
  // event payload so defaults + downstream consumers don't see a redundant id field.
@@ -349,7 +373,7 @@ export function createWriteVerbs(ctx) {
349
373
  };
350
374
  },
351
375
  async update(payload, user, db, updateOptions) {
352
- const runner = tenantDbRunner(db);
376
+ const runner = tenantDbWriteRunner(db, "ctx.db executor update");
353
377
  if (isForeignTenantOnGlobalEntity(entity, payload.changes["tenantId"])) {
354
378
  throw new AccessDeniedError({
355
379
  message: `${entityName}.update: entity is tenancy: "global" — payload.changes.tenantId must ` +
@@ -423,7 +447,12 @@ export function createWriteVerbs(ctx) {
423
447
  const lockedUpdate = recordLockedFailure(previous, payload.id, user, updateOptions);
424
448
  if (lockedUpdate)
425
449
  return lockedUpdate;
426
- const unavailableOnUpdate = selectOptionNotAvailableFailure(findUnavailableSelectOptions(entity.fields, mergedNew, previous));
450
+ // Statutory retention/erasure writes may change a condition field (e.g. null a PII
451
+ // reference) and must not be blocked by the select rule — same exemption as the lock.
452
+ const isStatutorySystemWrite = updateOptions?.writePurpose !== undefined && isFrameworkSystemUser(user);
453
+ const unavailableOnUpdate = isStatutorySystemWrite
454
+ ? undefined
455
+ : selectOptionNotAvailableFailure(findUnavailableSelectOptions(entity.fields, mergedNew, previous));
427
456
  if (unavailableOnUpdate)
428
457
  return unavailableOnUpdate;
429
458
  const streamTenant = streamTenantFor(user, streamTenantId);
@@ -462,6 +491,9 @@ export function createWriteVerbs(ctx) {
462
491
  // Compound-Types Auto-Convert (alle in einem Pass).
463
492
  // subjectSource: partial changes may carry a pii field without its
464
493
  // ownerField — the merged row still names the subject.
494
+ // skipUnchanged (#464) is opt-in: an identical resubmitted key is dropped before
495
+ // encryption so pii/encrypted fields get no fresh ciphertext for no real change.
496
+ // Direct callers (KEK rotation, #494 backfill) rely on the default to force a re-encrypt.
465
497
  const submittedChanges = updateOptions?.skipUnchanged
466
498
  ? Object.fromEntries(Object.entries(changes).filter(([key, value]) => !isUnchangedValue(value, previous[key])))
467
499
  : changes;
@@ -477,6 +509,7 @@ export function createWriteVerbs(ctx) {
477
509
  onlyKeys: Object.keys(changesToPersist),
478
510
  subjectSource: mergedNew,
479
511
  keyTenantId: streamTenantId,
512
+ ...erasureSentinelOption(updateOptions),
480
513
  });
481
514
  // The event payload carries BOTH `changes` (what the user asked for) AND
482
515
  // `previous` (the pre-update row). Cross-aggregate projections need the
@@ -492,6 +525,7 @@ export function createWriteVerbs(ctx) {
492
525
  const encryptedPrevious = await encryptForStorage(previous, user, {
493
526
  keyTenantId: streamTenantId,
494
527
  ownerFallback: submittedChanges,
528
+ ...erasureSentinelOption(updateOptions),
495
529
  });
496
530
  // Savepoint-scoped — see the create() append() above for why:
497
531
  // confines a losing writer's unique-violation to a nested scope
@@ -582,7 +616,7 @@ export function createWriteVerbs(ctx) {
582
616
  if (isIrreversibleEntityVerb("delete", entity)) {
583
617
  assertIrreversibleOperationAllowed(`${entityName}.delete (entity has no softDelete)`);
584
618
  }
585
- const runner = tenantDbRunner(db);
619
+ const runner = tenantDbWriteRunner(db, "ctx.db executor delete");
586
620
  const existing = await loadById(payload.id, db);
587
621
  if (!existing)
588
622
  return writeFailure(new NotFoundError(entityName, payload.id));
@@ -628,7 +662,10 @@ export function createWriteVerbs(ctx) {
628
662
  type: entityEventName(entityName, "deleted"),
629
663
  eventVersion,
630
664
  payload: {
631
- previous: await encryptForStorage(existing, user, { keyTenantId: streamTenantId }),
665
+ previous: await encryptForStorage(existing, user, {
666
+ keyTenantId: streamTenantId,
667
+ ...erasureSentinelOption(options),
668
+ }),
632
669
  },
633
670
  metadata: buildEventMetadata(user),
634
671
  }));
@@ -664,6 +701,7 @@ export function createWriteVerbs(ctx) {
664
701
  data: existing,
665
702
  entityName,
666
703
  event: echoEvent,
704
+ ...(isLockGuardBypassed(user, options) && { lockGuardBypassed: true }),
667
705
  },
668
706
  };
669
707
  },
@@ -673,7 +711,7 @@ export function createWriteVerbs(ctx) {
673
711
  // without the isDeleted filter so trashed (soft-deleted) rows are erased too.
674
712
  async forget(payload, user, db, options) {
675
713
  assertIrreversibleOperationAllowed(`${entityName}.forget`);
676
- const runner = tenantDbRunner(db);
714
+ const runner = tenantDbWriteRunner(db, "ctx.db executor forget");
677
715
  const raw = await db.fetchOne(table, { id: payload.id });
678
716
  if (!raw)
679
717
  return writeFailure(new NotFoundError(entityName, payload.id));
@@ -709,7 +747,10 @@ export function createWriteVerbs(ctx) {
709
747
  // Re-encrypt like delete(): `existing` came decrypted from loadById —
710
748
  // plaintext must not land in the immutable log, least of all on forget.
711
749
  payload: {
712
- previous: await encryptForStorage(existing, user, { keyTenantId: streamTenantId }),
750
+ previous: await encryptForStorage(existing, user, {
751
+ keyTenantId: streamTenantId,
752
+ onErasedSubject: "sentinel",
753
+ }),
713
754
  },
714
755
  metadata: buildEventMetadata(user),
715
756
  }));
@@ -744,6 +785,7 @@ export function createWriteVerbs(ctx) {
744
785
  data: existing,
745
786
  entityName,
746
787
  event: echoEvent,
788
+ lockGuardBypassed: true,
747
789
  },
748
790
  };
749
791
  },
@@ -753,7 +795,7 @@ export function createWriteVerbs(ctx) {
753
795
  i18nKey: "errors.softDeleteNotEnabled",
754
796
  }));
755
797
  }
756
- const runner = tenantDbRunner(db);
798
+ const runner = tenantDbWriteRunner(db, "ctx.db executor restore");
757
799
  const streamTenantId = options?.streamTenantId;
758
800
  const target = await loadSoftDeletedRow(db, payload.id, streamTenantId);
759
801
  if ("failure" in target)
@@ -780,34 +822,14 @@ export function createWriteVerbs(ctx) {
780
822
  // Restore carries the soft-deleted snapshot as `previous` — mirror of
781
823
  // delete for symmetry. Projections that decremented on delete use
782
824
  // `previous` to re-increment on restore without re-querying the entity
783
- // table. `data` is the raw stored row — pii/encrypted fields are
784
- // already ciphertext, no re-encrypt needed.
785
- let event;
786
- try {
787
- event = await runInSavepointIfSupported(runner, (sp) => append(sp, {
788
- aggregateId: String(payload.id),
789
- aggregateType: entityName,
790
- tenantId: streamTenantFor(user, streamTenantId),
791
- expectedVersion: currentVersion,
792
- type: entityEventName(entityName, "restored"),
793
- eventVersion,
794
- payload: { previous: data },
795
- metadata: buildEventMetadata(user),
796
- }));
797
- }
798
- catch (e) {
799
- if (e instanceof EventStoreVersionConflict) {
800
- return writeFailure(new FrameworkVersionConflict({
801
- entityId: payload.id,
802
- expectedVersion: currentVersion,
803
- currentVersion: -1,
804
- }));
805
- }
806
- if (e instanceof EventStoreIdempotentAppendConflict) {
807
- return writeFailure(new IdempotentReplayError({ idempotencyKey: e.idempotencyKey }));
808
- }
809
- throw e;
810
- }
825
+ // table. Rows predating the subject-KMS rollout may still hold plaintext
826
+ // pii, and envelope encryption is not idempotent: decrypt, then
827
+ // re-encrypt as delete does.
828
+ const previousPlain = await decryptForRead(data);
829
+ const appended = await appendRestoredEvent(runner, user, payload.id, previousPlain, streamTenantId, currentVersion);
830
+ if ("failure" in appended)
831
+ return appended.failure;
832
+ const event = appended.event;
811
833
  // Live==Rebuild via applyEntityEvent. Restore only writes isDeleted=false
812
834
  // plus the version bump, so there is no sensitive-field drift and no
813
835
  // payload override is needed.
@@ -835,7 +857,6 @@ export function createWriteVerbs(ctx) {
835
857
  // same as every other executor method — `data`/`restored` are raw rows
836
858
  // (db.fetchOne / applyEntityEvent), never decrypted before this point.
837
859
  const restoredHydrated = await decryptForRead(rehydrateCompoundTypes(restored, entity));
838
- const previousPlain = await decryptForRead(data);
839
860
  const echoEvent = { ...event, payload: { previous: stripSensitive(previousPlain) } };
840
861
  await projectEntityEvent(db, runner, echoEvent);
841
862
  return {
@@ -24,6 +24,7 @@ export { type AppliedMigration, type ApplyResult, type BaselineResult, baselineM
24
24
  export { flattenMoney, type MoneyRead, moneyPayloadToMinorUnits, rehydrateMoney } from "./money.js";
25
25
  export { constraintOf, extractPgError, isTableAlreadyExists, isUniqueViolation, type PgErrorInfo, } from "./pg-error.js";
26
26
  export { acquireNamespacedAdvisoryLock } from "./queries/advisory-lock.js";
27
+ export { toPostgresArrayLiteral } from "./queries/postgres-array-literal.js";
27
28
  export { executeRawQuery, executeRawQueryRead } from "./queries/raw-sql.js";
28
29
  export type { SelectOptions, WhereObject, WhereValue } from "./query-api.js";
29
30
  export { aggregateWhere, asRawClient, countWhere, deleteMany, fetchOne, insertMany, insertOne, runInSavepoint, selectMany, transaction, updateMany, } from "./query-api.js";
@@ -35,4 +36,4 @@ export { findCommentedDropTables, isRetiredFrameworkTable, type RetiredFramework
35
36
  export { tableExists } from "./schema-inspection.js";
36
37
  export { buildBaseColumns, buildEntityTable, declareGlobalTenancy, type EntityTable, physicalColumnName, toSnakeCase, toTableName, } from "./table-builder.js";
37
38
  export type { TenantDb, TenantDbGrants, TenantDbMode, UncheckedSystemDb } from "./tenant-db.js";
38
- export { castTenantRows, createSystemDbView, createTenantDb, runInOwnTransaction, SYSTEM_SCOPE_CHECK_BRAND, } from "./tenant-db.js";
39
+ export { castTenantRows, createSystemDbView, createTenantDb, runInOwnTransaction, SYSTEM_SCOPE_CHECK_BRAND, scopeTenantDbToTenant, } from "./tenant-db.js";
package/dist/db/index.js CHANGED
@@ -18,6 +18,7 @@ export { baselineMigrations, fetchAppliedMigrations, loadMigrationsFromDir, Migr
18
18
  export { flattenMoney, moneyPayloadToMinorUnits, rehydrateMoney } from "./money.js";
19
19
  export { constraintOf, extractPgError, isTableAlreadyExists, isUniqueViolation, } from "./pg-error.js";
20
20
  export { acquireNamespacedAdvisoryLock } from "./queries/advisory-lock.js";
21
+ export { toPostgresArrayLiteral } from "./queries/postgres-array-literal.js";
21
22
  export { executeRawQuery, executeRawQueryRead } from "./queries/raw-sql.js";
22
23
  export { aggregateWhere, asRawClient, countWhere, deleteMany, fetchOne, insertMany, insertOne, runInSavepoint, selectMany, transaction, updateMany, } from "./query-api.js";
23
24
  export { readRebuildMarker, rebuildTablesFromDiff, writeRebuildMarker, } from "./rebuild-marker.js";
@@ -27,4 +28,4 @@ export { diffReplayAgainstSnapshot, replayMigrationsDir, } from "./replay-migrat
27
28
  export { findCommentedDropTables, isRetiredFrameworkTable, retiredFrameworkTables, } from "./retired-framework-tables.js";
28
29
  export { tableExists } from "./schema-inspection.js";
29
30
  export { buildBaseColumns, buildEntityTable, declareGlobalTenancy, physicalColumnName, toSnakeCase, toTableName, } from "./table-builder.js";
30
- export { castTenantRows, createSystemDbView, createTenantDb, runInOwnTransaction, SYSTEM_SCOPE_CHECK_BRAND, } from "./tenant-db.js";
31
+ export { castTenantRows, createSystemDbView, createTenantDb, runInOwnTransaction, SYSTEM_SCOPE_CHECK_BRAND, scopeTenantDbToTenant, } from "./tenant-db.js";
@@ -0,0 +1,4 @@
1
+ import type { SessionUser } from "../engine/types/index.js";
2
+ import type { LockGuardOption } from "./event-store-executor.js";
3
+ export declare function isFrameworkSystemUser(user: SessionUser): boolean;
4
+ export declare function isLockGuardBypassed(user: SessionUser, options: LockGuardOption | undefined): boolean;
@@ -0,0 +1,14 @@
1
+ import { SYSTEM_ROLE, SYSTEM_USER_ID } from "../engine/system-user.js";
2
+ // Art. 17 erasure runs as the framework operator, not as a row owner; a
3
+ // per-role ownership map can never cover it, and a silent deny means the
4
+ // erasure never happened.
5
+ export function isFrameworkSystemUser(user) {
6
+ return user.id === SYSTEM_USER_ID && user.roles.includes(SYSTEM_ROLE);
7
+ }
8
+ // Framework system writes that name a writePurpose (retention, erasure) are exempt:
9
+ // statutory deletion must reach locked rows. The options are server-code only; no
10
+ // request field is ever copied into them.
11
+ export function isLockGuardBypassed(user, options) {
12
+ return (options?.unsafeSkipLockGuard === true ||
13
+ (options?.writePurpose !== undefined && isFrameworkSystemUser(user)));
14
+ }
@@ -437,7 +437,7 @@ export function assertUniqueTableNames(metas) {
437
437
  const duplicates = [...counts].filter(([, count]) => count > 1);
438
438
  if (duplicates.length > 0) {
439
439
  throw new Error(duplicates
440
- .map(([tableName, count]) => `ENTITY_METAS lists table "${tableName}" ${count} times — the migration diff would silently use only one definition. collectTableMetas(features) already includes every table a feature registers (r.storeTable, entities, projections); remove the hand-added meta for "${tableName}".`)
440
+ .map(([tableName, count]) => `ENTITY_METAS lists table "${tableName}" ${count} times — the migration diff would silently use only one definition. collectTableMetas(features) already includes every table a feature registers (r.storeTable, entities, projections); remove the duplicate entry for "${tableName}" (a hand-added meta, or two features registering the same table).`)
441
441
  .join("\n"));
442
442
  }
443
443
  }
@@ -12,6 +12,7 @@ export type ApplyResult = {
12
12
  readonly applied: readonly string[];
13
13
  readonly skipped: readonly string[];
14
14
  };
15
+ export declare const MIGRATION_ADVISORY_LOCK_KEY = 1802857833;
15
16
  export declare function splitSqlStatements(sqlText: string): readonly string[];
16
17
  export declare function loadMigrationsFromDir(dir: string, preprocess?: (sql: string) => string): readonly Migration[];
17
18
  export declare function readMigrationSqlTexts(dir: string): readonly string[];
@@ -17,11 +17,11 @@ import { createHash } from "node:crypto";
17
17
  import { existsSync, readdirSync, readFileSync } from "node:fs";
18
18
  import { join } from "node:path";
19
19
  import { propertyOf } from "@cosmicdrift/kumiko-types/type-guards";
20
- import { asRawClient } from "../bun-db/query.js";
20
+ import { asRawClient, hasBegin, } from "../bun-db/query.js";
21
21
  // Advisory-lock key (random 32-bit int, stable). Verhindert dass zwei
22
22
  // gleichzeitig bootende Pods beide migrate apply laufen lassen (zweiter
23
23
  // blockiert bis erster fertig).
24
- const ADVISORY_LOCK_KEY = 0x6b756d69; // "kumi" in ASCII
24
+ export const MIGRATION_ADVISORY_LOCK_KEY = 0x6b756d69; // "kumi" in ASCII
25
25
  const MIGRATIONS_TABLE_DDL = `
26
26
  CREATE TABLE IF NOT EXISTS "_kumiko_migrations" (
27
27
  "id" text PRIMARY KEY NOT NULL,
@@ -169,24 +169,35 @@ export function readMigrationSqlTexts(dir) {
169
169
  // without it fall through to the drizzle wrappers and otherwise fail here,
170
170
  // before any SQL runs, with the migrate-runner message.
171
171
  function migrationClient(db) {
172
- const hasDirectClient = typeof propertyOf(db, "unsafe") === "function" && typeof propertyOf(db, "begin") === "function";
173
- if (hasDirectClient)
174
- return asRawClient(db);
175
- for (const wrapped of [
172
+ for (const candidate of [
173
+ db,
176
174
  propertyOf(db, "$client"),
177
175
  propertyOf(propertyOf(db, "session"), "client"),
178
176
  ]) {
179
- if (typeof propertyOf(wrapped, "unsafe") === "function")
180
- return asRawClient(wrapped);
177
+ if (typeof propertyOf(candidate, "unsafe") !== "function")
178
+ continue;
179
+ const client = asRawClient(candidate);
180
+ if (hasBegin(client))
181
+ return client;
181
182
  }
182
- throw new Error("migrate-runner: db argument has no .unsafe() (need Bun.SQL or drizzle DbConnection)");
183
+ throw new Error("migrate-runner: db argument has no .unsafe() and .begin() (need Bun.SQL or drizzle DbConnection)");
183
184
  }
184
185
  // Raw-SQL via postgres-js or Bun.sql .unsafe() — same shape for both.
185
186
  async function executeRaw(db, sqlText) {
186
187
  await migrationClient(db).unsafe(sqlText);
187
188
  }
189
+ async function reserveConnection(db) {
190
+ const client = migrationClient(db);
191
+ if (typeof client.reserve !== "function") {
192
+ throw new Error("migrate-runner: db argument has no .reserve() (need Bun.SQL or postgres-js Sql)");
193
+ }
194
+ return client.reserve();
195
+ }
188
196
  export async function fetchAppliedMigrations(db) {
189
- const result = await migrationClient(db).unsafe(`SELECT id, checksum FROM "_kumiko_migrations" ORDER BY id`);
197
+ return fetchAppliedFrom(migrationClient(db));
198
+ }
199
+ async function fetchAppliedFrom(client) {
200
+ const result = await client.unsafe(`SELECT id, checksum FROM "_kumiko_migrations" ORDER BY id`);
190
201
  const rows = Array.isArray(result) ? result : [];
191
202
  const applied = [];
192
203
  for (const row of rows) {
@@ -215,40 +226,55 @@ export class MigrationChecksumMismatchError extends Error {
215
226
  // _kumiko_migrations-Lookup übersprungen. Checksum-Mismatch wirft fail-loud.
216
227
  // Advisory-Lock verhindert concurrent-apply-races.
217
228
  export async function runMigrations(db, migrations) {
218
- await executeRaw(db, MIGRATIONS_TABLE_DDL);
219
- await executeRaw(db, `SELECT pg_advisory_lock(${ADVISORY_LOCK_KEY})`);
229
+ // Session-level advisory lock and per-migration BEGIN/COMMIT must share one
230
+ // session. Pool calls may land on different connections (Bun.SQL round-robins),
231
+ // which would leave the lock held on a session nobody unlocks.
232
+ const conn = await reserveConnection(db);
220
233
  try {
221
- const applied = new Map((await fetchAppliedMigrations(db)).map((a) => [a.id, a.checksum]));
222
- const appliedIds = [];
223
- const skippedIds = [];
224
- for (const m of migrations) {
225
- const prevChecksum = applied.get(m.id);
226
- if (prevChecksum !== undefined) {
227
- if (prevChecksum !== m.checksum) {
228
- throw new MigrationChecksumMismatchError(m.id, prevChecksum, m.checksum);
234
+ await conn.unsafe(MIGRATIONS_TABLE_DDL);
235
+ await conn.unsafe(`SELECT pg_advisory_lock(${MIGRATION_ADVISORY_LOCK_KEY})`);
236
+ try {
237
+ const applied = new Map((await fetchAppliedFrom(conn)).map((a) => [a.id, a.checksum]));
238
+ const appliedIds = [];
239
+ const skippedIds = [];
240
+ for (const m of migrations) {
241
+ const prevChecksum = applied.get(m.id);
242
+ if (prevChecksum !== undefined) {
243
+ if (prevChecksum !== m.checksum) {
244
+ throw new MigrationChecksumMismatchError(m.id, prevChecksum, m.checksum);
245
+ }
246
+ skippedIds.push(m.id);
247
+ continue;
229
248
  }
230
- skippedIds.push(m.id);
231
- continue;
232
- }
233
- // Apply file content + INSERT tracking-row in einer TX. Wenn ein
234
- // Statement bricht, Rollback inkl. Tracking-Row → kein partial-apply
235
- // stuck in den books.
236
- const client = migrationClient(db);
237
- await client.begin(async (tx) => {
238
- for (const stmt of m.statements) {
239
- await tx.unsafe(stmt);
249
+ // Apply file content + INSERT tracking-row in einer TX. Wenn ein
250
+ // Statement bricht, Rollback inkl. Tracking-Row → kein partial-apply
251
+ // stuck in den books.
252
+ await conn.unsafe("BEGIN");
253
+ try {
254
+ for (const stmt of m.statements) {
255
+ await conn.unsafe(stmt);
256
+ }
257
+ await conn.unsafe(`INSERT INTO "_kumiko_migrations" ("id", "checksum") VALUES ($1, $2)`, [
258
+ m.id,
259
+ m.checksum,
260
+ ]);
261
+ await conn.unsafe("COMMIT");
262
+ }
263
+ catch (e) {
264
+ // A failed ROLLBACK means the session is gone (its lock and tx with it); keep the migration error.
265
+ await conn.unsafe("ROLLBACK").catch(() => undefined);
266
+ throw e;
240
267
  }
241
- await tx.unsafe(`INSERT INTO "_kumiko_migrations" ("id", "checksum") VALUES ($1, $2)`, [
242
- m.id,
243
- m.checksum,
244
- ]);
245
- });
246
- appliedIds.push(m.id);
268
+ appliedIds.push(m.id);
269
+ }
270
+ return { applied: appliedIds, skipped: skippedIds };
271
+ }
272
+ finally {
273
+ await conn.unsafe(`SELECT pg_advisory_unlock(${MIGRATION_ADVISORY_LOCK_KEY})`);
247
274
  }
248
- return { applied: appliedIds, skipped: skippedIds };
249
275
  }
250
276
  finally {
251
- await executeRaw(db, `SELECT pg_advisory_unlock(${ADVISORY_LOCK_KEY})`);
277
+ await conn.release();
252
278
  }
253
279
  }
254
280
  // Convenience: load + run in einem Call. Wird vom `kumiko migrate apply`