@cosmicdrift/kumiko-bundled-features 0.14.0 → 0.16.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 (269) hide show
  1. package/package.json +2 -2
  2. package/src/__tests__/env-schemas.test.ts +1 -1
  3. package/src/__tests__/es-ops-e2e.integration.ts +10 -9
  4. package/src/audit/__tests__/audit.integration.ts +3 -3
  5. package/src/audit/handlers/list.query.ts +39 -51
  6. package/src/auth-email-password/__tests__/account-lockout-no-redis.integration.ts +4 -3
  7. package/src/auth-email-password/__tests__/account-lockout.integration.ts +4 -3
  8. package/src/auth-email-password/__tests__/auth-claims.integration.ts +5 -4
  9. package/src/auth-email-password/__tests__/auth.integration.ts +4 -3
  10. package/src/auth-email-password/__tests__/confirm-token-flow.test.ts +1 -1
  11. package/src/auth-email-password/__tests__/email-templates.test.ts +1 -1
  12. package/src/auth-email-password/__tests__/email-verification.integration.ts +7 -10
  13. package/src/auth-email-password/__tests__/identity-v3-hash.test.ts +1 -1
  14. package/src/auth-email-password/__tests__/identity-v3-login.integration.ts +4 -3
  15. package/src/auth-email-password/__tests__/invite-flow.integration.ts +16 -43
  16. package/src/auth-email-password/__tests__/multi-roles.integration.ts +6 -9
  17. package/src/auth-email-password/__tests__/password-reset.integration.ts +8 -7
  18. package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.ts +4 -3
  19. package/src/auth-email-password/__tests__/seed-admin.integration.ts +19 -32
  20. package/src/auth-email-password/__tests__/session-callbacks.integration.ts +6 -5
  21. package/src/auth-email-password/__tests__/session-strict-mode.integration.ts +1 -1
  22. package/src/auth-email-password/__tests__/signed-token.test.ts +1 -1
  23. package/src/auth-email-password/__tests__/signup-flow.integration.ts +11 -15
  24. package/src/auth-email-password/handlers/invite-accept-with-login.write.ts +26 -26
  25. package/src/auth-email-password/handlers/invite-accept.write.ts +24 -21
  26. package/src/auth-email-password/handlers/invite-create.write.ts +3 -8
  27. package/src/auth-email-password/handlers/invite-signup-complete.write.ts +20 -17
  28. package/src/auth-email-password/handlers/signup-confirm.write.ts +3 -7
  29. package/src/auth-email-password/seeding.ts +1 -1
  30. package/src/auth-email-password/web/__tests__/auth-gate.test.tsx +1 -2
  31. package/src/auth-email-password/web/__tests__/forgot-password-screen.test.tsx +10 -19
  32. package/src/auth-email-password/web/__tests__/login-screen.test.tsx +12 -18
  33. package/src/auth-email-password/web/__tests__/reset-password-screen.test.tsx +12 -17
  34. package/src/auth-email-password/web/__tests__/session-roles.test.ts +1 -1
  35. package/src/auth-email-password/web/__tests__/tenant-switcher.test.tsx +1 -8
  36. package/src/auth-email-password/web/__tests__/test-utils.tsx +4 -8
  37. package/src/auth-email-password/web/__tests__/user-menu.test.tsx +2 -8
  38. package/src/auth-email-password/web/__tests__/verify-email-screen.test.tsx +10 -15
  39. package/src/billing-foundation/__tests__/billing-foundation.integration.ts +1 -1
  40. package/src/billing-foundation/__tests__/feature.test.ts +1 -1
  41. package/src/billing-foundation/__tests__/webhook-handler.test.ts +6 -5
  42. package/src/billing-foundation/db/queries/subscription-projection.ts +15 -0
  43. package/src/billing-foundation/get-subscription-for-tenant.ts +2 -6
  44. package/src/billing-foundation/handlers/create-portal-session.write.ts +2 -2
  45. package/src/billing-foundation/handlers/list-subscriptions.query.ts +4 -1
  46. package/src/billing-foundation/projection.ts +32 -13
  47. package/src/cap-counter/__tests__/cap-counter.integration.ts +1 -1
  48. package/src/cap-counter/__tests__/enforce-cap.test.ts +37 -32
  49. package/src/cap-counter/__tests__/with-cap-enforcement.integration.ts +1 -1
  50. package/src/cap-counter/enforce-cap.ts +14 -20
  51. package/src/cap-counter/handlers/get-counter.query.ts +7 -13
  52. package/src/cap-counter/handlers/increment.write.ts +2 -2
  53. package/src/cap-counter/handlers/mark-soft-warned.write.ts +2 -2
  54. package/src/channel-in-app/handlers/inbox.query.ts +7 -13
  55. package/src/channel-in-app/handlers/mark-all-read.write.ts +7 -9
  56. package/src/channel-in-app/handlers/mark-read.write.ts +8 -14
  57. package/src/channel-in-app/handlers/unread-count.query.ts +10 -9
  58. package/src/channel-in-app/in-app-channel.ts +10 -12
  59. package/src/channel-in-app/tables.ts +1 -1
  60. package/src/compliance-profiles/__tests__/compliance-profiles.integration.ts +1 -1
  61. package/src/compliance-profiles/__tests__/seeding.integration.ts +1 -1
  62. package/src/compliance-profiles/_internal/parse-override.ts +19 -0
  63. package/src/compliance-profiles/handlers/for-tenant.query.ts +10 -32
  64. package/src/compliance-profiles/handlers/needs-profile.query.ts +4 -7
  65. package/src/compliance-profiles/handlers/set-profile.write.ts +5 -7
  66. package/src/compliance-profiles/resolve-for-tenant.ts +11 -27
  67. package/src/compliance-profiles/schema/profile-selection.ts +2 -2
  68. package/src/compliance-profiles/seeding.ts +4 -7
  69. package/src/config/__tests__/app-overrides.test.ts +1 -1
  70. package/src/config/__tests__/cascade.integration.ts +1 -1
  71. package/src/config/__tests__/config.integration.ts +8 -27
  72. package/src/config/db/queries/resolver.ts +47 -0
  73. package/src/config/handlers/__tests__/prepare-config-write.test.ts +1 -1
  74. package/src/config/resolver.ts +14 -62
  75. package/src/config/table.ts +4 -4
  76. package/src/config/write-helpers.ts +7 -11
  77. package/src/custom-fields/__tests__/audit-integration.integration.ts +6 -6
  78. package/src/custom-fields/__tests__/custom-fields.integration.ts +7 -7
  79. package/src/custom-fields/__tests__/feature.test.ts +1 -1
  80. package/src/custom-fields/__tests__/field-access.integration.ts +6 -6
  81. package/src/custom-fields/__tests__/quota.integration.ts +6 -6
  82. package/src/custom-fields/__tests__/retention.integration.ts +12 -10
  83. package/src/custom-fields/__tests__/user-data-rights.integration.ts +27 -17
  84. package/src/custom-fields/__tests__/wire-for-entity.test.ts +5 -5
  85. package/src/custom-fields/db/queries/field-access.ts +16 -0
  86. package/src/custom-fields/db/queries/projection.ts +43 -0
  87. package/src/custom-fields/db/queries/quota.ts +14 -0
  88. package/src/custom-fields/db/queries/retention.ts +39 -0
  89. package/src/custom-fields/db/queries/user-data-rights.ts +54 -0
  90. package/src/custom-fields/lib/field-access.ts +2 -41
  91. package/src/custom-fields/lib/quota.ts +2 -25
  92. package/src/custom-fields/run-retention.ts +19 -21
  93. package/src/custom-fields/wire-for-entity.ts +30 -23
  94. package/src/custom-fields/wire-user-data-rights.ts +33 -85
  95. package/src/data-retention/__tests__/data-retention.integration.ts +1 -1
  96. package/src/data-retention/__tests__/keep-for.test.ts +1 -1
  97. package/src/data-retention/__tests__/override-schema.test.ts +1 -1
  98. package/src/data-retention/__tests__/policy-for.integration.ts +1 -1
  99. package/src/data-retention/__tests__/resolver.test.ts +1 -1
  100. package/src/data-retention/handlers/policy-for.query.ts +5 -8
  101. package/src/data-retention/resolve-for-tenant.ts +6 -8
  102. package/src/data-retention/schema/tenant-retention-override.ts +2 -2
  103. package/src/delivery/__tests__/delivery-events.integration.ts +8 -21
  104. package/src/delivery/__tests__/delivery.integration.ts +100 -190
  105. package/src/delivery/db/queries/preferences.ts +30 -0
  106. package/src/delivery/delivery-service.ts +8 -36
  107. package/src/delivery/feature.ts +10 -2
  108. package/src/delivery/handlers/log.query.ts +5 -7
  109. package/src/delivery/handlers/preferences.query.ts +2 -5
  110. package/src/delivery/tables.ts +26 -1
  111. package/src/delivery/upsert-preference.ts +8 -14
  112. package/src/feature-toggles/__tests__/feature-toggles.integration.ts +30 -30
  113. package/src/feature-toggles/__tests__/registered-system-tenant.test.ts +7 -6
  114. package/src/feature-toggles/db/queries/toggle-state.ts +25 -0
  115. package/src/feature-toggles/feature.ts +16 -2
  116. package/src/feature-toggles/global-feature-state-table.ts +1 -1
  117. package/src/feature-toggles/handlers/list.query.ts +9 -2
  118. package/src/feature-toggles/handlers/registered.query.ts +3 -7
  119. package/src/feature-toggles/handlers/set.write.ts +37 -25
  120. package/src/feature-toggles/toggle-runtime.ts +3 -6
  121. package/src/file-foundation/__tests__/feature.test.ts +1 -1
  122. package/src/file-foundation/__tests__/file-foundation.integration.ts +1 -1
  123. package/src/file-provider-inmemory/__tests__/feature.test.ts +1 -1
  124. package/src/file-provider-s3/__tests__/feature.test.ts +1 -1
  125. package/src/files/__tests__/files.integration.ts +18 -7
  126. package/src/files/schema/file-ref.ts +1 -1
  127. package/src/files-provider-s3/__tests__/env-helper.test.ts +1 -1
  128. package/src/files-provider-s3/__tests__/s3-provider.integration.ts +1 -1
  129. package/src/files-provider-s3/__tests__/s3-provider.test.ts +1 -1
  130. package/src/jobs/__tests__/job-system-user.integration.ts +1 -1
  131. package/src/jobs/__tests__/jobs-events.integration.ts +8 -21
  132. package/src/jobs/__tests__/jobs-feature.integration.ts +1 -1
  133. package/src/jobs/feature.ts +26 -15
  134. package/src/jobs/handlers/detail.query.ts +10 -8
  135. package/src/jobs/handlers/list.query.ts +9 -21
  136. package/src/jobs/handlers/retry.write.ts +2 -7
  137. package/src/jobs/job-run-logger.ts +3 -9
  138. package/src/jobs/job-run-table.ts +49 -17
  139. package/src/legal-pages/__tests__/legal-pages.integration.ts +1 -1
  140. package/src/mail-foundation/__tests__/feature.test.ts +1 -1
  141. package/src/mail-foundation/__tests__/mail-foundation.integration.ts +1 -1
  142. package/src/mail-transport-inmemory/__tests__/feature.test.ts +1 -1
  143. package/src/mail-transport-smtp/__tests__/feature.test.ts +1 -1
  144. package/src/rate-limiting/__tests__/rate-limiting.integration.ts +1 -1
  145. package/src/renderer-foundation/__tests__/api.test.ts +2 -2
  146. package/src/renderer-foundation/__tests__/collect-plugins.integration.ts +1 -1
  147. package/src/renderer-simple/__tests__/adapter.test.ts +2 -2
  148. package/src/renderer-simple/__tests__/simple-renderer.test.ts +1 -1
  149. package/src/secrets/__tests__/require-secrets-context.test.ts +6 -5
  150. package/src/secrets/__tests__/rotate.integration.ts +6 -9
  151. package/src/secrets/__tests__/secrets-events.integration.ts +6 -12
  152. package/src/secrets/__tests__/secrets.integration.ts +6 -11
  153. package/src/secrets/db/queries/read.ts +16 -0
  154. package/src/secrets/handlers/list.query.ts +16 -17
  155. package/src/secrets/handlers/rotate.job.ts +8 -12
  156. package/src/secrets/secrets-context.ts +9 -21
  157. package/src/secrets/table.ts +1 -1
  158. package/src/sessions/__tests__/cleanup.integration.ts +8 -6
  159. package/src/sessions/__tests__/password-auto-revoke.integration.ts +7 -6
  160. package/src/sessions/__tests__/sessions.integration.ts +23 -38
  161. package/src/sessions/__tests__/test-helpers.ts +1 -1
  162. package/src/sessions/db/queries/cleanup.ts +21 -0
  163. package/src/sessions/handlers/cleanup.job.ts +6 -29
  164. package/src/sessions/handlers/list.query.ts +24 -24
  165. package/src/sessions/handlers/mine.query.ts +24 -23
  166. package/src/sessions/handlers/revoke-all-for-user.write.ts +7 -11
  167. package/src/sessions/handlers/revoke-all-others.write.ts +7 -12
  168. package/src/sessions/handlers/revoke.write.ts +11 -18
  169. package/src/sessions/schema/user-session.ts +2 -2
  170. package/src/sessions/session-callbacks.ts +19 -21
  171. package/src/subscription-mollie/__tests__/feature.test.ts +1 -1
  172. package/src/subscription-mollie/__tests__/mollie-foundation.integration.ts +1 -1
  173. package/src/subscription-mollie/__tests__/verify-webhook.test.ts +8 -7
  174. package/src/subscription-stripe/__tests__/feature.test.ts +1 -1
  175. package/src/subscription-stripe/__tests__/plugin-methods.test.ts +14 -15
  176. package/src/subscription-stripe/__tests__/stripe-foundation.integration.ts +1 -1
  177. package/src/subscription-stripe/__tests__/verify-webhook.test.ts +14 -14
  178. package/src/subscription-stripe/verify-webhook.ts +1 -1
  179. package/src/template-resolver/__tests__/handlers.integration.ts +1 -1
  180. package/src/template-resolver/__tests__/template-resolver.integration.ts +3 -2
  181. package/src/template-resolver/api.ts +7 -13
  182. package/src/template-resolver/handlers/archive.write.ts +4 -7
  183. package/src/template-resolver/handlers/find-by-id.query.ts +4 -7
  184. package/src/template-resolver/handlers/list.query.ts +13 -21
  185. package/src/template-resolver/handlers/publish.write.ts +4 -7
  186. package/src/template-resolver/handlers/upsert-system.write.ts +7 -10
  187. package/src/template-resolver/handlers/upsert-tenant.write.ts +7 -10
  188. package/src/template-resolver/table.ts +2 -5
  189. package/src/tenant/__tests__/multi-tenant.integration.ts +1 -1
  190. package/src/tenant/__tests__/seed-testing.integration.ts +19 -45
  191. package/src/tenant/__tests__/tenant.integration.ts +1 -1
  192. package/src/tenant/handlers/active-tenant-ids.query.ts +3 -8
  193. package/src/tenant/handlers/add-member.write.ts +6 -8
  194. package/src/tenant/handlers/cancel-invitation.write.ts +5 -7
  195. package/src/tenant/handlers/invitations.query.ts +5 -10
  196. package/src/tenant/handlers/me.query.ts +2 -3
  197. package/src/tenant/handlers/members.query.ts +4 -5
  198. package/src/tenant/handlers/memberships.query.ts +2 -5
  199. package/src/tenant/handlers/remove-member.write.ts +6 -8
  200. package/src/tenant/handlers/resolve-user-ids.query.ts +6 -16
  201. package/src/tenant/handlers/update-member-roles.write.ts +6 -8
  202. package/src/tenant/invitation-table.ts +2 -5
  203. package/src/tenant/membership-table.ts +3 -6
  204. package/src/tenant/schema/tenant.ts +2 -2
  205. package/src/tenant/seeding.ts +12 -18
  206. package/src/text-content/README.md +1 -1
  207. package/src/text-content/__tests__/text-content.integration.ts +2 -2
  208. package/src/text-content/api.ts +2 -9
  209. package/src/text-content/handlers/by-slug.query.ts +6 -9
  210. package/src/text-content/handlers/by-tenant.query.ts +2 -2
  211. package/src/text-content/handlers/set.write.ts +7 -9
  212. package/src/text-content/seeding.ts +6 -9
  213. package/src/text-content/table.ts +2 -2
  214. package/src/text-content/web/__tests__/editor-read-only.test.tsx +31 -45
  215. package/src/text-content/web/__tests__/group-blocks.test.ts +1 -18
  216. package/src/text-content/web/client-plugin.tsx +11 -23
  217. package/src/tier-engine/__tests__/auto-default-tier.integration.ts +10 -16
  218. package/src/tier-engine/__tests__/compose-app.test.ts +1 -1
  219. package/src/tier-engine/__tests__/drift.test.ts +1 -1
  220. package/src/tier-engine/__tests__/resolver.integration.ts +6 -6
  221. package/src/tier-engine/__tests__/tier-engine.integration.ts +1 -1
  222. package/src/tier-engine/feature.ts +9 -16
  223. package/src/user/__tests__/seed-testing.integration.ts +10 -22
  224. package/src/user/__tests__/user-status.test.ts +1 -1
  225. package/src/user/__tests__/user.integration.ts +6 -5
  226. package/src/user/handlers/create.write.ts +5 -7
  227. package/src/user/handlers/find-for-auth.query.ts +5 -7
  228. package/src/user/schema/user.ts +2 -2
  229. package/src/user/seeding.ts +2 -3
  230. package/src/user-data-rights/__tests__/audit-log.integration.ts +24 -12
  231. package/src/user-data-rights/__tests__/cross-data-matrix.integration.ts +64 -37
  232. package/src/user-data-rights/__tests__/download.integration.ts +29 -46
  233. package/src/user-data-rights/__tests__/export-job-idempotency.integration.ts +35 -28
  234. package/src/user-data-rights/__tests__/export-job-schema.test.ts +2 -2
  235. package/src/user-data-rights/__tests__/policy-to-strategy.test.ts +1 -1
  236. package/src/user-data-rights/__tests__/request-cancel-deletion.integration.ts +11 -15
  237. package/src/user-data-rights/__tests__/request-deletion-callback.integration.ts +10 -12
  238. package/src/user-data-rights/__tests__/request-export.integration.ts +23 -16
  239. package/src/user-data-rights/__tests__/restriction-flow.integration.ts +24 -32
  240. package/src/user-data-rights/__tests__/run-export-jobs.integration.ts +142 -137
  241. package/src/user-data-rights/__tests__/run-forget-cleanup.integration.ts +46 -28
  242. package/src/user-data-rights/__tests__/run-user-export.integration.ts +20 -14
  243. package/src/user-data-rights/__tests__/token-helpers.test.ts +1 -1
  244. package/src/user-data-rights/__tests__/user-data-rights.integration.ts +1 -1
  245. package/src/user-data-rights/__tests__/zip-path.test.ts +1 -1
  246. package/src/user-data-rights/audit-download.ts +3 -3
  247. package/src/user-data-rights/db/queries/export-jobs.ts +23 -0
  248. package/src/user-data-rights/db/queries/forget-cleanup.ts +13 -0
  249. package/src/user-data-rights/handlers/cancel-deletion.write.ts +28 -22
  250. package/src/user-data-rights/handlers/download-by-job.query.ts +11 -21
  251. package/src/user-data-rights/handlers/download-by-token.query.ts +20 -35
  252. package/src/user-data-rights/handlers/export-status.query.ts +19 -33
  253. package/src/user-data-rights/handlers/lift-restriction.write.ts +7 -12
  254. package/src/user-data-rights/handlers/list-download-attempts.query.ts +14 -23
  255. package/src/user-data-rights/handlers/my-audit-log.query.ts +33 -23
  256. package/src/user-data-rights/handlers/request-deletion.write.ts +15 -15
  257. package/src/user-data-rights/handlers/request-export.write.ts +7 -11
  258. package/src/user-data-rights/handlers/restrict-account.write.ts +12 -12
  259. package/src/user-data-rights/run-export-jobs.ts +20 -60
  260. package/src/user-data-rights/run-forget-cleanup.ts +19 -33
  261. package/src/user-data-rights/run-user-export.ts +4 -6
  262. package/src/user-data-rights/schema/download-attempt.ts +2 -2
  263. package/src/user-data-rights/schema/download-token.ts +2 -2
  264. package/src/user-data-rights/schema/export-job.ts +2 -3
  265. package/src/user-data-rights-defaults/__tests__/user-data-rights-defaults.integration.ts +37 -30
  266. package/src/user-data-rights-defaults/db/queries/user-hook.ts +17 -0
  267. package/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +12 -27
  268. package/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +16 -18
  269. package/CHANGELOG.md +0 -689
@@ -5,7 +5,9 @@
5
5
  // pins both paths so a silent rename breaks here, not in a compliance
6
6
  // audit query.
7
7
 
8
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
8
9
  import { randomBytes } from "node:crypto";
10
+ import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
9
11
  import { createEventsTable, eventsTable } from "@cosmicdrift/kumiko-framework/event-store";
10
12
  import {
11
13
  createEnvMasterKeyProvider,
@@ -17,8 +19,6 @@ import {
17
19
  type TestStack,
18
20
  unsafePushTables,
19
21
  } from "@cosmicdrift/kumiko-framework/stack";
20
- import { eq } from "drizzle-orm";
21
- import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
22
22
  import { createSecretsFeature } from "../feature";
23
23
  import {
24
24
  createSecretsContext,
@@ -59,8 +59,8 @@ afterAll(async () => {
59
59
  });
60
60
 
61
61
  beforeEach(async () => {
62
- await stack.db.delete(eventsTable);
63
- await stack.db.delete(tenantSecretsTable);
62
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${eventsTable.tableName}"`);
63
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantSecretsTable.tableName}"`);
64
64
  });
65
65
 
66
66
  describe("tenantSecret lifecycle events", () => {
@@ -71,10 +71,7 @@ describe("tenantSecret lifecycle events", () => {
71
71
  admin,
72
72
  );
73
73
 
74
- const created = await stack.db
75
- .select()
76
- .from(eventsTable)
77
- .where(eq(eventsTable.type, "tenant-secret.created"));
74
+ const created = await selectMany(stack.db, eventsTable, { type: "tenant-secret.created" });
78
75
  expect(created.length).toBe(1);
79
76
  // aggregateType stable; downstream MSPs filter by this.
80
77
  expect(created[0]?.aggregateType).toBe("tenant-secret");
@@ -91,10 +88,7 @@ describe("tenantSecret lifecycle events", () => {
91
88
  );
92
89
  await stack.http.writeOk("secrets:write:delete", { key: "example.to.delete" }, admin);
93
90
 
94
- const events = await stack.db
95
- .select()
96
- .from(eventsTable)
97
- .where(eq(eventsTable.aggregateType, "tenant-secret"));
91
+ const events = await selectMany(stack.db, eventsTable, { aggregateType: "tenant-secret" });
98
92
 
99
93
  // Exactly 2 events on the same aggregate-stream: created + deleted.
100
94
  expect(events.length).toBe(2);
@@ -4,7 +4,9 @@
4
4
  // (samples/secrets-demo) shows the broader rotation + cross-feature flow;
5
5
  // this test covers just the feature's own handlers.
6
6
 
7
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7
8
  import { randomBytes } from "node:crypto";
9
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
8
10
  import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
9
11
  import {
10
12
  createEnvMasterKeyProvider,
@@ -16,8 +18,6 @@ import {
16
18
  type TestStack,
17
19
  unsafePushTables,
18
20
  } from "@cosmicdrift/kumiko-framework/stack";
19
- import { and, eq } from "drizzle-orm";
20
- import { afterAll, beforeAll, describe, expect, test } from "vitest";
21
21
  import { createSecretsFeature } from "../feature";
22
22
  import { createSecretsContext } from "../secrets-context";
23
23
  import { type StoredEnvelope, tenantSecretsTable } from "../table";
@@ -76,15 +76,10 @@ describe("secrets feature — CRUD round-trip", () => {
76
76
  expect(row?.kekVersion).toBe(1);
77
77
 
78
78
  // DB row holds an envelope, no plaintext
79
- const [dbRow] = await stack.db
80
- .select()
81
- .from(tenantSecretsTable)
82
- .where(
83
- and(
84
- eq(tenantSecretsTable.tenantId, admin.tenantId),
85
- eq(tenantSecretsTable.key, "api.key.x"),
86
- ),
87
- );
79
+ const [dbRow] = await selectMany(stack.db, tenantSecretsTable, {
80
+ tenantId: admin.tenantId,
81
+ key: "api.key.x",
82
+ });
88
83
  if (!dbRow) throw new Error("row missing");
89
84
  const env = dbRow.envelope as StoredEnvelope;
90
85
  expect(env.ciphertext).toBeTruthy();
@@ -0,0 +1,16 @@
1
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import type { DbRunner } from "@cosmicdrift/kumiko-framework/db";
3
+ import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
+ import type { StoredEnvelope } from "../../table";
5
+
6
+ export async function selectTenantSecretEnvelope(
7
+ db: DbRunner,
8
+ tenantId: TenantId,
9
+ key: string,
10
+ ): Promise<StoredEnvelope | undefined> {
11
+ const rows = await asRawClient(db).unsafe<{ envelope: StoredEnvelope }>(
12
+ `SELECT envelope FROM read_tenant_secrets WHERE tenant_id = $1 AND key = $2 LIMIT 1`,
13
+ [tenantId, key],
14
+ );
15
+ return rows[0]?.envelope;
16
+ }
@@ -1,5 +1,5 @@
1
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
1
2
  import { defineQueryHandler } from "@cosmicdrift/kumiko-framework/engine";
2
- import { eq } from "drizzle-orm";
3
3
  import { z } from "zod";
4
4
  import { tenantSecretsTable } from "../table";
5
5
 
@@ -11,28 +11,27 @@ export const listQuery = defineQueryHandler({
11
11
  schema: z.object({}),
12
12
  access: { roles: ["TenantAdmin"] },
13
13
  handler: async (event, ctx) => {
14
- const rows = await ctx.db.raw
15
- .select({
16
- key: tenantSecretsTable.key,
17
- kekVersion: tenantSecretsTable.kekVersion,
18
- metadata: tenantSecretsTable.metadata,
19
- lastRotatedAt: tenantSecretsTable.lastRotatedAt,
20
- // Post-ES the projection uses the framework base-columns — `inserted_at`
21
- // replaces the legacy `created_at`. Response stays on the `createdAt`
22
- // key so Admin-UIs don't have to re-map.
23
- createdAt: tenantSecretsTable.insertedAt,
24
- })
25
- .from(tenantSecretsTable)
26
- .where(eq(tenantSecretsTable.tenantId, event.user.tenantId))
27
- .orderBy(tenantSecretsTable.key);
28
-
14
+ const rows = await selectMany<{
15
+ key: string;
16
+ kekVersion: number;
17
+ metadata: { redactedPreview?: string; hint?: string };
18
+ lastRotatedAt: unknown;
19
+ insertedAt: unknown;
20
+ }>(
21
+ ctx.db.raw,
22
+ tenantSecretsTable,
23
+ { tenantId: event.user.tenantId },
24
+ {
25
+ orderBy: { col: "key", direction: "asc" },
26
+ },
27
+ );
29
28
  return rows.map((r) => ({
30
29
  key: r.key,
31
30
  redactedPreview: r.metadata.redactedPreview ?? null,
32
31
  hint: r.metadata.hint ?? null,
33
32
  kekVersion: r.kekVersion,
34
33
  lastRotatedAt: r.lastRotatedAt,
35
- createdAt: r.createdAt,
34
+ createdAt: r.insertedAt,
36
35
  }));
37
36
  },
38
37
  });
@@ -17,6 +17,7 @@
17
17
  // that landed the row on the new kekVersion first surfaces here as a
18
18
  // version_conflict error (counted as "skipped", not "failed").
19
19
 
20
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
20
21
  import {
21
22
  createEventStoreExecutor,
22
23
  createTenantDb,
@@ -26,7 +27,6 @@ import {
26
27
  import type { JobHandlerFn, SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
27
28
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
28
29
  import { rewrapDek } from "@cosmicdrift/kumiko-framework/secrets";
29
- import { ne } from "drizzle-orm";
30
30
  import { type StoredEnvelope, tenantSecretEntity, tenantSecretsTable } from "../table";
31
31
 
32
32
  const DEFAULT_BATCH_SIZE = 100;
@@ -100,17 +100,13 @@ export const rotateJob: JobHandlerFn = async (rawPayload, ctx): Promise<void> =>
100
100
  }
101
101
 
102
102
  const targetVersion = provider.currentVersion();
103
- const batch = await db
104
- .select({
105
- id: tenantSecretsTable.id,
106
- tenantId: tenantSecretsTable.tenantId,
107
- version: tenantSecretsTable.version,
108
- envelope: tenantSecretsTable.envelope,
109
- kekVersion: tenantSecretsTable.kekVersion,
110
- })
111
- .from(tenantSecretsTable)
112
- .where(ne(tenantSecretsTable.kekVersion, targetVersion))
113
- .limit(batchSize);
103
+ const batch = await selectMany<{
104
+ id: string;
105
+ tenantId: string;
106
+ version: number;
107
+ envelope: StoredEnvelope;
108
+ kekVersion: number;
109
+ }>(db, tenantSecretsTable, { kekVersion: { ne: targetVersion } }, { limit: batchSize });
114
110
 
115
111
  if (batch.length === 0) break;
116
112
 
@@ -12,11 +12,11 @@
12
12
  // read logged") now sits on the events-table instead of a
13
13
  // dedicated audit-table.
14
14
 
15
+ import { fetchOne, transaction } from "@cosmicdrift/kumiko-framework/bun-db";
15
16
  import {
16
17
  createEventStoreExecutor,
17
18
  createTenantDb,
18
19
  type DbConnection,
19
- fetchOne,
20
20
  } from "@cosmicdrift/kumiko-framework/db";
21
21
  import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
22
22
  import { InternalError, type WriteErrorInfo } from "@cosmicdrift/kumiko-framework/errors";
@@ -31,8 +31,8 @@ import {
31
31
  type SecretsContext,
32
32
  } from "@cosmicdrift/kumiko-framework/secrets";
33
33
  import { generateId } from "@cosmicdrift/kumiko-framework/utils";
34
- import { and, eq } from "drizzle-orm";
35
34
  import { z } from "zod";
35
+ import { selectTenantSecretEnvelope } from "./db/queries/read";
36
36
  import {
37
37
  type StoredEnvelope,
38
38
  type StoredMetadata,
@@ -118,12 +118,7 @@ export function createSecretsContext(opts: SecretsContextOptions): SecretsContex
118
118
  };
119
119
 
120
120
  async function lookup(tenantId: string, key: string): Promise<SecretLookupRow | undefined> {
121
- return fetchOne<SecretLookupRow>(
122
- db,
123
- tenantSecretsTable,
124
- eq(tenantSecretsTable.tenantId, tenantId),
125
- eq(tenantSecretsTable.key, key),
126
- );
121
+ return fetchOne<SecretLookupRow>(db, tenantSecretsTable, { tenantId, key });
127
122
  }
128
123
 
129
124
  return {
@@ -142,18 +137,11 @@ export function createSecretsContext(opts: SecretsContextOptions): SecretsContex
142
137
  return createSecret(plaintext);
143
138
  }
144
139
 
145
- const plaintext = await db.transaction(async (tx) => {
146
- // Inline select inside the TX — fetchOne's SelectChainDb shape
147
- // doesn't widen to drizzle's tx-object cleanly. Structurally
148
- // identical; the one-off repeat beats a double-cast at the
149
- // call site.
150
- const [row] = await tx
151
- .select({ envelope: tenantSecretsTable.envelope })
152
- .from(tenantSecretsTable)
153
- .where(and(eq(tenantSecretsTable.tenantId, tenantId), eq(tenantSecretsTable.key, key)))
154
- .limit(1);
155
- if (!row) return undefined;
156
- const envelope = row.envelope;
140
+ const plaintext = await transaction(db, async (tx) => {
141
+ // Inline select inside the TX via raw client — fetchOne's connection
142
+ // type doesn't widen to the transaction object cleanly.
143
+ const envelope = await selectTenantSecretEnvelope(tx, tenantId, key);
144
+ if (!envelope) return undefined;
157
145
  const pt = await decryptValue(decodeEnvelope(envelope), provider);
158
146
 
159
147
  // One event per read on its own aggregate-stream (fresh UUID as
@@ -173,7 +161,7 @@ export function createSecretsContext(opts: SecretsContextOptions): SecretsContex
173
161
  userId: auditCtx.userId,
174
162
  handlerName: auditCtx.handlerName,
175
163
  });
176
- await append(tx, {
164
+ await append(tx as unknown as Parameters<typeof append>[0], {
177
165
  aggregateId: readId,
178
166
  aggregateType: "tenantSecretRead",
179
167
  tenantId,
@@ -3,6 +3,7 @@ import {
3
3
  instant,
4
4
  integer,
5
5
  jsonb,
6
+ sql,
6
7
  table,
7
8
  text,
8
9
  uniqueIndex,
@@ -12,7 +13,6 @@ import {
12
13
  createNumberField,
13
14
  createTextField,
14
15
  } from "@cosmicdrift/kumiko-framework/engine";
15
- import { sql } from "drizzle-orm";
16
16
 
17
17
  // Envelope stored as a single jsonb blob. All ops are upsert-by-(tenantId, key)
18
18
  // so there's no value in decomposing the envelope into separate columns —
@@ -4,6 +4,9 @@
4
4
  // exercised by the framework's job tests. Here we pin the semantics: old
5
5
  // expired/revoked rows go, live rows stay, batching + signal work.
6
6
 
7
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
8
+ import { insertOne, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
9
+ import { sql } from "@cosmicdrift/kumiko-framework/db";
7
10
  import type { AppContext } from "@cosmicdrift/kumiko-framework/engine";
8
11
  import {
9
12
  setupTestStack,
@@ -11,8 +14,7 @@ import {
11
14
  testTenantId,
12
15
  unsafeCreateEntityTable,
13
16
  } from "@cosmicdrift/kumiko-framework/stack";
14
- import { sql } from "drizzle-orm";
15
- import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
17
+ import { resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
16
18
  import { createSessionsFeature } from "../feature";
17
19
  import { cleanupJob } from "../handlers/cleanup.job";
18
20
  import { userSessionEntity, userSessionTable } from "../schema/user-session";
@@ -46,7 +48,7 @@ afterAll(async () => {
46
48
  });
47
49
 
48
50
  beforeEach(async () => {
49
- await stack.db.delete(userSessionTable);
51
+ await resetTestTables(stack.db, [userSessionTable]);
50
52
  });
51
53
 
52
54
  type JobCtx = Pick<AppContext, "db" | "registry" | "log">;
@@ -74,7 +76,7 @@ async function seedSession(opts: {
74
76
  const past = sql`now() - ${sql.raw(`interval '${opts.ageDays} days'`)}`;
75
77
  const future = sql`now() + ${sql.raw(`interval '30 days'`)}`;
76
78
 
77
- await stack.db.insert(userSessionTable).values({
79
+ await insertOne(stack.db, userSessionTable, {
78
80
  id: opts.id,
79
81
  tenantId: TENANT,
80
82
  userId: opts.userId,
@@ -88,7 +90,7 @@ async function seedSession(opts: {
88
90
  }
89
91
 
90
92
  async function countSessions(): Promise<number> {
91
- const rows = await stack.db.select().from(userSessionTable);
93
+ const rows = await selectMany(stack.db, userSessionTable);
92
94
  return rows.length;
93
95
  }
94
96
 
@@ -111,7 +113,7 @@ describe("sessions cleanup job — purge expired/revoked rows", () => {
111
113
  await cleanupJob({}, jobCtx());
112
114
 
113
115
  expect(await countSessions()).toBe(1);
114
- const [remaining] = await stack.db.select().from(userSessionTable);
116
+ const [remaining] = await selectMany(stack.db, userSessionTable);
115
117
  expect(remaining?.["revokedAt"]).toBeNull();
116
118
  });
117
119
 
@@ -1,4 +1,6 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test";
1
2
  import { randomBytes } from "node:crypto";
3
+ import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
4
  import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
3
5
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
6
  import {
@@ -9,7 +11,6 @@ import {
9
11
  unsafePushTables,
10
12
  } from "@cosmicdrift/kumiko-framework/stack";
11
13
  import { createLateBoundHolder } from "@cosmicdrift/kumiko-framework/testing";
12
- import { afterAll, beforeAll, beforeEach, describe, expect, test, vi } from "vitest";
13
14
  import { AuthHandlers } from "../../auth-email-password/constants";
14
15
  import { createAuthEmailPasswordFeature } from "../../auth-email-password/feature";
15
16
  import { createConfigFeature } from "../../config";
@@ -38,7 +39,7 @@ const callbacks = createLateBoundHolder<SessionCallbacks>("session-callbacks");
38
39
 
39
40
  // vi.fn spy for the revoker — lets us assert exact call counts and arguments
40
41
  // per test without leaking module-level mutable state across suites.
41
- const massRevokeSpy = vi.fn<(userId: string) => Promise<number>>();
42
+ const massRevokeSpy = mock<(userId: string) => Promise<number>>();
42
43
 
43
44
  const encryptionKey = randomBytes(32).toString("base64");
44
45
 
@@ -88,9 +89,9 @@ afterAll(async () => {
88
89
  });
89
90
 
90
91
  beforeEach(async () => {
91
- await stack.db.delete(userTable);
92
- await stack.db.delete(tenantMembershipsTable);
93
- await stack.db.delete(userSessionTable);
92
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}"`);
93
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
94
+ await asRawClient(stack.db).unsafe(`DELETE FROM "${userSessionTable.tableName}"`);
94
95
  massRevokeSpy.mockClear();
95
96
  });
96
97
 
@@ -143,7 +144,7 @@ describe("password change mass-revokes every live session", () => {
143
144
  ).toBe(401);
144
145
 
145
146
  // DB state confirms: zero live rows for this user
146
- const liveRows = await stack.db.select().from(userSessionTable);
147
+ const liveRows = await selectMany(stack.db, userSessionTable);
147
148
  const stillLive = liveRows.filter((r) => r["revokedAt"] === null);
148
149
  expect(stillLive).toHaveLength(0);
149
150
 
@@ -1,4 +1,6 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
1
2
  import { randomBytes } from "node:crypto";
3
+ import { deleteMany, selectMany, updateMany } from "@cosmicdrift/kumiko-framework/bun-db";
2
4
  import { createEncryptionProvider } from "@cosmicdrift/kumiko-framework/db";
3
5
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
4
6
  import {
@@ -8,10 +10,8 @@ import {
8
10
  unsafeCreateEntityTable,
9
11
  unsafePushTables,
10
12
  } from "@cosmicdrift/kumiko-framework/stack";
11
- import { createLateBoundHolder } from "@cosmicdrift/kumiko-framework/testing";
12
- import { and, eq } from "drizzle-orm";
13
+ import { createLateBoundHolder, resetTestTables } from "@cosmicdrift/kumiko-framework/testing";
13
14
  import { Temporal } from "temporal-polyfill";
14
- import { afterAll, beforeAll, beforeEach, describe, expect, test } from "vitest";
15
15
  import { AuthHandlers } from "../../auth-email-password/constants";
16
16
  import { createAuthEmailPasswordFeature } from "../../auth-email-password/feature";
17
17
  import { createConfigFeature } from "../../config";
@@ -75,9 +75,7 @@ afterAll(async () => {
75
75
  });
76
76
 
77
77
  beforeEach(async () => {
78
- await stack.db.delete(userTable);
79
- await stack.db.delete(tenantMembershipsTable);
80
- await stack.db.delete(userSessionTable);
78
+ await resetTestTables(stack.db, [userTable, tenantMembershipsTable, userSessionTable]);
81
79
  });
82
80
 
83
81
  describe("sessions feature — login → check → revoke → rejected", () => {
@@ -85,7 +83,7 @@ describe("sessions feature — login → check → revoke → rejected", () => {
85
83
  await h.seedUser("persist@example.com", "pw-long-enough");
86
84
  const { sid } = await h.login("persist@example.com", "pw-long-enough");
87
85
 
88
- const rows = await stack.db.select().from(userSessionTable);
86
+ const rows = await selectMany(stack.db, userSessionTable);
89
87
  expect(rows).toHaveLength(1);
90
88
  expect(rows[0]?.["id"]).toBe(sid);
91
89
  expect(rows[0]?.["revokedAt"]).toBeNull();
@@ -126,7 +124,7 @@ describe("sessions feature — login → check → revoke → rejected", () => {
126
124
  const logoutRes = await h.authedPost("/api/auth/logout", token);
127
125
  expect(logoutRes.status).toBe(200);
128
126
 
129
- const rows = await stack.db.select().from(userSessionTable);
127
+ const rows = await selectMany(stack.db, userSessionTable);
130
128
  expect(rows[0]?.["id"]).toBe(sid);
131
129
  expect(rows[0]?.["revokedAt"]).not.toBeNull();
132
130
 
@@ -153,10 +151,7 @@ describe("sessions feature — login → check → revoke → rejected", () => {
153
151
  });
154
152
  expect(firstRevoke.status).toBe(200);
155
153
 
156
- const [rowAfterFirst] = await stack.db
157
- .select()
158
- .from(userSessionTable)
159
- .where(eq(userSessionTable["id"], first.sid));
154
+ const [rowAfterFirst] = await selectMany(stack.db, userSessionTable, { id: first.sid });
160
155
  const originalRevokedAt = rowAfterFirst?.["revokedAt"] as Temporal.Instant | null;
161
156
  expect(originalRevokedAt).not.toBeNull();
162
157
 
@@ -173,10 +168,7 @@ describe("sessions feature — login → check → revoke → rejected", () => {
173
168
  expect(body.error?.details?.reason).toBe("session_already_revoked");
174
169
 
175
170
  // Audit: the retry must NOT have touched the row. Same timestamp as t1.
176
- const [rowAfterRetry] = await stack.db
177
- .select()
178
- .from(userSessionTable)
179
- .where(eq(userSessionTable["id"], first.sid));
171
+ const [rowAfterRetry] = await selectMany(stack.db, userSessionTable, { id: first.sid });
180
172
  const preservedRevokedAt = rowAfterRetry?.["revokedAt"] as Temporal.Instant | null;
181
173
  expect(preservedRevokedAt?.epochMilliseconds).toBe(originalRevokedAt?.epochMilliseconds);
182
174
  });
@@ -340,10 +332,7 @@ describe("sessions feature — login → check → revoke → rejected", () => {
340
332
  // not a bypass hack). If the audit-guard were missing, the second
341
333
  // readout would move forward because one of the late racers would
342
334
  // have overwritten t1.
343
- const [row] = await stack.db
344
- .select()
345
- .from(userSessionTable)
346
- .where(eq(userSessionTable["id"], sid));
335
+ const [row] = await selectMany(stack.db, userSessionTable, { id: sid });
347
336
  const tAfterRace = row?.["revokedAt"] as Temporal.Instant | null;
348
337
  expect(tAfterRace).not.toBeNull();
349
338
 
@@ -356,10 +345,7 @@ describe("sessions feature — login → check → revoke → rejected", () => {
356
345
  });
357
346
  expect(retry.status).toBe(422);
358
347
 
359
- const [rowAfterRetry] = await stack.db
360
- .select()
361
- .from(userSessionTable)
362
- .where(eq(userSessionTable["id"], sid));
348
+ const [rowAfterRetry] = await selectMany(stack.db, userSessionTable, { id: sid });
363
349
  const tAfterRetry = rowAfterRetry?.["revokedAt"] as Temporal.Instant | null;
364
350
  expect(tAfterRetry?.epochMilliseconds).toBe(tAfterRace?.epochMilliseconds);
365
351
 
@@ -381,7 +367,7 @@ describe("sessions feature — login → check → revoke → rejected", () => {
381
367
 
382
368
  // Hard-delete the session row so it's gone from the store (as opposed to
383
369
  // soft-revoked). The JWT stays syntactically valid.
384
- await stack.db.delete(userSessionTable).where(eq(userSessionTable["id"], sid));
370
+ await deleteMany(stack.db, userSessionTable, { id: sid });
385
371
 
386
372
  const res = await h.authedPost("/api/query", token, {
387
373
  type: "user:query:user:me",
@@ -398,10 +384,12 @@ describe("sessions feature — login → check → revoke → rejected", () => {
398
384
 
399
385
  // Back-date expiresAt so the row is still present + not revoked, just
400
386
  // past its window. Simulates what a long-lived JWT would hit.
401
- await stack.db
402
- .update(userSessionTable)
403
- .set({ expiresAt: Temporal.Instant.from("2020-01-01T00:00:00Z") })
404
- .where(eq(userSessionTable["id"], sid));
387
+ await updateMany(
388
+ stack.db,
389
+ userSessionTable,
390
+ { expiresAt: Temporal.Instant.from("2020-01-01T00:00:00Z") },
391
+ { id: sid },
392
+ );
405
393
 
406
394
  const res = await h.authedPost("/api/query", token, {
407
395
  type: "user:query:user:me",
@@ -434,15 +422,12 @@ describe("sessions feature — login → check → revoke → rejected", () => {
434
422
  // so she gets a fresh JWT with the new role in its claims. This is the
435
423
  // actual production path — roles are tenant-membership data, not JWT
436
424
  // metadata we can fiddle with directly.
437
- await stack.db
438
- .update(tenantMembershipsTable)
439
- .set({ roles: JSON.stringify(["Admin"]) })
440
- .where(
441
- and(
442
- eq(tenantMembershipsTable.userId, aliceId),
443
- eq(tenantMembershipsTable.tenantId, TENANT),
444
- ),
445
- );
425
+ await updateMany(
426
+ stack.db,
427
+ tenantMembershipsTable,
428
+ { roles: JSON.stringify(["Admin"]) },
429
+ { userId: aliceId, tenantId: TENANT },
430
+ );
446
431
  const aliceAsAdmin = await h.login("alice2@example.com", "pw-long-enough");
447
432
 
448
433
  const asAdmin = await h.authedPost("/api/query", aliceAsAdmin.token, {
@@ -8,10 +8,10 @@
8
8
  // const { token, sid } = await h.login("x@example.com", "pw");
9
9
  // const res = await h.authedPost("/api/query", token, { type, payload });
10
10
 
11
+ import { expect } from "bun:test";
11
12
  import type { TenantId } from "@cosmicdrift/kumiko-framework/engine";
12
13
  import { type TestStack, TestUsers } from "@cosmicdrift/kumiko-framework/stack";
13
14
  import * as jose from "jose";
14
- import { expect } from "vitest";
15
15
  import { hashPassword } from "../../auth-email-password/password-hashing";
16
16
  import { seedTenantMembership } from "../../tenant/seeding";
17
17
  import { UserHandlers } from "../../user";
@@ -0,0 +1,21 @@
1
+ import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
2
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
3
+
4
+ export async function deleteStaleSessionsBatch(
5
+ db: DbConnection,
6
+ olderThanDays: number,
7
+ batchSize: number,
8
+ ): Promise<number> {
9
+ const rows = (await asRawClient(db).unsafe(
10
+ `DELETE FROM "read_user_sessions"
11
+ WHERE "id" IN (
12
+ SELECT "id" FROM "read_user_sessions"
13
+ WHERE "expires_at" < now() - ($1::int * interval '1 day')
14
+ OR "revoked_at" < now() - ($1::int * interval '1 day')
15
+ LIMIT $2
16
+ )
17
+ RETURNING "id"`,
18
+ [olderThanDays, batchSize],
19
+ )) as readonly { id: string }[];
20
+ return rows.length;
21
+ }
@@ -20,8 +20,7 @@
20
20
  import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
21
21
  import type { JobHandlerFn } from "@cosmicdrift/kumiko-framework/engine";
22
22
  import { InternalError } from "@cosmicdrift/kumiko-framework/errors";
23
- import { or, sql } from "drizzle-orm";
24
- import { userSessionTable } from "../schema/user-session";
23
+ import { deleteStaleSessionsBatch } from "../db/queries/cleanup";
25
24
 
26
25
  const DEFAULT_OLDER_THAN_DAYS = 30;
27
26
  const DEFAULT_BATCH_SIZE = 1000;
@@ -45,10 +44,8 @@ export const cleanupJob: JobHandlerFn = async (rawPayload, ctx): Promise<void> =
45
44
  message: "[sessions:cleanup] ctx.db missing — job context requires a database connection.",
46
45
  });
47
46
  }
48
- const db = ctx.db as DbConnection; // @cast-boundary db-operator
47
+ const db = ctx.db as DbConnection;
49
48
 
50
- // Coerce-and-validate: BullMQ payloads arrive as opaque JSON, so TS types
51
- // don't survive. Guard before the value is interpolated into SQL.
52
49
  const olderThanDaysRaw = payload.olderThanDays ?? DEFAULT_OLDER_THAN_DAYS;
53
50
  const olderThanDays = Number(olderThanDaysRaw);
54
51
  if (!Number.isFinite(olderThanDays) || olderThanDays < 0 || !Number.isInteger(olderThanDays)) {
@@ -61,8 +58,6 @@ export const cleanupJob: JobHandlerFn = async (rawPayload, ctx): Promise<void> =
61
58
  ? Date.now() + payload.maxDurationMs
62
59
  : Number.POSITIVE_INFINITY;
63
60
 
64
- const cutoff = sql`now() - (${olderThanDays} * interval '1 day')`;
65
-
66
61
  let deleted = 0;
67
62
  let batchesProcessed = 0;
68
63
  let stoppedReason: SessionCleanupResult["stoppedReason"] = "empty";
@@ -77,31 +72,13 @@ export const cleanupJob: JobHandlerFn = async (rawPayload, ctx): Promise<void> =
77
72
  break;
78
73
  }
79
74
 
80
- // DELETE-by-id-subquery with an explicit LIMIT so the lock stays short.
81
- // The WHERE clause is the safety net: we only touch rows that are
82
- // PAST-CUTOFF (expired OR revoked), never currently-live sessions. A
83
- // null-check in PG semantics: `x < cutoff` already excludes null.
84
- const rows = await db
85
- .delete(userSessionTable)
86
- .where(
87
- sql`${userSessionTable["id"]} in (
88
- select ${userSessionTable["id"]}
89
- from ${userSessionTable}
90
- where ${or(
91
- sql`${userSessionTable["expiresAt"]} < ${cutoff}`,
92
- sql`${userSessionTable["revokedAt"]} < ${cutoff}`,
93
- )}
94
- limit ${batchSize}
95
- )`,
96
- )
97
- .returning({ id: userSessionTable["id"] });
98
-
99
- if (rows.length === 0) break;
75
+ const batchDeleted = await deleteStaleSessionsBatch(db, olderThanDays, batchSize);
76
+ if (batchDeleted === 0) break;
100
77
 
101
- deleted += rows.length;
78
+ deleted += batchDeleted;
102
79
  batchesProcessed++;
103
80
 
104
- if (rows.length < batchSize) break;
81
+ if (batchDeleted < batchSize) break;
105
82
  }
106
83
 
107
84
  const result: SessionCleanupResult = { deleted, batchesProcessed, stoppedReason };