@cosmicdrift/kumiko-framework 0.366.0 → 0.368.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 +22 -33
  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/seed-context.js +3 -3
  96. package/dist/db/queries/test-stack.js +1 -1
  97. package/dist/db/query.d.ts +105 -1
  98. package/dist/db/query.js +1450 -3
  99. package/dist/{stack/table-helpers.d.ts → db/table-push.d.ts} +5 -15
  100. package/dist/{stack/table-helpers.js → db/table-push.js} +6 -46
  101. package/dist/db/tenant-db.d.ts +11 -0
  102. package/dist/db/tenant-db.js +51 -12
  103. package/dist/derivatives/derivatives-context.d.ts +1 -1
  104. package/dist/derivatives/derivatives-context.js +1 -1
  105. package/dist/engine/boot-validator/gdpr-storage.js +3 -3
  106. package/dist/engine/boot-validator/pii-retention.js +3 -3
  107. package/dist/engine/boot-validator/record-owned.js +3 -3
  108. package/dist/engine/boot-validator/screens.js +8 -1
  109. package/dist/engine/bridge-stub.d.ts +4 -0
  110. package/dist/engine/bridge-stub.js +62 -0
  111. package/dist/engine/event-helpers.d.ts +2 -1
  112. package/dist/engine/event-helpers.js +1 -0
  113. package/dist/engine/feature-config-events-jobs.d.ts +4 -0
  114. package/dist/engine/feature-config-events-jobs.js +4 -2
  115. package/dist/engine/index.d.ts +1 -0
  116. package/dist/engine/index.js +1 -0
  117. package/dist/engine/registry-ingest.js +3 -0
  118. package/dist/engine/template-variables.d.ts +3 -0
  119. package/dist/engine/template-variables.js +3 -0
  120. package/dist/es-ops/operations-schema.js +1 -1
  121. package/dist/event-store/admin-api.js +1 -1
  122. package/dist/event-store/archive.js +1 -1
  123. package/dist/event-store/errors.d.ts +2 -1
  124. package/dist/event-store/errors.js +6 -2
  125. package/dist/event-store/event-store.js +17 -9
  126. package/dist/event-store/events-schema.js +1 -1
  127. package/dist/event-store/rebuild-dead-letter.js +3 -3
  128. package/dist/event-store/snapshot.js +1 -1
  129. package/dist/event-store/upcaster-dead-letter.js +3 -3
  130. package/dist/files/create-file-ref-from-bytes.d.ts +1 -0
  131. package/dist/files/create-file-ref-from-bytes.js +8 -3
  132. package/dist/files/file-routes.js +1 -1
  133. package/dist/internal/stack-support.d.ts +12 -0
  134. package/dist/internal/stack-support.js +12 -0
  135. package/dist/internal/testing.d.ts +4 -0
  136. package/dist/internal/testing.js +5 -0
  137. package/dist/jobs/job-runner.js +23 -11
  138. package/dist/migrations/pending-rebuilds.js +1 -1
  139. package/dist/migrations/pii-event-backfill.js +2 -2
  140. package/dist/pipeline/dispatch-shared.js +43 -23
  141. package/dist/pipeline/dispatch-write.js +6 -2
  142. package/dist/pipeline/dispatcher-utils.js +6 -2
  143. package/dist/pipeline/event-consumer-state.js +1 -1
  144. package/dist/pipeline/projection-rebuild.d.ts +1 -0
  145. package/dist/pipeline/projection-rebuild.js +1 -1
  146. package/dist/pipeline/projection-state.js +1 -1
  147. package/dist/pipeline/system-hooks.js +1 -1
  148. package/dist/schema-cli.js +3 -3
  149. package/dist/search/purge-subject.js +20 -10
  150. package/dist/seeding/index.d.ts +1 -0
  151. package/dist/seeding/index.js +1 -0
  152. package/dist/stack/index.d.ts +10 -9
  153. package/dist/stack/index.js +11 -19
  154. package/dist/testing/e2e-generator.d.ts +5 -49
  155. package/dist/testing/e2e-generator.js +5 -382
  156. package/dist/testing/handler-context.d.ts +5 -4
  157. package/dist/testing/handler-context.js +5 -69
  158. package/dist/testing/index.d.ts +10 -26
  159. package/dist/testing/index.js +10 -34
  160. package/dist/testing/real-providers.d.ts +5 -3
  161. package/dist/testing/real-providers.js +5 -21
  162. package/package.json +37 -9
  163. package/src/changes.json +70 -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
@@ -123,7 +123,7 @@ export const features = [
123
123
  // löschen — Row bleibt für Multi-User-Refs intakt.
124
124
 
125
125
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
126
- import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
126
+ import { createAuthFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
127
127
  import {
128
128
  createComplianceProfilesFeature,
129
129
  tenantComplianceProfileEntity,
@@ -133,6 +133,10 @@ import {
133
133
  tenantRetentionOverrideEntity,
134
134
  } from "@cosmicdrift/kumiko-bundled-features/data-retention";
135
135
  import { createSessionsFeature } from "@cosmicdrift/kumiko-bundled-features/sessions";
136
+ import {
137
+ tenantMembershipEntity,
138
+ tenantMembershipsTable,
139
+ } from "@cosmicdrift/kumiko-bundled-features/tenant";
136
140
  import {
137
141
  createUserFeature,
138
142
  USER_STATUS,
@@ -144,15 +148,15 @@ import {
144
148
  runForgetCleanup,
145
149
  runUserExport,
146
150
  } from "@cosmicdrift/kumiko-bundled-features/user-data-rights";
147
- import { asRawClient, insertOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
151
+ import { insertOne, selectMany } from "@cosmicdrift/kumiko-framework/db";
148
152
  import { EXT_USER_DATA } from "@cosmicdrift/kumiko-framework/engine";
153
+ import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
154
+ import { seedRow } from "@cosmicdrift/kumiko-testing/helpers";
149
155
  import {
150
156
  setupTestStack,
151
157
  type TestStack,
152
158
  unsafeCreateEntityTable,
153
- } from "@cosmicdrift/kumiko-framework/stack";
154
- import { seedRow } from "@cosmicdrift/kumiko-framework/testing";
155
- import { getTemporal } from "@cosmicdrift/kumiko-framework/time";
159
+ } from "@cosmicdrift/kumiko-testing/stack";
156
160
  import { noteEntity, notesFeature, notesTable } from "../feature";
157
161
 
158
162
  let stack: TestStack;
@@ -173,7 +177,7 @@ beforeAll(async () => {
173
177
  stack = await setupTestStack({
174
178
  features: [
175
179
  createUserFeature(),
176
- authFoundationFeature,
180
+ createAuthFoundationFeature(),
177
181
  createSessionsFeature(),
178
182
  createDataRetentionFeature(),
179
183
  createComplianceProfilesFeature(),
@@ -185,23 +189,7 @@ beforeAll(async () => {
185
189
  await unsafeCreateEntityTable(stack.db, noteEntity);
186
190
  await unsafeCreateEntityTable(stack.db, tenantRetentionOverrideEntity);
187
191
  await unsafeCreateEntityTable(stack.db, tenantComplianceProfileEntity);
188
- await asRawClient(stack.db).unsafe(`
189
- CREATE TABLE IF NOT EXISTS read_tenant_memberships (
190
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
191
- tenant_id UUID NOT NULL,
192
- user_id TEXT NOT NULL,
193
- version INTEGER NOT NULL DEFAULT 0,
194
- inserted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
195
- modified_at TIMESTAMPTZ NOT NULL DEFAULT now(),
196
- inserted_by_id TEXT,
197
- modified_by_id TEXT,
198
- is_deleted BOOLEAN NOT NULL DEFAULT false,
199
- deleted_at TIMESTAMPTZ,
200
- deleted_by_id TEXT,
201
- roles TEXT NOT NULL DEFAULT '[]',
202
- UNIQUE(user_id, tenant_id)
203
- )
204
- `);
192
+ await unsafeCreateEntityTable(stack.db, tenantMembershipEntity);
205
193
  });
206
194
 
207
195
  afterAll(async () => {
@@ -233,14 +221,11 @@ async function seedAlice(opts: { gracePeriodEnd?: Instant; status?: string } = {
233
221
  status: opts.status ?? USER_STATUS.Active,
234
222
  gracePeriodEnd: opts.gracePeriodEnd ?? null,
235
223
  });
236
- await asRawClient(stack.db).unsafe(
237
- `
238
- INSERT INTO read_tenant_memberships (tenant_id, user_id, roles)
239
- VALUES ($1, $2, '["Member"]')
240
- ON CONFLICT DO NOTHING
241
- `,
242
- [TENANT_A, ALICE_ID],
243
- );
224
+ await seedRow(stack.db, tenantMembershipsTable, {
225
+ tenantId: TENANT_A,
226
+ userId: ALICE_ID,
227
+ roles: '["Member"]',
228
+ });
244
229
  }
245
230
 
246
231
  async function seedNote(id: string, title: string): Promise<void> {
@@ -336,8 +321,12 @@ describe("user-data-rights recipe :: EXT_USER_DATA-Hooks integrieren Notes-Domai
336
321
  // - HTTP-Endpoints fuer create/list
337
322
  // - Compliance-Profile-Wiring + Cron-Scheduling
338
323
 
339
- import { deleteMany, selectMany, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
340
- import { deriveEntityTableMeta } from "@cosmicdrift/kumiko-framework/db";
324
+ import {
325
+ deleteMany,
326
+ deriveEntityTableMeta,
327
+ selectMany,
328
+ updateMany,
329
+ } from "@cosmicdrift/kumiko-framework/db";
341
330
  import {
342
331
  createEntity,
343
332
  createTextField,
@@ -109,7 +109,7 @@ import {
109
109
  createRegistry,
110
110
  validateBoot as validateBootRaw,
111
111
  } from "@cosmicdrift/kumiko-framework/engine";
112
- import { withBootValidatorFixture } from "@cosmicdrift/kumiko-framework/testing";
112
+ import { withBootValidatorFixture } from "@cosmicdrift/kumiko-testing/helpers";
113
113
  import { composeAccountApp } from "../feature";
114
114
 
115
115
  function validateBoot(features: Parameters<typeof validateBootRaw>[0]): void {
@@ -166,7 +166,7 @@ describe("user-profile recipe — boot validation", () => {
166
166
  // })
167
167
 
168
168
  import { createAuthEmailPasswordFeature } from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
169
- import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
169
+ import { createAuthFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
170
170
  import { createComplianceProfilesFeature } from "@cosmicdrift/kumiko-bundled-features/compliance-profiles";
171
171
  import { createConfigFeature } from "@cosmicdrift/kumiko-bundled-features/config";
172
172
  import { createDataRetentionFeature } from "@cosmicdrift/kumiko-bundled-features/data-retention";
@@ -211,7 +211,7 @@ export function composeAccountApp(): FeatureDefinition[] {
211
211
  createAuthEmailPasswordFeature(),
212
212
  createDataRetentionFeature(),
213
213
  createComplianceProfilesFeature(),
214
- authFoundationFeature,
214
+ createAuthFoundationFeature(),
215
215
  createPersonalAccessTokensFeature({ scopes: {} }),
216
216
  createSessionsFeature(),
217
217
  createFilesFeature(),
@@ -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.
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.
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.
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.
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.
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.
417
417
 
418
418
  Category: notifications
419
419
 
@@ -538,7 +538,7 @@ Requires: `template-resolver`
538
538
 
539
539
  ## renderer-mail-html
540
540
 
541
- A `renderer-foundation` plugin that handles `RenderRequest { kind: "mail" }` and returns `{ subject, html, text, previewText? }` suitable for sending via `channel-email`. The mail template (`kind: "mail"`, document HTML, subject in `meta`) resolves through `template-resolver`'s DB fallback, its body is compiled to Handlebars by template-resolver and rendered with escaping, then placed into an MJML layout (`kind: "mail-layout"`, slug `default` unless `meta.layoutSlug` says otherwise, an in-code structured layout with the tenant branding logo and accent color as last resort) that `mjml()` compiles to email-safe table HTML, with `html-to-text` producing the plain-text alternative. An optional `footer` resolver adds the platform imprint, privacy link and footer text below every structured layout, and an optional `brandSuffix` resolver adds a word after the tenant name in the brand row of the in-code layout. Mail meta `headerChip`, `tone` and `footerNote` add a chip beside the brand, the tone of the top line and chip, and a note above the site and unsubscribe links of the card. Seed an editable system layout with `seedSystemMailLayout(db)`. Requires `renderer-foundation` + `template-resolver`.
541
+ A `renderer-foundation` plugin that handles `RenderRequest { kind: "mail" }` and returns `{ subject, html, text, previewText? }` suitable for sending via `channel-email`. The mail template (`kind: "mail"`, document HTML, subject in `meta`) resolves through `template-resolver`'s DB fallback, its body is compiled to Handlebars by template-resolver and rendered with escaping, then placed into an MJML layout (`kind: "mail-layout"`, slug `default` unless `meta.layoutSlug` says otherwise, an in-code structured layout with the tenant branding logo and accent color as last resort) that `mjml()` compiles to email-safe table HTML, with `html-to-text` producing the plain-text alternative. An optional `footer` resolver adds the platform imprint, privacy link and footer text below every structured layout, an optional `brandSuffix` resolver adds a word after the tenant name in the brand row of the in-code layout, and an optional `siteUrl` resolver supplies the site link of the card when the tenant branding has no site URL (without either, the link is left out). The brand row and `data-resource="logo"` images use the branding logo upload, or the branding logo https address when no file is uploaded. Mail meta `headerChip`, `tone` and `footerNote` add a chip beside the brand, the tone of the top line and chip, and a note above the site and unsubscribe links of the card. Seed an editable system layout with `seedSystemMailLayout(db)`. Requires `renderer-foundation` + `template-resolver`.
542
542
 
543
543
  Requires: `renderer-foundation`, `template-resolver`
544
544
 
@@ -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