@cosmicdrift/kumiko-framework 0.367.0 → 0.369.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 (240) hide show
  1. package/AGENTS.md +1 -7
  2. package/README.md +1 -1
  3. package/agents/examples/access-control.md +2 -2
  4. package/agents/examples/anonymous-access-multitenant.md +2 -2
  5. package/agents/examples/anonymous-access.md +3 -3
  6. package/agents/examples/apex-landing.md +1 -1
  7. package/agents/examples/apex-surface-auth.md +6 -7
  8. package/agents/examples/api-worker-split.md +7 -8
  9. package/agents/examples/auth-claims.md +2 -1
  10. package/agents/examples/auth-foundation-providers.md +3 -3
  11. package/agents/examples/auth-mfa.md +3 -4
  12. package/agents/examples/basic-entity.md +3 -4
  13. package/agents/examples/chat-channels.md +4 -4
  14. package/agents/examples/compliance-profiles-demo.md +2 -3
  15. package/agents/examples/cross-feature-events.md +4 -4
  16. package/agents/examples/crypto-shredding-hr.md +14 -9
  17. package/agents/examples/currencies-global.md +3 -4
  18. package/agents/examples/currencies-per-tenant.md +3 -4
  19. package/agents/examples/custom-fields-basic.md +2 -3
  20. package/agents/examples/custom-handlers.md +3 -5
  21. package/agents/examples/dashboard-stats.md +2 -3
  22. package/agents/examples/delivery-notifications.md +5 -6
  23. package/agents/examples/e2e-generator.md +1 -1
  24. package/agents/examples/embedded-entity-form.md +2 -3
  25. package/agents/examples/embedded.md +2 -3
  26. package/agents/examples/encrypted-tenant-config.md +5 -6
  27. package/agents/examples/error-contract.md +3 -4
  28. package/agents/examples/event-sourcing.md +19 -23
  29. package/agents/examples/feature-toggles.md +4 -4
  30. package/agents/examples/field-access.md +3 -4
  31. package/agents/examples/files-post-processing.md +12 -13
  32. package/agents/examples/golden-path.md +208 -0
  33. package/agents/examples/idempotency.md +2 -2
  34. package/agents/examples/inbound-mail-triage.md +7 -7
  35. package/agents/examples/lane-routing.md +3 -3
  36. package/agents/examples/ledger-banking.md +2 -3
  37. package/agents/examples/ledger-invoicing.md +2 -3
  38. package/agents/examples/legal-pages.md +1 -1
  39. package/agents/examples/lifecycle-hooks.md +3 -3
  40. package/agents/examples/managed-config.md +3 -4
  41. package/agents/examples/managed-pages.md +2 -2
  42. package/agents/examples/notes-history-basic.md +4 -5
  43. package/agents/examples/ownership.md +3 -5
  44. package/agents/examples/pdf-audit-export.md +2 -3
  45. package/agents/examples/pipeline-basics.md +2 -2
  46. package/agents/examples/public-share-token.md +3 -3
  47. package/agents/examples/rate-limiting.md +2 -1
  48. package/agents/examples/realtime-sse.md +2 -3
  49. package/agents/examples/record-detail-layout.md +1 -1
  50. package/agents/examples/reference-data.md +3 -3
  51. package/agents/examples/relations.md +2 -2
  52. package/agents/examples/screens-nav.md +1 -1
  53. package/agents/examples/search.md +2 -2
  54. package/agents/examples/secrets-demo.md +8 -8
  55. package/agents/examples/security-baseline.md +2 -2
  56. package/agents/examples/session-revocation.md +7 -8
  57. package/agents/examples/state-machine.md +181 -165
  58. package/agents/examples/tags-basic.md +2 -3
  59. package/agents/examples/tenant-isolation.md +2 -3
  60. package/agents/examples/tenant-settings.md +2 -2
  61. package/agents/examples/tier-admin.md +3 -4
  62. package/agents/examples/timezones.md +3 -3
  63. package/agents/examples/user-data-rights.md +12 -8
  64. package/agents/examples/user-profile.md +3 -3
  65. package/agents/examples/webhook-step.md +2 -3
  66. package/agents/examples/wizard-form.md +4 -5
  67. package/agents/examples/workflow-engine.md +7 -3
  68. package/agents/examples/writeform-section.md +1 -1
  69. package/agents/examples.md +1 -0
  70. package/agents/features.md +4 -4
  71. package/dist/consumer-cli.js +2 -3
  72. package/dist/crypto/index.d.ts +1 -1
  73. package/dist/crypto/index.js +1 -1
  74. package/dist/crypto/owner-transition-rekey.js +2 -0
  75. package/dist/crypto/pg-kms-adapter.js +1 -1
  76. package/dist/crypto/subject-resolver.d.ts +2 -0
  77. package/dist/crypto/subject-resolver.js +30 -15
  78. package/dist/db/bun-db-deprecated.d.ts +10 -0
  79. package/dist/{bun-db/index.js → db/bun-db-deprecated.js} +4 -3
  80. package/dist/db/config-seed.js +1 -1
  81. package/dist/db/connection.d.ts +23 -5
  82. package/dist/db/connection.js +10 -5
  83. package/dist/db/dialect.js +1 -1
  84. package/dist/db/entity-field-encryption.d.ts +5 -0
  85. package/dist/db/entity-field-encryption.js +29 -0
  86. package/dist/db/entity-table-meta.js +6 -5
  87. package/dist/db/event-store-executor-read.js +26 -5
  88. package/dist/db/index.d.ts +4 -4
  89. package/dist/db/index.js +1 -1
  90. package/dist/db/migrate-runner.js +1 -1
  91. package/dist/db/queries/backfill-pii.js +16 -9
  92. package/dist/db/queries/ddl.js +1 -1
  93. package/dist/db/queries/event-store.d.ts +2 -0
  94. package/dist/db/queries/event-store.js +26 -0
  95. package/dist/db/queries/test-stack.js +1 -1
  96. package/dist/db/query.d.ts +105 -1
  97. package/dist/db/query.js +1450 -3
  98. package/dist/{stack/table-helpers.d.ts → db/table-push.d.ts} +5 -15
  99. package/dist/{stack/table-helpers.js → db/table-push.js} +6 -46
  100. package/dist/db/tenant-db.d.ts +11 -0
  101. package/dist/db/tenant-db.js +51 -12
  102. package/dist/derivatives/derivatives-context.d.ts +1 -1
  103. package/dist/derivatives/derivatives-context.js +1 -1
  104. package/dist/engine/boot-validator/gdpr-storage.js +3 -3
  105. package/dist/engine/boot-validator/pii-retention.js +3 -3
  106. package/dist/engine/boot-validator/record-owned.js +3 -3
  107. package/dist/engine/boot-validator/screens.js +37 -1
  108. package/dist/engine/bridge-stub.d.ts +4 -0
  109. package/dist/engine/bridge-stub.js +62 -0
  110. package/dist/engine/build-config-feature-schema.js +197 -65
  111. package/dist/engine/event-helpers.d.ts +2 -1
  112. package/dist/engine/event-helpers.js +1 -0
  113. package/dist/engine/extension-selector-plugins.d.ts +3 -0
  114. package/dist/engine/extension-selector-plugins.js +14 -0
  115. package/dist/engine/feature-config-events-jobs.d.ts +2 -0
  116. package/dist/engine/feature-config-events-jobs.js +3 -2
  117. package/dist/engine/index.d.ts +1 -0
  118. package/dist/engine/index.js +1 -0
  119. package/dist/es-ops/operations-schema.js +1 -1
  120. package/dist/event-store/admin-api.js +1 -1
  121. package/dist/event-store/archive.js +1 -1
  122. package/dist/event-store/errors.d.ts +2 -1
  123. package/dist/event-store/errors.js +6 -2
  124. package/dist/event-store/event-store.js +17 -9
  125. package/dist/event-store/events-schema.js +1 -1
  126. package/dist/event-store/rebuild-dead-letter.js +3 -3
  127. package/dist/event-store/snapshot.js +1 -1
  128. package/dist/event-store/upcaster-dead-letter.js +3 -3
  129. package/dist/files/create-file-ref-from-bytes.js +1 -1
  130. package/dist/files/file-routes.js +1 -1
  131. package/dist/i18n/settings-hub-keys.js +1 -2
  132. package/dist/internal/stack-support.d.ts +12 -0
  133. package/dist/internal/stack-support.js +12 -0
  134. package/dist/internal/testing.d.ts +4 -0
  135. package/dist/internal/testing.js +5 -0
  136. package/dist/jobs/job-runner.js +23 -11
  137. package/dist/migrations/pending-rebuilds.js +1 -1
  138. package/dist/migrations/pii-event-backfill.js +2 -2
  139. package/dist/pipeline/dispatch-shared.js +43 -23
  140. package/dist/pipeline/dispatch-write.js +6 -2
  141. package/dist/pipeline/dispatcher-utils.js +6 -2
  142. package/dist/pipeline/event-consumer-state.js +1 -1
  143. package/dist/pipeline/projection-rebuild.d.ts +1 -0
  144. package/dist/pipeline/projection-rebuild.js +1 -1
  145. package/dist/pipeline/projection-state.js +1 -1
  146. package/dist/pipeline/system-hooks.js +1 -1
  147. package/dist/schema-cli.js +3 -3
  148. package/dist/search/purge-subject.js +20 -10
  149. package/dist/seeding/index.d.ts +1 -0
  150. package/dist/seeding/index.js +1 -0
  151. package/dist/stack/index.d.ts +10 -9
  152. package/dist/stack/index.js +11 -19
  153. package/dist/testing/e2e-generator.d.ts +5 -49
  154. package/dist/testing/e2e-generator.js +5 -382
  155. package/dist/testing/handler-context.d.ts +5 -4
  156. package/dist/testing/handler-context.js +5 -69
  157. package/dist/testing/index.d.ts +10 -26
  158. package/dist/testing/index.js +10 -34
  159. package/dist/testing/real-providers.d.ts +5 -3
  160. package/dist/testing/real-providers.js +5 -21
  161. package/dist/upgrade-cli.js +6 -4
  162. package/package.json +37 -9
  163. package/src/changes.json +64 -0
  164. package/src/scripts/codemod/README.md +43 -0
  165. package/src/scripts/codemod/codemod-support.ts +276 -0
  166. package/src/scripts/codemod/db-factory.ts +810 -0
  167. package/src/scripts/codemod/feature-factory.ts +429 -0
  168. package/src/scripts/codemod/s3-imports.ts +274 -0
  169. package/src/scripts/codemod/tenant-db-options.ts +454 -0
  170. package/src/scripts/codemod/testing-move-targets.ts +161 -0
  171. package/src/scripts/codemod/testing-move.ts +520 -0
  172. package/dist/bun-db/connection.d.ts +0 -17
  173. package/dist/bun-db/connection.js +0 -40
  174. package/dist/bun-db/index.d.ts +0 -4
  175. package/dist/bun-db/query.d.ts +0 -106
  176. package/dist/bun-db/query.js +0 -1457
  177. package/dist/db/api.d.ts +0 -19
  178. package/dist/db/api.js +0 -23
  179. package/dist/db/bun-provider.d.ts +0 -2
  180. package/dist/db/bun-provider.js +0 -28
  181. package/dist/db/postgres-provider.d.ts +0 -2
  182. package/dist/db/postgres-provider.js +0 -21
  183. package/dist/db/query-api.d.ts +0 -2
  184. package/dist/db/query-api.js +0 -6
  185. package/dist/stack/db.d.ts +0 -22
  186. package/dist/stack/db.js +0 -67
  187. package/dist/stack/drain-event-consumers.d.ts +0 -4
  188. package/dist/stack/drain-event-consumers.js +0 -51
  189. package/dist/stack/drain-jobs.d.ts +0 -22
  190. package/dist/stack/drain-jobs.js +0 -141
  191. package/dist/stack/event-collector.d.ts +0 -9
  192. package/dist/stack/event-collector.js +0 -12
  193. package/dist/stack/push-entity-projection-tables.d.ts +0 -24
  194. package/dist/stack/push-entity-projection-tables.js +0 -74
  195. package/dist/stack/redis.d.ts +0 -17
  196. package/dist/stack/redis.js +0 -52
  197. package/dist/stack/request-helper.d.ts +0 -35
  198. package/dist/stack/request-helper.js +0 -144
  199. package/dist/stack/test-stack.d.ts +0 -192
  200. package/dist/stack/test-stack.js +0 -429
  201. package/dist/testing/access-assertions.d.ts +0 -2
  202. package/dist/testing/access-assertions.js +0 -13
  203. package/dist/testing/assertions.d.ts +0 -10
  204. package/dist/testing/assertions.js +0 -22
  205. package/dist/testing/boot-validator-fixture.d.ts +0 -3
  206. package/dist/testing/boot-validator-fixture.js +0 -101
  207. package/dist/testing/closed-connection-error.d.ts +0 -7
  208. package/dist/testing/closed-connection-error.js +0 -46
  209. package/dist/testing/db-cleanup.d.ts +0 -9
  210. package/dist/testing/db-cleanup.js +0 -31
  211. package/dist/testing/expect-error.d.ts +0 -2
  212. package/dist/testing/expect-error.js +0 -22
  213. package/dist/testing/file-provider-contract.d.ts +0 -2
  214. package/dist/testing/file-provider-contract.js +0 -102
  215. package/dist/testing/http-cookies.d.ts +0 -7
  216. package/dist/testing/http-cookies.js +0 -47
  217. package/dist/testing/late-bound.d.ts +0 -9
  218. package/dist/testing/late-bound.js +0 -31
  219. package/dist/testing/manual-clock.d.ts +0 -14
  220. package/dist/testing/manual-clock.js +0 -12
  221. package/dist/testing/multipart-helper.d.ts +0 -22
  222. package/dist/testing/multipart-helper.js +0 -77
  223. package/dist/testing/mutable-master-key-provider.d.ts +0 -7
  224. package/dist/testing/mutable-master-key-provider.js +0 -35
  225. package/dist/testing/observability-recorder.d.ts +0 -9
  226. package/dist/testing/observability-recorder.js +0 -29
  227. package/dist/testing/seed.d.ts +0 -9
  228. package/dist/testing/seed.js +0 -21
  229. package/dist/testing/shared-entities.d.ts +0 -286
  230. package/dist/testing/shared-entities.js +0 -56
  231. package/dist/testing/utils.d.ts +0 -1
  232. package/dist/testing/utils.js +0 -1
  233. package/dist/testing/wait-for.d.ts +0 -20
  234. package/dist/testing/wait-for.js +0 -47
  235. package/dist/testing/without-ambient-temporal.d.ts +0 -5
  236. package/dist/testing/without-ambient-temporal.js +0 -17
  237. package/dist/testing/zip-fixture.d.ts +0 -1
  238. package/dist/testing/zip-fixture.js +0 -33
  239. /package/dist/{stack/test-users.d.ts → seeding/seed-users.d.ts} +0 -0
  240. /package/dist/{stack/test-users.js → seeding/seed-users.js} +0 -0
@@ -91,15 +91,14 @@ import {
91
91
  createEnvMasterKeyProvider,
92
92
  type MasterKeyProvider,
93
93
  } from "@cosmicdrift/kumiko-framework/secrets";
94
+ import { createTestUser, testTenantId } from "@cosmicdrift/kumiko-framework/seeding";
94
95
  import {
95
- createTestUser,
96
96
  resetEventStore,
97
97
  setupTestStack,
98
98
  type TestStack,
99
- testTenantId,
100
99
  unsafeCreateEntityTable,
101
100
  unsafePushTables,
102
- } from "@cosmicdrift/kumiko-framework/stack";
101
+ } from "@cosmicdrift/kumiko-testing/stack";
103
102
 
104
103
  import { incidentEntity, incidentTable, webhookDemoFeature } from "../feature";
105
104
 
@@ -102,19 +102,18 @@ the draft (reopening the wizard starts fresh on step 1).
102
102
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
103
103
  import { createConfigFeature } from "@cosmicdrift/kumiko-bundled-features/config";
104
104
  import {
105
+ createFormDraftFeature,
105
106
  FormDraftHandlers,
106
107
  FormDraftQueries,
107
108
  formDraftEntity,
108
- formDraftFeature,
109
109
  } from "@cosmicdrift/kumiko-bundled-features/form-draft";
110
110
  import type { SaveContext } from "@cosmicdrift/kumiko-framework/engine";
111
+ import { createTestUser, TestUsers } from "@cosmicdrift/kumiko-framework/seeding";
111
112
  import {
112
- createTestUser,
113
113
  setupTestStack,
114
114
  type TestStack,
115
- TestUsers,
116
115
  unsafeCreateEntityTable,
117
- } from "@cosmicdrift/kumiko-framework/stack";
116
+ } from "@cosmicdrift/kumiko-testing/stack";
118
117
  import { listingEntity, listingsFeature } from "../feature";
119
118
 
120
119
  let stack: TestStack;
@@ -131,7 +130,7 @@ const DRAFT_KEY = "listing-wizard";
131
130
  beforeAll(async () => {
132
131
  // form-draft requires the "config" feature (its retention-days setting).
133
132
  stack = await setupTestStack({
134
- features: [listingsFeature, formDraftFeature, createConfigFeature()],
133
+ features: [listingsFeature, createFormDraftFeature(), createConfigFeature()],
135
134
  });
136
135
  await unsafeCreateEntityTable(stack.db, listingEntity);
137
136
  await unsafeCreateEntityTable(stack.db, formDraftEntity);
@@ -60,7 +60,8 @@ import {
60
60
  WORKFLOW_WAITING_TYPE,
61
61
  } from "@cosmicdrift/kumiko-framework/engine";
62
62
  import { append, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
63
- import { setupTestStack, type TestStack, TestUsers } from "@cosmicdrift/kumiko-framework/stack";
63
+ import { TestUsers } from "@cosmicdrift/kumiko-framework/seeding";
64
+ import { setupTestStack, type TestStack } from "@cosmicdrift/kumiko-testing/stack";
64
65
  import { createSuspendedRunFetcher } from "../postgres-resume-loop";
65
66
 
66
67
  let stack: TestStack;
@@ -982,8 +983,11 @@ export function startCronScheduler(
982
983
  ### src/db/queries/suspended-runs.ts
983
984
 
984
985
  ```ts
985
- import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
986
- import { type DbRunner, toPostgresArrayLiteral } from "@cosmicdrift/kumiko-framework/db";
986
+ import {
987
+ asRawClient,
988
+ type DbRunner,
989
+ toPostgresArrayLiteral,
990
+ } from "@cosmicdrift/kumiko-framework/db";
987
991
 
988
992
  export async function selectExpiredSuspensionEvents(
989
993
  db: DbRunner,
@@ -57,7 +57,7 @@ import {
57
57
  defineFeature,
58
58
  validateBoot as validateBootRaw,
59
59
  } from "@cosmicdrift/kumiko-framework/engine";
60
- import { withBootValidatorFixture } from "@cosmicdrift/kumiko-framework/testing";
60
+ import { withBootValidatorFixture } from "@cosmicdrift/kumiko-testing/helpers";
61
61
  import { z } from "zod";
62
62
  import { noteDeskFeature } from "../feature";
63
63
 
@@ -35,6 +35,7 @@
35
35
  | `feature-toggles` | Runtime feature-toggle flip — two toggleable features, one registers a cross-feature hook on the other; disabling either has the documented effect without reboot. | `examples/feature-toggles.md` |
36
36
  | `field-access` | Field-level access control. When entity-level roles aren't granular enough. | `examples/field-access.md` |
37
37
  | `files-post-processing` | The three ways to generate an image variant: declarative field variants, imperative ctx.derivatives.variant() with region-blur, and the public route behind a default-deny predicate. | `examples/files-post-processing.md` |
38
+ | `golden-path` | The smallest complete feature: entity, write handler, event, projection, screen and an integration test. | `examples/golden-path.md` |
38
39
  | `i18n` | Feature-scoped translations. 'Hallo' and 'Hello' in the same app. | `examples/i18n.md` |
39
40
  | `idempotency` | Same request, same result. Dedupes on request ID. | `examples/idempotency.md` |
40
41
  | `inbound-mail-triage` | Inbound-mail consumer pattern: inbound-mail-foundation + in-memory provider, a worker-lane triage job attached to the ingest handler, verified end-to-end through createAllInOneEntrypoint. | `examples/inbound-mail-triage.md` |
@@ -106,7 +106,7 @@ Requires: `delivery`, `secrets`
106
106
 
107
107
  ## channel-email
108
108
 
109
- Wires an `EmailTransport` (typically `mail-transport-smtp` in production, `createInMemoryTransport()` in tests) into the delivery system as the `email` channel. Requires `delivery`; pass an `EmailChannelOptions` with a `transport` and a `resolveEmail` function that maps a user ID to their email address. Mails render from stored templates (`renderer-mail-html`): a notification routed to this channel needs `template` + `meta` on `r.notification`, otherwise its delivery fails. Pass `tenantTransport: tenantMailTransport` (from `mail-foundation`) to send each tenant's mail through its own configured provider: a broken provider fails the send, a tenant without one uses the app `transport`. Notifications declared with `unsubscribe: true` get a signed `data.unsubscribeUrl` from delivery, which turns into the `List-Unsubscribe` headers.
109
+ Wires an `EmailTransport` (typically `mail-transport-smtp` in production, `createInMemoryTransport()` in tests) into the delivery system as the `email` channel. Requires `delivery`; pass an `EmailChannelOptions` with a `transport` and a `resolveEmail` function that maps a user ID to their email address. Mails render from stored templates (`renderer-mail-html`): a notification routed to this channel needs `template` + `meta` on `r.notification`, otherwise its delivery fails. Pass `tenantTransport: tenantMailTransport` (from `mail-foundation`) to send the notifications declared with `tenantTransport: true` through the sending tenant's own configured provider: a broken provider fails the send, a tenant without one uses the app `transport`; every other notification (password reset, sign-in) always goes through the app `transport`. `transport` may be left out when `tenantTransport` is set; a mail that then has no transport fails with `missing_credentials`. Notifications declared with `unsubscribe: true` get a signed `data.unsubscribeUrl` from delivery, which turns into the `List-Unsubscribe` headers.
110
110
 
111
111
  Category: notifications
112
112
 
@@ -206,7 +206,7 @@ Exposes APIs: `retention.policyFor`
206
206
 
207
207
  ## delivery
208
208
 
209
- The notification dispatch core: call `ctx.notify(notificationType, { to, route, data, priority, idempotencyKey })` from any handler to fan out a notification across all registered channels (email, in-app, push). Inside a write handler the delivery runs after the write has committed (the call returns `{ kind: "deferred" }`, a rolled-back write sends nothing); `immediate: true` delivers synchronously and returns `{ kind: "dispatched", deliveries }`. It stores per-user channel preferences in the `notification-preference` entity, opt-outs for no-account recipient addresses in `notification-address-opt-out` (keyed by a blind-index hash, never the plaintext address), logs every attempt to `store_delivery_attempts`, and enforces idempotency and rate-limiting — add `channel-email`, `channel-in-app`, or `channel-push` on top to actually send anything. Unsubscribe/resubscribe links are served by `createUnsubscribeRoutes({ secret })` mounted via the app's `extraRoutes` at `/api/delivery/unsubscribe` and `/api/delivery/resubscribe`: `GET /unsubscribe?token=` renders a confirmation page (no write), `POST /unsubscribe` performs the opt-out (RFC 8058 one-click, token from the form body or query), `POST /resubscribe` undoes it (JSON `{token}` or form body only, never the query — reachable by a mail-client link prefetcher) — sign links with `signUnsubscribeToken` / `signAddressUnsubscribeToken` using the same secret. `channel-email` sets `List-Unsubscribe` / `List-Unsubscribe-Post` automatically when a message's `data.unsubscribeUrl` points at the unsubscribe route. A notification declared with `unsubscribe: true` gets that link from delivery itself when the service has `unsubscribeLinks` (`runProdApp` option `unsubscribe: { secret }` mounts the routes and wires it; base URL from `auth.publicOrigin`, `auth.mail.baseUrl` or `APP_BASE_URL`): user recipients get a user token, `route` recipients an address token (needs a blind-index key, otherwise no link); a `data.unsubscribeUrl` from the caller wins. `unsubscribe.tenantOrigin: (ctx) => origin | null` points the link at the tenant's own host (subdomain or custom domain, must serve this app's `/api`); null, a throw or a non-https value fall back to the app origin. The token carries the mail's locale, so the confirmation and result pages render in the mail's language. To react to a no-account opt-out (e.g. remove the app's own subscriber row), declare `r.job(name, { trigger: { on: DeliveryHandlers.unsubscribeAddress } }, handler)` on a lane the app consumes: it runs after each committed opt-out, also an idempotent repeat, with `unsubscribeAddressPayloadSchema` (`addressHash`, `notificationType`, `channel`) as payload and the tenant in `ctx.triggeredBy.tenantId`; match the subscriber via `hashUnsubscribeAddress(email)`. Declare `unsubscribeUrl` as an optional template variable if the mail body shows it.
209
+ The notification dispatch core: call `ctx.notify(notificationType, { to, route, data, priority, idempotencyKey })` from any handler to fan out a notification across all registered channels (email, in-app, push). Inside a write handler the delivery runs after the write has committed (the call returns `{ kind: "deferred" }`, a rolled-back write sends nothing); `immediate: true` delivers synchronously and returns `{ kind: "dispatched", deliveries }`. It stores per-user channel preferences in the `notification-preference` entity, opt-outs for no-account recipient addresses in `notification-address-opt-out` (keyed by a blind-index hash, never the plaintext address), logs every attempt to `store_delivery_attempts`, and enforces idempotency and rate-limiting — add `channel-email`, `channel-in-app`, or `channel-push` on top to actually send anything. Unsubscribe/resubscribe links are served by `createUnsubscribeRoutes({ secret })` mounted via the app's `extraRoutes` at `/api/delivery/unsubscribe` and `/api/delivery/resubscribe`: `GET /unsubscribe?token=` renders a confirmation page (no write), `POST /unsubscribe` performs the opt-out (RFC 8058 one-click, token from the form body or query), `POST /resubscribe` undoes it (JSON `{token}` or form body only, never the query — reachable by a mail-client link prefetcher) — sign links with `signUnsubscribeToken` / `signAddressUnsubscribeToken` using the same secret. `channel-email` sets `List-Unsubscribe` / `List-Unsubscribe-Post` automatically when a message's `data.unsubscribeUrl` points at the unsubscribe route. A notification declared with `unsubscribe: true` gets that link from delivery itself when the service has `unsubscribeLinks` (the `runProdApp` and `runDevApp` option `unsubscribe: { secret }` mounts the routes and wires it; base URL from `auth.publicOrigin`, `auth.mail.baseUrl` or `APP_BASE_URL`): user recipients get a user token, `route` recipients an address token (needs a blind-index key, otherwise no link); a `data.unsubscribeUrl` from the caller wins. `unsubscribe.tenantOrigin: (ctx) => origin | null` points the link at the tenant's own host (subdomain or custom domain, must serve this app's `/api`); null, a throw or a non-https value fall back to the app origin. The token carries the mail's locale, so the confirmation and result pages render in the mail's language. An app serving its own (e.g. legacy) unsubscribe links renders the same result page with `renderUnsubscribeResultPage({ kind, locale })`. To react to a no-account opt-out (e.g. remove the app's own subscriber row), declare `r.job(name, { trigger: { on: DeliveryHandlers.unsubscribeAddress } }, handler)` on a lane the app consumes: it runs after each committed opt-out, also an idempotent repeat, with `unsubscribeAddressPayloadSchema` (`addressHash`, `notificationType`, `channel`) as payload and the tenant in `ctx.triggeredBy.tenantId`; match the subscriber via `hashUnsubscribeAddress(email)`. Declare `unsubscribeUrl` as an optional template variable if the mail body shows it.
210
210
 
211
211
  Category: notifications
212
212
 
@@ -413,7 +413,7 @@ German UI and mail copy for framework screens. Opt-in; not included by includeBu
413
413
 
414
414
  ## mail-foundation
415
415
 
416
- Defines the `mailTransport` extension point and a per-tenant `provider` config key that selects which registered transport plugin to use at runtime. Call `createTransportForTenant(ctx, tenantId)` to get an `EmailTransport` ready for sending — use this feature together with at least one `mail-transport-*` feature; use `delivery` + `channel-email` instead when you need the full notification pipeline with delivery attempts and user preferences. Pass `tenantMailTransport` as `tenantTransport` to `createChannelEmailFeature` so a tenant with a provider configured sends delivery mails through its own transport and other tenants through the app transport.
416
+ Defines the `mailTransport` extension point and a per-tenant `provider` config key that selects which registered transport plugin to use at runtime. Call `createTransportForTenant(ctx, tenantId)` to get an `EmailTransport` ready for sending — use this feature together with at least one `mail-transport-*` feature; use `delivery` + `channel-email` instead when you need the full notification pipeline with delivery attempts and user preferences. Pass `tenantMailTransport` as `tenantTransport` to `createChannelEmailFeature`: notifications declared with `tenantTransport: true` then go through the tenant's own transport when it has a provider and through the app transport otherwise. Tenant admins pick the provider and fill in its settings on the generated Email sending settings screen; an empty provider is a valid choice and means the platform sends.
417
417
 
418
418
  Category: notifications
419
419
 
@@ -558,7 +558,7 @@ Requires: `renderer-foundation`
558
558
 
559
559
  ## secrets
560
560
 
561
- Stores arbitrary per-tenant secrets (API keys, tokens, credentials) encrypted at rest using AES-256 with a KEK loaded from `KUMIKO_SECRETS_MASTER_KEY_V1` (and successive versions for rotation). Read a secret in handlers via `ctx.secrets.get(tenantId, handle)`, which automatically appends a `tenantSecretRead` audit event so every access is traceable. A `rotate` job re-encrypts all envelopes after a KEK version bump. The `set`/`delete`/`list` handlers share one access rule — default { roles: ["TenantAdmin"] }; adopt the host's role vocabulary with createSecretsFeature({ roles }) or open it to every authenticated tenant user with { access: { openToAll: { reason } } } (larger blast radius: any tenant member can then read secret previews and write/delete secrets, not just admins).
561
+ Stores arbitrary per-tenant secrets (API keys, tokens, credentials) encrypted at rest using AES-256 with a KEK loaded from `KUMIKO_SECRETS_MASTER_KEY_V1` (and successive versions for rotation). Read a secret in handlers via `ctx.secrets.get(tenantId, handle)`, which automatically appends a `tenantSecretRead` audit event so every access is traceable. A `rotate` job re-encrypts all envelopes after a KEK version bump. The `set`/`delete`/`list` handlers share one access rule — default { roles: ["TenantAdmin"] }; adopt the host's role vocabulary with createSecretsFeature({ roles }) or open it to every authenticated tenant user with { access: { openToAll: { reason } } } (larger blast radius: any tenant member can then read secret previews and write/delete secrets, not just admins). Server-side code writes a tenant secret with `dispatchSystemWrite({ handlerQn: "secrets:write:set", payload, tenantId })`: set and delete also admit the in-process system identity, so such writes pass the same declared-key and value checks.
562
562
 
563
563
  Category: infrastructure
564
564
 
@@ -5,8 +5,7 @@
5
5
  // kumiko_event_consumers. Mirrors schema-cli.ts's shape (single runXCli(argv,
6
6
  // out) entry point, own DB connection) so `kumiko-consumer` ships the same
7
7
  // way `kumiko-schema` does.
8
- import { createConnection } from "./db/api.js";
9
- import { dbConnectionOptionsFromEnv } from "./db/connection.js";
8
+ import { createDbConnection, dbConnectionOptionsFromEnv } from "./db/connection.js";
10
9
  import { getConsumerState, restartConsumer } from "./pipeline/index.js";
11
10
  import { ensureTemporalPolyfill } from "./time/index.js";
12
11
  // Single pass: pulls --instance-id (both "--instance-id <id>" and
@@ -80,7 +79,7 @@ export async function runConsumerCli(argv, out) {
80
79
  out.err(" DATABASE_URL not set.");
81
80
  return 1;
82
81
  }
83
- const { db, close } = await createConnection(dbUrl, dbConnectionOptionsFromEnv());
82
+ const { db, close } = createDbConnection({ url: dbUrl, ...dbConnectionOptionsFromEnv() });
84
83
  try {
85
84
  if (sub === "status") {
86
85
  const state = await getConsumerState(db, name, instanceId);
@@ -8,4 +8,4 @@ export { type ActiveKmsWiring, buildPgKmsOptions, type KmsWiring, type KmsWiring
8
8
  export { createPgKmsAdapter, PgKmsAdapter, type PgKmsAdapterOptions, type RewrapOptions, type RewrapResult, rewrapSubjectKeys, } from "./pg-kms-adapter.js";
9
9
  export { configuredPiiSubjectKms, configurePiiSubjectKms, decryptPiiFieldValues, decryptPiiValueForSubject, type EncryptPiiOptions, encryptPiiFieldValues, encryptPiiJsonValueForSubject, encryptPiiValueForSubject, isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_CIPHERTEXT_PREFIX_JSON, PII_ERASED_SENTINEL, } from "./pii-field-encryption.js";
10
10
  export { createRequestKmsCache, type RequestKmsCache, } from "./request-kms-cache.js";
11
- export { collectPiiEncryptedFields, collectPiiSubjectFields, collectSearchableSubjectFields, type ResolveSubjectOptions, resolveSubjectForField, SubjectResolutionError, } from "./subject-resolver.js";
11
+ export { collectPiiEncryptedFields, collectPiiSubjectFields, collectSearchableSubjectFields, isRecordKeyedField, type ResolveSubjectOptions, resolveSubjectForField, SubjectResolutionError, } from "./subject-resolver.js";
@@ -8,4 +8,4 @@ export { buildPgKmsOptions, requireKmsWiring, requireKmsWiringAsync, resolveKmsW
8
8
  export { createPgKmsAdapter, PgKmsAdapter, rewrapSubjectKeys, } from "./pg-kms-adapter.js";
9
9
  export { configuredPiiSubjectKms, configurePiiSubjectKms, decryptPiiFieldValues, decryptPiiValueForSubject, encryptPiiFieldValues, encryptPiiJsonValueForSubject, encryptPiiValueForSubject, isPiiCiphertext, PII_CIPHERTEXT_PREFIX, PII_CIPHERTEXT_PREFIX_JSON, PII_ERASED_SENTINEL, } from "./pii-field-encryption.js";
10
10
  export { createRequestKmsCache, } from "./request-kms-cache.js";
11
- export { collectPiiEncryptedFields, collectPiiSubjectFields, collectSearchableSubjectFields, resolveSubjectForField, SubjectResolutionError, } from "./subject-resolver.js";
11
+ export { collectPiiEncryptedFields, collectPiiSubjectFields, collectSearchableSubjectFields, isRecordKeyedField, resolveSubjectForField, SubjectResolutionError, } from "./subject-resolver.js";
@@ -12,6 +12,8 @@ export function isPlainUserOwnedField(field) {
12
12
  // owner. Returns the unchanged-by-the-caller values that must be written again
13
13
  // so they encrypt under the new owner. Owner → absent is not a transition:
14
14
  // the value stays with the last owner so that owner's forget still erases it.
15
+ // Absent → owner moves a whenAbsent value (tenant or record key) to the owner's
16
+ // key; the created event stays keyed under the whenAbsent subject.
15
17
  export function personalFieldsRekeyedOnOwnerTransition(entity, previous, mergedNew, submittedChanges) {
16
18
  const rekeyed = {};
17
19
  for (const [name, field] of Object.entries(entity.fields)) {
@@ -1,6 +1,6 @@
1
1
  import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
2
2
  import postgres from "postgres";
3
- import { DEFAULT_DB_CLOSE_TIMEOUT_SECONDS } from "../db/api.js";
3
+ import { DEFAULT_DB_CLOSE_TIMEOUT_SECONDS } from "../db/connection.js";
4
4
  import { KeyAlreadyExistsError, KeyErasedError, KeyNotFoundError, subjectIdToKey, } from "./kms-adapter.js";
5
5
  const IV_LENGTH = 12;
6
6
  const TAG_LENGTH = 16;
@@ -28,6 +28,8 @@ export interface EventSubjectEnvelope {
28
28
  readonly aggregateId: string;
29
29
  }
30
30
  export declare function resolveEventSubject(fieldName: string, spec: EventPiiSubject, payload: Record<string, unknown>, envelope: EventSubjectEnvelope): SubjectId | null;
31
+ /** True when the field can be keyed on the row's own `record:<entity>:<id>` subject. */
32
+ export declare function isRecordKeyedField(field: EntityDefinition["fields"][string]): boolean;
31
33
  export declare function collectPiiSubjectFields(entity: EntityDefinition): readonly string[];
32
34
  export declare function collectPiiEncryptedFields(entity: EntityDefinition): readonly string[];
33
35
  export declare function collectPiiFileReferenceFields(entity: EntityDefinition): readonly string[];
@@ -34,10 +34,13 @@ function resolveUserOwnedSubject(userOwned, fieldName, row, opts) {
34
34
  const userId = nonEmptyString(row[ownerField]) ?? nonEmptyString(opts.ownerFallback?.[ownerField]);
35
35
  if (userId !== null)
36
36
  return { kind: "user", userId };
37
- if (userOwned.whenAbsent === "tenant" && opts.ignoreWhenAbsent !== true) {
38
- return resolveTenantSubject(fieldName, row, opts);
37
+ if (opts.ignoreWhenAbsent !== true) {
38
+ if (userOwned.whenAbsent === "tenant")
39
+ return resolveTenantSubject(fieldName, row, opts);
40
+ if (userOwned.whenAbsent === "self")
41
+ return resolveRecordSubject(fieldName, row, opts);
39
42
  }
40
- throw new SubjectResolutionError(fieldName, `owner field "${ownerField}" is empty on the row and the field declares no whenAbsent — declare { personal: { of: "${ownerField}", whenAbsent: "tenant" } } (fw#3654)`);
43
+ throw new SubjectResolutionError(fieldName, `owner field "${ownerField}" is empty on the row and the field declares no whenAbsent — declare { personal: { of: "${ownerField}", whenAbsent: "tenant" } } or whenAbsent: "self" to key it on the row itself (fw#3654)`);
41
44
  }
42
45
  function resolveTenantSubject(fieldName, row, opts) {
43
46
  const tenantId = nonEmptyString(row["tenantId"]) ?? opts.tenantId;
@@ -87,6 +90,19 @@ function resolveEnvelopeTenant(fieldName, envelope) {
87
90
  }
88
91
  return { kind: "tenant", tenantId: envelope.tenantId };
89
92
  }
93
+ function resolveEnvelopeRecordSubject(fieldName, envelope) {
94
+ if (nonEmptyString(envelope.aggregateType) === null ||
95
+ nonEmptyString(envelope.aggregateId) === null) {
96
+ throw new SubjectResolutionError(fieldName, "event envelope aggregateType/aggregateId is empty");
97
+ }
98
+ // aggregate_type is a free-form text column (events-schema.ts) — reject
99
+ // here rather than mint a record key that subjectIdSchema (kms-adapter.ts)
100
+ // would later refuse to shred.
101
+ if (!RECORD_ENTITY_PATTERN.test(envelope.aggregateType)) {
102
+ throw new SubjectResolutionError(fieldName, `event aggregateType "${envelope.aggregateType}" does not match ${RECORD_ENTITY_PATTERN} — not a shreddable record subject`);
103
+ }
104
+ return { kind: "record", entity: envelope.aggregateType, id: envelope.aggregateId };
105
+ }
90
106
  // Same resolver for the live-append path (encryptEventPayloadPii) and the
91
107
  // backfill catalog path (backfillEventPiiEncryption) — the reason the two
92
108
  // can never encrypt the same field under different subjects. A "user"
@@ -101,34 +117,33 @@ export function resolveEventSubject(fieldName, spec, payload, envelope) {
101
117
  return { kind: "user", userId };
102
118
  if (normalized.whenAbsent === "tenant")
103
119
  return resolveEnvelopeTenant(fieldName, envelope);
120
+ if (normalized.whenAbsent === "self")
121
+ return resolveEnvelopeRecordSubject(fieldName, envelope);
104
122
  if (normalized.whenAbsent === "plaintext") {
105
123
  // skip: author declared whenAbsent: "plaintext" — value ships unencrypted by decision
106
124
  return null;
107
125
  }
108
126
  throw new SubjectResolutionError(fieldName, `owner field "${normalized.ownerField}" carries no id and the event declares no whenAbsent fallback — ` +
109
127
  `refusing to append plaintext PII. Declare { personal: { of: "${normalized.ownerField}", ` +
110
- `whenAbsent: "tenant" } } to encrypt under the envelope tenant key, or whenAbsent: "plaintext" ` +
128
+ `whenAbsent: "tenant" } } to encrypt under the envelope tenant key, whenAbsent: "self" to encrypt ` +
129
+ `under the event's own record key, or whenAbsent: "plaintext" ` +
111
130
  "to acknowledge that this value cannot be crypto-shredded (fw#2776).");
112
131
  }
113
132
  if (normalized.kind === "tenant") {
114
133
  return resolveEnvelopeTenant(fieldName, envelope);
115
134
  }
116
135
  if (normalized.kind === "self") {
117
- if (nonEmptyString(envelope.aggregateType) === null ||
118
- nonEmptyString(envelope.aggregateId) === null) {
119
- throw new SubjectResolutionError(fieldName, "event envelope aggregateType/aggregateId is empty");
120
- }
121
- // aggregate_type is a free-form text column (events-schema.ts) — reject
122
- // here rather than mint a record key that subjectIdSchema (kms-adapter.ts)
123
- // would later refuse to shred.
124
- if (!RECORD_ENTITY_PATTERN.test(envelope.aggregateType)) {
125
- throw new SubjectResolutionError(fieldName, `event aggregateType "${envelope.aggregateType}" does not match ${RECORD_ENTITY_PATTERN} — not a shreddable record subject`);
126
- }
127
- return { kind: "record", entity: envelope.aggregateType, id: envelope.aggregateId };
136
+ return resolveEnvelopeRecordSubject(fieldName, envelope);
128
137
  }
129
138
  const exhaustiveCheck = normalized;
130
139
  throw new Error(`Unhandled normalized event PII subject: ${JSON.stringify(exhaustiveCheck)}`);
131
140
  }
141
+ /** True when the field can be keyed on the row's own `record:<entity>:<id>` subject. */
142
+ export function isRecordKeyedField(field) {
143
+ if ("recordOwned" in field && field.recordOwned === true)
144
+ return true;
145
+ return "userOwned" in field && field.userOwned?.whenAbsent === "self";
146
+ }
132
147
  function hasSubjectAnnotation(field) {
133
148
  return (("userOwned" in field && field.userOwned !== undefined) ||
134
149
  ("tenantOwned" in field && field.tenantOwned === true) ||
@@ -0,0 +1,10 @@
1
+ /**
2
+ * @deprecated Moved to `@cosmicdrift/kumiko-framework/db`. Run `bun kumiko-upgrade --apply`
3
+ * to rewrite imports. Removed in the first release after 2026-10-29.
4
+ */
5
+ export type { DeleteManyBatchedOptions, DeleteManyBatchedResult, IncrementCounterOptions, InnerJoinRow, InnerJoinSpec, InsertOnConflictDoNothingOptions, JoinColumnPair, JsonTextMatch, SelectOptions, TableInfo, UpsertOnConflictOptions, WhereObject, WhereOperator, WhereValue, } from "./query.js";
6
+ /**
7
+ * @deprecated Moved to `@cosmicdrift/kumiko-framework/db`. Run `bun kumiko-upgrade --apply`
8
+ * to rewrite imports. Removed in the first release after 2026-10-29.
9
+ */
10
+ export { aggregateWhere, asEntityTableMeta, asRawClient, countWhere, deleteMany, deleteManyBatched, extractTableInfo, fetchOne, incrementCounter, insertMany, insertOnConflictDoNothing, insertOne, instantToTimestamptzParam, isTimestamptzType, requireEntityTableMeta, runInSavepoint, runInSavepointIfSupported, runnerCanBeginTransaction, selectInnerJoin, selectMany, transaction, unsafeReadRetrying, updateMany, upsertByPk, upsertOnConflict, } from "./query.js";
@@ -1,4 +1,5 @@
1
- // bun-db: Bun.sql-basierte DB-API ohne drizzle.
2
- // Production-Pfad nach drizzle-removal.
3
- export { bunDbConnectionOptionsFromEnv, createBunDbConnection } from "./connection.js";
1
+ /**
2
+ * @deprecated Moved to `@cosmicdrift/kumiko-framework/db`. Run `bun kumiko-upgrade --apply`
3
+ * to rewrite imports. Removed in the first release after 2026-10-29.
4
+ */
4
5
  export { aggregateWhere, asEntityTableMeta, asRawClient, countWhere, deleteMany, deleteManyBatched, extractTableInfo, fetchOne, incrementCounter, insertMany, insertOnConflictDoNothing, insertOne, instantToTimestamptzParam, isTimestamptzType, requireEntityTableMeta, runInSavepoint, runInSavepointIfSupported, runnerCanBeginTransaction, selectInnerJoin, selectMany, transaction, unsafeReadRetrying, updateMany, upsertByPk, upsertOnConflict, } from "./query.js";
@@ -24,7 +24,7 @@ export async function seedConfigValues(seeds, table, entity, registry, db, ciphe
24
24
  return { created, skipped };
25
25
  const systemUser = createSystemUser(SYSTEM_TENANT_ID);
26
26
  const executor = createEventStoreExecutor(table, entity, { entityName: "config-value" });
27
- const tdb = createTenantDb(db, SYSTEM_TENANT_ID, "system");
27
+ const tdb = createTenantDb({ db, tenantId: SYSTEM_TENANT_ID, mode: "system" });
28
28
  for (const seed of seeds) {
29
29
  const keyDef = registry.getConfigKey(seed.key);
30
30
  if (!keyDef) {
@@ -1,12 +1,30 @@
1
1
  import type { DbConnection, PgClient } from "@cosmicdrift/kumiko-types/db-connection";
2
+ import postgres from "postgres";
2
3
  export type * from "@cosmicdrift/kumiko-types/db-connection";
3
- export type { DbConnectionOptions, DbPoolHandle } from "./api.js";
4
- export { createConnection } from "./api.js";
5
- export declare function createDbConnection(url: string, options?: import("./api.js").DbConnectionOptions): {
4
+ type PostgresDriverOptions = NonNullable<Parameters<typeof postgres>[1]>;
5
+ export type DbConnectionOptions = {
6
+ readonly maxConnections?: number;
7
+ readonly idleTimeoutSeconds?: number;
8
+ readonly connectTimeoutSeconds?: number;
9
+ readonly closeTimeoutSeconds?: number;
10
+ readonly ssl?: PostgresDriverOptions["ssl"];
11
+ readonly onNotice?: (notice: postgres.Notice) => void;
12
+ };
13
+ export type CreateDbConnectionOptions = DbConnectionOptions & {
14
+ readonly url: string;
15
+ };
16
+ export type DbConnectionHandle = {
6
17
  db: DbConnection;
7
18
  client: PgClient;
8
19
  close: () => Promise<void>;
9
20
  };
10
- export declare function dbConnectionOptionsFromEnv(env?: Readonly<Record<string, string | undefined>>): import("./api.js").DbConnectionOptions;
21
+ export declare const DEFAULT_DB_CLOSE_TIMEOUT_SECONDS = 5;
22
+ export declare function createDbConnection(options: CreateDbConnectionOptions): DbConnectionHandle;
23
+ /**
24
+ * @deprecated Positional form, use `createDbConnection({ url, ... })`. Run
25
+ * `bun kumiko-upgrade --apply` to rewrite call sites. Removed in the first release after 2026-10-29.
26
+ */
27
+ export declare function createDbConnection(url: string, options?: DbConnectionOptions): DbConnectionHandle;
28
+ export declare function dbConnectionOptionsFromEnv(env?: Readonly<Record<string, string | undefined>>): DbConnectionOptions;
11
29
  export declare const DEFAULT_OUTSIDE_TRANSACTION_POOL_MAX = 3;
12
- export declare function outsideTransactionPoolOptionsFromEnv(env?: Readonly<Record<string, string | undefined>>): import("./api.js").DbConnectionOptions;
30
+ export declare function outsideTransactionPoolOptionsFromEnv(env?: Readonly<Record<string, string | undefined>>): DbConnectionOptions;
@@ -1,10 +1,11 @@
1
1
  import postgres from "postgres";
2
2
  import { readPositiveIntEnv } from "../utils/env-parse.js";
3
- import { DEFAULT_DB_CLOSE_TIMEOUT_SECONDS } from "./api.js";
4
- export { createConnection } from "./api.js";
5
- // Legacy: postgres-js only. Neue Aufrufer: createConnection() aus api.ts.
6
- // guard:dup-ok — andere Layer als createPgConnection (gibt DbConnection zurück, nicht postgres-Instanz)
7
- export function createDbConnection(url, options = {}) {
3
+ // A DB outage can leave an in-flight query referenced on a dead connection,
4
+ // so a plain end() (no timeout = wait for queries to finish) hangs forever —
5
+ // close() must always bound how long shutdown waits.
6
+ export const DEFAULT_DB_CLOSE_TIMEOUT_SECONDS = 5;
7
+ export function createDbConnection(urlOrOptions, legacyOptions = {}) {
8
+ const { url, ...options } = typeof urlOrOptions === "string" ? { ...legacyOptions, url: urlOrOptions } : urlOrOptions;
8
9
  const pgOptions = {};
9
10
  if (options.maxConnections !== undefined)
10
11
  pgOptions.max = options.maxConnections;
@@ -13,6 +14,10 @@ export function createDbConnection(url, options = {}) {
13
14
  if (options.connectTimeoutSeconds !== undefined) {
14
15
  pgOptions.connect_timeout = options.connectTimeoutSeconds;
15
16
  }
17
+ if (options.ssl !== undefined)
18
+ pgOptions.ssl = options.ssl;
19
+ if (options.onNotice !== undefined)
20
+ pgOptions.onnotice = options.onNotice;
16
21
  const client = postgres(url, pgOptions);
17
22
  return {
18
23
  db: client,
@@ -251,7 +251,7 @@ export function primaryKey(opts) {
251
251
  // Limits: no nested SqlExpression composition (drizzle's recursive
252
252
  // `sql\`${other}\``) — schema-files use single-level expressions only.
253
253
  // Unforgeable via JSON — a client-supplied jsonb value can fake `kind:
254
- // "sql-expr"` but can never carry a Symbol, so isSqlExpression() (bun-db/query.ts)
254
+ // "sql-expr"` but can never carry a Symbol, so isSqlExpression() (db/query.ts)
255
255
  // can't be tricked into treating request data as a raw SQL literal. The same
256
256
  // brand gate is enforced here in the schema DSL (sql`...` interpolation +
257
257
  // literalDefault) and in entity-table-meta's sqlExpressionText — a duck-typed
@@ -3,6 +3,11 @@ import { type Logger } from "../logging/index.js";
3
3
  import type { EnvelopeCipher } from "../secrets/envelope-cipher.js";
4
4
  export declare function collectEncryptedFieldNames(entity: EntityDefinition): ReadonlySet<string>;
5
5
  export declare function isSensitiveLabelField(entity: EntityDefinition, field: string): boolean;
6
+ export declare function isListFilterAllowed(entity: EntityDefinition, field: string, filter: {
7
+ readonly op: string;
8
+ readonly value: unknown;
9
+ }): boolean;
10
+ export declare function isListSortAllowed(entity: EntityDefinition, field: string): boolean;
6
11
  export declare function hasSearchablePlaintext(entity: EntityDefinition, field: string): boolean;
7
12
  export declare function encryptEntityFieldValues(row: Record<string, unknown>, encryptedFields: ReadonlySet<string>, cipher: EnvelopeCipher, opts?: {
8
13
  onlyKeys?: Iterable<string>;
@@ -22,6 +22,35 @@ export function isSensitiveLabelField(entity, field) {
22
22
  return (collectEncryptedFieldNames(entity).has(field) ||
23
23
  collectPiiEncryptedFields(entity).includes(field));
24
24
  }
25
+ // A `sensitive` (find: "secret") field is never comparable either, even while its value is still
26
+ // legacy plaintext.
27
+ function holdsCiphertextOrSecret(entity, field) {
28
+ const def = entity.fields[field];
29
+ if (def === undefined)
30
+ return false;
31
+ return ("sensitive" in def && def.sensitive === true) || isSensitiveLabelField(entity, field);
32
+ }
33
+ // Only a PII field with a blind index (`find: "exact" | "fuzzy"`) can be matched without reading
34
+ // the stored value: the `<field>Bidx` HMAC arm of an equality filter.
35
+ function isBlindIndexEqualityTarget(entity, field) {
36
+ const def = entity.fields[field];
37
+ return (def?.type === "text" &&
38
+ def.lookupable === true &&
39
+ def.sensitive !== true &&
40
+ !collectEncryptedFieldNames(entity).has(field));
41
+ }
42
+ // Filtering ciphertext (or a secret) turns hit/count into a value oracle, and for legacy plaintext
43
+ // rows an `in`/range comparison into a prefix oracle. Blind-index equality is the one exception.
44
+ export function isListFilterAllowed(entity, field, filter) {
45
+ if (!holdsCiphertextOrSecret(entity, field))
46
+ return true;
47
+ return (isBlindIndexEqualityTarget(entity, field) &&
48
+ filter.op === "eq" &&
49
+ typeof filter.value === "string");
50
+ }
51
+ export function isListSortAllowed(entity, field) {
52
+ return !holdsCiphertextOrSecret(entity, field);
53
+ }
25
54
  // `searchable: true` on a text field means it is indexed plaintext
26
55
  // somewhere findable by term — a plain text field's own column, or (for a
27
56
  // personal-data field) the derived search index mirroring it.
@@ -15,7 +15,7 @@
15
15
  // entity-shaped base columns. Escape hatch: no forced audit trail for the
16
16
  // hand-built path; app author owns tenant scoping / versioning. Prefer a
17
17
  // `store_` table name — `read_` is reserved for managed projections (#1208/#1220).
18
- import { collectPiiEncryptedFields } from "../crypto/index.js";
18
+ import { collectPiiEncryptedFields, isRecordKeyedField } from "../crypto/index.js";
19
19
  import { isSqlExpression } from "./dialect.js";
20
20
  import { READ_MODEL_PREFIX, SOFT_DELETE_LIVE_ROW_PREDICATE, toSnakeCase, toTableName, } from "./table-builder.js";
21
21
  // Standard base columns for event-sourced read-model tables. Mirrors
@@ -224,11 +224,12 @@ export function deriveEntityTableMeta(entityName, entity, options) {
224
224
  const idType = entity.idType ?? "uuid";
225
225
  if (source === "unmanaged" && idType === "serial") {
226
226
  // Unmanaged direct-write stores bypass validateRecordOwnedSubjects (it only walks
227
- // feature.entities), yet encryptForDirectWrite still keys recordOwned fields as
228
- // `record:<entity>:<id>`; forgetSubject requires a UUID, so the value could never be shredded.
229
- const recordOwnedField = Object.entries(entity.fields).find(([, field]) => "recordOwned" in field && field.recordOwned === true);
227
+ // feature.entities), yet encryptForDirectWrite still keys recordOwned and
228
+ // whenAbsent: "self" fields as `record:<entity>:<id>`; forgetSubject requires a UUID,
229
+ // so the value could never be shredded.
230
+ const recordOwnedField = Object.entries(entity.fields).find(([, field]) => isRecordKeyedField(field));
230
231
  if (recordOwnedField) {
231
- throw new Error(`deriveEntityTableMeta("${entityName}", { source: "unmanaged" }): field "${recordOwnedField[0]}" is recordOwned but the entity declares idType: "serial" — the record subject key is "record:${entityName}:<id>" and forgetSubject validates that id as a UUID, so this field could never be shredded. Use idType: "uuid", or give the field a different personal stance.`);
232
+ throw new Error(`deriveEntityTableMeta("${entityName}", { source: "unmanaged" }): field "${recordOwnedField[0]}" is recordOwned (or whenAbsent: "self") but the entity declares idType: "serial" — the record subject key is "record:${entityName}:<id>" and forgetSubject validates that id as a UUID, so this field could never be shredded. Use idType: "uuid", or give the field a different personal stance.`);
232
233
  }
233
234
  }
234
235
  // Base columns first, then user-fields. User-fields with the same
@@ -5,12 +5,12 @@ import { executeRawQueryRead } from "../db/queries/raw-sql.js";
5
5
  import { coerceRow, extractTableInfo } from "../db/query.js";
6
6
  import { buildOwnershipClause, combineClauses, normalizeAccessEntry, shiftParams, } from "../engine/ownership.js";
7
7
  import { SYSTEM_TENANT_ID } from "../engine/types/identifiers.js";
8
- import { UnprocessableError } from "../errors/index.js";
8
+ import { UnprocessableError, ValidationError } from "../errors/index.js";
9
9
  import { getStreamVersion } from "../event-store/index.js";
10
10
  import { LIST_ROW_META_REFERENCES } from "../ui-types/list-row-meta.js";
11
11
  import { rehydrateCompoundTypes } from "./compound-types.js";
12
12
  import { decodeKeysetCursor, encodeCursor, encodeKeysetCursor } from "./cursor.js";
13
- import { hasSearchablePlaintext, isSensitiveLabelField } from "./entity-field-encryption.js";
13
+ import { hasSearchablePlaintext, isListFilterAllowed, isListSortAllowed, isSensitiveLabelField, } from "./entity-field-encryption.js";
14
14
  import { buildFilterWhere, } from "./event-store-executor-context.js";
15
15
  import { buildParentRefClause } from "./parent-ref-clause.js";
16
16
  import { buildEntityTable, physicalColumnName } from "./table-builder.js";
@@ -144,7 +144,7 @@ function applyScreenFilter(table, colSql, whereSql, params, f) {
144
144
  return;
145
145
  }
146
146
  for (const [field, value] of Object.entries(screen)) {
147
- // #2015: `x <> NULL` is never true — mirror buildWhereClause's IS [NOT] NULL handling in bun-db/query.ts.
147
+ // #2015: `x <> NULL` is never true — mirror buildWhereClause's IS [NOT] NULL handling in db/query.ts.
148
148
  if (value === null) {
149
149
  whereSql.push(`${colSql(field)} IS NULL`);
150
150
  }
@@ -176,7 +176,7 @@ function applyScreenFilter(table, colSql, whereSql, params, f) {
176
176
  }
177
177
  else {
178
178
  // Blind-index OR rewrite (#818), lock-step with buildWhereClause
179
- // in bun-db/query.ts — equality on lookupable fields matches the
179
+ // in db/query.ts — equality on lookupable fields matches the
180
180
  // plaintext arm OR the HMAC arm.
181
181
  const bidxKey = configuredBlindIndexKey();
182
182
  if (bidxKey !== undefined && typeof value === "string" && table[`${field}Bidx`]) {
@@ -547,7 +547,12 @@ export function createReadVerbs(ctx) {
547
547
  // tenant filter that follows.
548
548
  whereSql.push(`(${orParts.join(" OR ")})`);
549
549
  }
550
- const sortCandidate = payload.sort && table[payload.sort] && fieldReadClause(payload.sort).kind === "pass"
550
+ // A column sort must not 400 (the renderer sends one per header click), and the order of
551
+ // ciphertext carries no meaning for the user while leaking the order of the plaintext.
552
+ const sortCandidate = payload.sort &&
553
+ table[payload.sort] &&
554
+ fieldReadClause(payload.sort).kind === "pass" &&
555
+ isListSortAllowed(entity, payload.sort)
551
556
  ? payload.sort
552
557
  : undefined;
553
558
  const sortDescending = payload.sortDirection === "desc";
@@ -606,6 +611,22 @@ export function createReadVerbs(ctx) {
606
611
  // itself confirm the field exists (field-access.ts strips silently)
607
612
  return;
608
613
  }
614
+ if (!isListFilterAllowed(entity, f.field, f)) {
615
+ throw new ValidationError({
616
+ fields: [
617
+ {
618
+ path: `filter.${f.field}`,
619
+ code: "field_not_filterable",
620
+ i18nKey: "errors.validation.invalid_value",
621
+ params: {
622
+ field: f.field,
623
+ op: f.op,
624
+ hint: "Encrypted, personal and secret fields cannot be filtered; only equality on a field with find: exact or fuzzy.",
625
+ },
626
+ },
627
+ ],
628
+ });
629
+ }
609
630
  if (readClause.kind === "sql") {
610
631
  const shifted = shiftParams({ sqlText: readClause.sqlText, params: readClause.params }, params.length);
611
632
  whereSql.push(shifted.sqlText);
@@ -3,7 +3,7 @@ export { nullBlindIndexesForSubject, recordEventsOwnedExclusivelyByTenant, recor
3
3
  export { collectTableMetas } from "./collect-table-metas.js";
4
4
  export { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types.js";
5
5
  export { seedConfigValues } from "./config-seed.js";
6
- export type { DbConnection, DbConnectionOptions, DbPoolHandle, DbRow, DbRunner, DbTx, } from "./connection.js";
6
+ export type { CreateDbConnectionOptions, DbConnection, DbConnectionHandle, DbConnectionOptions, DbRow, DbRunner, DbTx, } from "./connection.js";
7
7
  export { createDbConnection, DEFAULT_OUTSIDE_TRANSACTION_POOL_MAX, dbConnectionOptionsFromEnv, outsideTransactionPoolOptionsFromEnv, } from "./connection.js";
8
8
  export type { CursorQueryOptions, CursorResult, DecodedKeysetCursor } from "./cursor.js";
9
9
  export { decodeCursor, decodeKeysetCursor, encodeCursor, encodeKeysetCursor } from "./cursor.js";
@@ -26,8 +26,8 @@ export { constraintOf, extractPgError, isTableAlreadyExists, isUniqueViolation,
26
26
  export { acquireNamespacedAdvisoryLock } from "./queries/advisory-lock.js";
27
27
  export { toPostgresArrayLiteral } from "./queries/postgres-array-literal.js";
28
28
  export { executeRawQuery, executeRawQueryRead } from "./queries/raw-sql.js";
29
- export type { SelectOptions, WhereObject, WhereValue } from "./query-api.js";
30
- export { aggregateWhere, asRawClient, countWhere, deleteMany, fetchOne, insertMany, insertOne, runInSavepoint, selectMany, transaction, updateMany, } from "./query-api.js";
29
+ export type { DeleteManyBatchedOptions, DeleteManyBatchedResult, IncrementCounterOptions, InnerJoinRow, InnerJoinSpec, InsertOnConflictDoNothingOptions, JoinColumnPair, JsonTextMatch, SelectOptions, TableInfo, UpsertOnConflictOptions, WhereObject, WhereOperator, WhereValue, } from "./query.js";
30
+ export { aggregateWhere, asEntityTableMeta, asRawClient, countWhere, deleteMany, deleteManyBatched, extractTableInfo, fetchOne, incrementCounter, insertMany, insertOnConflictDoNothing, insertOne, instantToTimestamptzParam, isTimestamptzType, requireEntityTableMeta, runInSavepoint, runInSavepointIfSupported, runnerCanBeginTransaction, selectInnerJoin, selectMany, transaction, unsafeReadRetrying, updateMany, upsertByPk, upsertOnConflict, } from "./query.js";
31
31
  export { readRebuildMarker, rebuildTablesFromDiff, writeRebuildMarker, } from "./rebuild-marker.js";
32
32
  export { seedReferenceData } from "./reference-data.js";
33
33
  export { renderTableDdl, renderTablesDdl } from "./render-ddl.js";
@@ -35,5 +35,5 @@ export { diffReplayAgainstSnapshot, type ReplayedSchema, type ReplayedTable, typ
35
35
  export { findCommentedDropTables, isRetiredFrameworkTable, type RetiredFrameworkTable, retiredFrameworkTables, } from "./retired-framework-tables.js";
36
36
  export { tableExists } from "./schema-inspection.js";
37
37
  export { buildBaseColumns, buildEntityTable, declareGlobalTenancy, type EntityTable, physicalColumnName, toSnakeCase, toTableName, } from "./table-builder.js";
38
- export type { TenantDb, TenantDbGrants, TenantDbMode, UncheckedSystemDb } from "./tenant-db.js";
38
+ export type { CreateTenantDbOptions, TenantDb, TenantDbGrants, TenantDbMode, UncheckedSystemDb, } from "./tenant-db.js";
39
39
  export { castTenantRows, createSystemDbView, createTenantDb, runInOwnTransaction, SYSTEM_SCOPE_CHECK_BRAND, scopeTenantDbToTenant, } from "./tenant-db.js";
package/dist/db/index.js CHANGED
@@ -20,7 +20,7 @@ export { constraintOf, extractPgError, isTableAlreadyExists, isUniqueViolation,
20
20
  export { acquireNamespacedAdvisoryLock } from "./queries/advisory-lock.js";
21
21
  export { toPostgresArrayLiteral } from "./queries/postgres-array-literal.js";
22
22
  export { executeRawQuery, executeRawQueryRead } from "./queries/raw-sql.js";
23
- export { aggregateWhere, asRawClient, countWhere, deleteMany, fetchOne, insertMany, insertOne, runInSavepoint, selectMany, transaction, updateMany, } from "./query-api.js";
23
+ export { aggregateWhere, asEntityTableMeta, asRawClient, countWhere, deleteMany, deleteManyBatched, extractTableInfo, fetchOne, incrementCounter, insertMany, insertOnConflictDoNothing, insertOne, instantToTimestamptzParam, isTimestamptzType, requireEntityTableMeta, runInSavepoint, runInSavepointIfSupported, runnerCanBeginTransaction, selectInnerJoin, selectMany, transaction, unsafeReadRetrying, updateMany, upsertByPk, upsertOnConflict, } from "./query.js";
24
24
  export { readRebuildMarker, rebuildTablesFromDiff, writeRebuildMarker, } from "./rebuild-marker.js";
25
25
  export { seedReferenceData } from "./reference-data.js";
26
26
  export { renderTableDdl, renderTablesDdl } from "./render-ddl.js";
@@ -17,7 +17,7 @@ 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, hasBegin, } from "../bun-db/query.js";
20
+ import { asRawClient, hasBegin, } from "./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).