cartethyia 2.5.10 → 2.6.1

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 (369) hide show
  1. package/.env.example +8 -0
  2. package/dist/dashboard/assets/{BannedPage-Cd30Z0b9.js → BannedPage-wr370U7a.js} +1 -1
  3. package/dist/dashboard/assets/BannedPage-wr370U7a.js.gz +0 -0
  4. package/dist/dashboard/assets/{CliToolDetailPage-wf5u-ww6.js → CliToolDetailPage-D1sCezWa.js} +1 -1
  5. package/dist/dashboard/assets/CliToolDetailPage-D1sCezWa.js.gz +0 -0
  6. package/dist/dashboard/assets/{CliToolsPage-DykPvuck.js → CliToolsPage-DVWbJPEQ.js} +1 -1
  7. package/dist/dashboard/assets/CliToolsPage-DVWbJPEQ.js.gz +0 -0
  8. package/dist/dashboard/assets/{CombosPage-DwvSD3vs.js → CombosPage-CZc7fyMe.js} +1 -1
  9. package/dist/dashboard/assets/CombosPage-CZc7fyMe.js.gz +0 -0
  10. package/dist/dashboard/assets/{ConsoleLogPage-CIr7KDex.js → ConsoleLogPage-OPN3qRCa.js} +1 -1
  11. package/dist/dashboard/assets/ConsoleLogPage-OPN3qRCa.js.gz +0 -0
  12. package/dist/dashboard/assets/{CustomizationPage-82X0DDdH.js → CustomizationPage-DGTSqmwm.js} +1 -1
  13. package/dist/dashboard/assets/CustomizationPage-DGTSqmwm.js.gz +0 -0
  14. package/dist/dashboard/assets/HealthEventsModal-BujG2QP4.js +1 -0
  15. package/dist/dashboard/assets/HealthEventsModal-BujG2QP4.js.gz +0 -0
  16. package/dist/dashboard/assets/{LoginPage-CS5mPuVF.js → LoginPage-C_Jd_KgP.js} +1 -1
  17. package/dist/dashboard/assets/LoginPage-C_Jd_KgP.js.gz +0 -0
  18. package/dist/dashboard/assets/{ModelPicker-C0UCUsVJ.js → ModelPicker-DVny4S5_.js} +1 -1
  19. package/dist/dashboard/assets/ModelPicker-DVny4S5_.js.gz +0 -0
  20. package/dist/dashboard/assets/OverviewPage-Bpbu-VeU.js +1 -0
  21. package/dist/dashboard/assets/OverviewPage-Bpbu-VeU.js.gz +0 -0
  22. package/dist/dashboard/assets/ProviderDetailPage-BEhXOO4L.js +3 -0
  23. package/dist/dashboard/assets/ProviderDetailPage-BEhXOO4L.js.gz +0 -0
  24. package/dist/dashboard/assets/{ProvidersPage-vdja5ZPK.js → ProvidersPage-DbQnexQd.js} +1 -1
  25. package/dist/dashboard/assets/ProvidersPage-DbQnexQd.js.gz +0 -0
  26. package/dist/dashboard/assets/{ProxyPage-BB4CNm4-.js → ProxyPage-ByprzADo.js} +1 -1
  27. package/dist/dashboard/assets/ProxyPage-ByprzADo.js.gz +0 -0
  28. package/dist/dashboard/assets/QuotaPage-CvSfv6tP.js +1 -0
  29. package/dist/dashboard/assets/QuotaPage-CvSfv6tP.js.gz +0 -0
  30. package/dist/dashboard/assets/SettingsPage-_NzLq3C-.js +1 -0
  31. package/dist/dashboard/assets/SettingsPage-_NzLq3C-.js.gz +0 -0
  32. package/dist/dashboard/assets/{SetupPage-FadUOl9S.js → SetupPage-Bg9DcG-G.js} +1 -1
  33. package/dist/dashboard/assets/SetupPage-Bg9DcG-G.js.gz +0 -0
  34. package/dist/dashboard/assets/{SortableList-Cfy6_Khv.js → SortableList-C2PWPzJm.js} +1 -1
  35. package/dist/dashboard/assets/SortableList-C2PWPzJm.js.gz +0 -0
  36. package/dist/dashboard/assets/{StudioPage-CM92fgJf.js → StudioPage-Dky1zZMt.js} +1 -1
  37. package/dist/dashboard/assets/StudioPage-Dky1zZMt.js.gz +0 -0
  38. package/dist/dashboard/assets/{UsagePage-BT4MyQeT.js → UsagePage-C5Vdpd_c.js} +2 -2
  39. package/dist/dashboard/assets/UsagePage-C5Vdpd_c.js.gz +0 -0
  40. package/dist/dashboard/assets/{api-keys-DQYLQxpW.js → api-keys-C23S9sgp.js} +1 -1
  41. package/dist/dashboard/assets/api-keys-C23S9sgp.js.gz +0 -0
  42. package/dist/dashboard/assets/{app-B7pVRhJ1.js → app-2KhtHCv9.js} +1 -1
  43. package/dist/dashboard/assets/{app-B7pVRhJ1.js.gz → app-2KhtHCv9.js.gz} +0 -0
  44. package/dist/dashboard/assets/app-CEeaMHOy.css +2 -0
  45. package/dist/dashboard/assets/app-CEeaMHOy.css.gz +0 -0
  46. package/dist/dashboard/assets/{assert-C9FD_JwJ.js → assert-DU6qrMA_.js} +1 -1
  47. package/dist/dashboard/assets/assert-DU6qrMA_.js.gz +0 -0
  48. package/dist/dashboard/assets/{badge-Bvyr7D0u.js → badge-CKsF123d.js} +1 -1
  49. package/dist/dashboard/assets/badge-CKsF123d.js.gz +0 -0
  50. package/dist/dashboard/assets/{banner-DIvKAZih.js → banner-8j7uChA9.js} +1 -1
  51. package/dist/dashboard/assets/banner-8j7uChA9.js.gz +0 -0
  52. package/dist/dashboard/assets/{card-D5_gYIS_.js → card-CdsSlWA3.js} +1 -1
  53. package/dist/dashboard/assets/card-CdsSlWA3.js.gz +0 -0
  54. package/dist/dashboard/assets/{cli-tools-CNqzrfdb.js → cli-tools-BaFUuFFF.js} +1 -1
  55. package/dist/dashboard/assets/cli-tools-BaFUuFFF.js.gz +0 -0
  56. package/dist/dashboard/assets/{clipboard-button-B6vDBtbm.js → clipboard-button-uL5aYLfW.js} +1 -1
  57. package/dist/dashboard/assets/clipboard-button-uL5aYLfW.js.gz +0 -0
  58. package/dist/dashboard/assets/{dialog-CpjrgdwJ.js → dialog-C07hr5_G.js} +1 -1
  59. package/dist/dashboard/assets/dialog-C07hr5_G.js.gz +0 -0
  60. package/dist/dashboard/assets/{drawer-BWmt3b5y.js → drawer-BPO1TcKH.js} +1 -1
  61. package/dist/dashboard/assets/drawer-BPO1TcKH.js.gz +0 -0
  62. package/dist/dashboard/assets/entry-BNFjUjQ0.js +4 -0
  63. package/dist/dashboard/assets/entry-BNFjUjQ0.js.gz +0 -0
  64. package/dist/dashboard/assets/{entry-C_oakV3z.js → entry-Bz_CtDfC.js} +1 -1
  65. package/dist/dashboard/assets/entry-Bz_CtDfC.js.gz +0 -0
  66. package/dist/dashboard/assets/entry-GJRbmrpA.js +1 -0
  67. package/dist/dashboard/assets/entry-GJRbmrpA.js.gz +0 -0
  68. package/dist/dashboard/assets/{github-badge-RRHHWGxh.js → github-badge-DmQreSDd.js} +1 -1
  69. package/dist/dashboard/assets/github-badge-DmQreSDd.js.gz +0 -0
  70. package/dist/dashboard/assets/{index-Bhey_rg_.js → index-DyllNVCY.js} +2 -2
  71. package/dist/dashboard/assets/index-DyllNVCY.js.gz +0 -0
  72. package/dist/dashboard/assets/{inline-C2_EQ5Uf.js → inline-C9dCYaBZ.js} +1 -1
  73. package/dist/dashboard/assets/inline-C9dCYaBZ.js.gz +0 -0
  74. package/dist/dashboard/assets/{input-BMRlQ450.js → input-CjCnXweJ.js} +1 -1
  75. package/dist/dashboard/assets/input-CjCnXweJ.js.gz +0 -0
  76. package/dist/dashboard/assets/{layout-XR5xP8ew.js → layout-V6y8DAkl.js} +1 -1
  77. package/dist/dashboard/assets/layout-V6y8DAkl.js.gz +0 -0
  78. package/dist/dashboard/assets/{list-row-Cw07dioc.js → list-row-CM1AaG_y.js} +1 -1
  79. package/dist/dashboard/assets/list-row-CM1AaG_y.js.gz +0 -0
  80. package/dist/dashboard/assets/{live-BivGfuUs.js → live-ta6GMb-8.js} +1 -1
  81. package/dist/dashboard/assets/live-ta6GMb-8.js.gz +0 -0
  82. package/dist/dashboard/assets/{model-bans-CmffjLuG.js → model-bans-BmYTmbAk.js} +1 -1
  83. package/dist/dashboard/assets/model-bans-BmYTmbAk.js.gz +0 -0
  84. package/dist/dashboard/assets/{mutation-46P0oWMe.js → mutation-g_fduiNw.js} +1 -1
  85. package/dist/dashboard/assets/mutation-g_fduiNw.js.gz +0 -0
  86. package/dist/dashboard/assets/{numbers-DrdA9oSM.js → numbers-BOevJzdz.js} +1 -1
  87. package/dist/dashboard/assets/numbers-BOevJzdz.js.gz +0 -0
  88. package/dist/dashboard/assets/{query-keys-BFhGpwR1.js → query-keys-UETqfytp.js} +1 -1
  89. package/dist/dashboard/assets/query-keys-UETqfytp.js.gz +0 -0
  90. package/dist/dashboard/assets/{routing-CAb4F37R.js → routing-BPUo-W8d.js} +1 -1
  91. package/dist/dashboard/assets/routing-BPUo-W8d.js.gz +0 -0
  92. package/dist/dashboard/assets/{select-CxVVB__N.js → select-DAhoTabv.js} +1 -1
  93. package/dist/dashboard/assets/select-DAhoTabv.js.gz +0 -0
  94. package/dist/dashboard/assets/{settings-DNX9sydi.js → settings--3hxSyry.js} +1 -1
  95. package/dist/dashboard/assets/settings--3hxSyry.js.gz +0 -0
  96. package/dist/dashboard/assets/{stack-uPOwM9Hs.js → stack-_gfTdLVB.js} +1 -1
  97. package/dist/dashboard/assets/stack-_gfTdLVB.js.gz +0 -0
  98. package/dist/dashboard/assets/{state-D1Yz79iL.js → state-2c5V4zo7.js} +1 -1
  99. package/dist/dashboard/assets/state-2c5V4zo7.js.gz +0 -0
  100. package/dist/dashboard/assets/{switch-sevsRrRo.js → switch-Dkke7Naf.js} +1 -1
  101. package/dist/dashboard/assets/switch-Dkke7Naf.js.gz +0 -0
  102. package/dist/dashboard/assets/{useMutation-Dem0lXQj.js → useMutation-EDFRkn1i.js} +1 -1
  103. package/dist/dashboard/assets/useMutation-EDFRkn1i.js.gz +0 -0
  104. package/dist/dashboard/index.html +1 -1
  105. package/dist/dashboard/index.html.gz +0 -0
  106. package/dist/dashboard/precompressed-assets.json +51 -51
  107. package/migrations/README.md +52 -52
  108. package/migrations/full-postgres/0020_add_telemetry_event_analytics.sql +77 -0
  109. package/migrations/full-postgres/0021_add_api_key_concurrency_tolerance.sql +13 -0
  110. package/migrations/full-postgres/0022_add_inferhub_config_key.sql +17 -0
  111. package/migrations/full-postgres/0023_drop_api_key_concurrency_tolerance.sql +13 -0
  112. package/migrations/lite-sqlite3/0020_add_telemetry_event_analytics.sql +69 -0
  113. package/migrations/lite-sqlite3/0021_add_api_key_concurrency_tolerance.sql +13 -0
  114. package/migrations/lite-sqlite3/0022_add_inferhub_config_key.sql +17 -0
  115. package/migrations/lite-sqlite3/0023_drop_api_key_concurrency_tolerance.sql +13 -0
  116. package/package.json +96 -95
  117. package/scripts/cli/admin.ts +126 -128
  118. package/scripts/cli/autostart.ts +237 -237
  119. package/scripts/cli/background.ts +123 -110
  120. package/scripts/cli/bin/carte.cjs +8 -8
  121. package/scripts/cli/bin/cartethyia.cjs +177 -167
  122. package/scripts/cli/console-menu.ts +45 -30
  123. package/scripts/cli/controller.ts +145 -0
  124. package/scripts/cli/env.ts +99 -99
  125. package/scripts/cli/gateway.ts +264 -260
  126. package/scripts/cli/instance.ts +234 -224
  127. package/scripts/cli/main.ts +195 -167
  128. package/scripts/cli/platform.ts +64 -64
  129. package/scripts/cli/prompt.ts +217 -79
  130. package/scripts/cli/services.ts +88 -88
  131. package/scripts/cli/setup.ts +445 -445
  132. package/scripts/cli/start.ts +85 -85
  133. package/scripts/cli/startup-signal.ts +23 -23
  134. package/scripts/cli/telemetry.ts +70 -0
  135. package/scripts/cli/tray/hidden-window.vbs +16 -2
  136. package/scripts/cli/tray/index.ts +263 -263
  137. package/scripts/cli/tray/windows.ps1 +107 -107
  138. package/scripts/cli/tray.ts +29 -4
  139. package/scripts/cli/update.ts +39 -39
  140. package/scripts/cli/version.ts +67 -67
  141. package/scripts/cli/wordmark.ts +58 -58
  142. package/src/boot/app.ts +16 -1
  143. package/src/boot/dependencies.ts +128 -25
  144. package/src/config/resources.ts +137 -0
  145. package/src/config/runtime.ts +133 -76
  146. package/src/config/spec.ts +30 -29
  147. package/src/config/telemetry.ts +0 -25
  148. package/src/console/api-keys/bucket-spend.ts +14 -17
  149. package/src/console/api-keys/contracts.ts +410 -410
  150. package/src/console/api-keys/routes.ts +12 -0
  151. package/src/console/audit/store.ts +2 -3
  152. package/src/console/auth/setup-service.ts +8 -1
  153. package/src/console/backup/routes.ts +7 -0
  154. package/src/console/backup/service.ts +463 -463
  155. package/src/console/backup/store.ts +16 -11
  156. package/src/console/backup/validate.ts +1 -4
  157. package/src/console/cli-tools/fs-ops.ts +18 -2
  158. package/src/console/data-management/contracts.ts +1 -8
  159. package/src/console/data-management/operations.ts +61 -48
  160. package/src/console/data-management/routes.ts +4 -2
  161. package/src/console/domain-registration.ts +25 -3
  162. package/src/console/inferhub/client.ts +139 -0
  163. package/src/console/inferhub/key-store.ts +129 -0
  164. package/src/console/inferhub/normalize.ts +347 -0
  165. package/src/console/inferhub/routes.ts +411 -0
  166. package/src/console/inferhub/store.ts +109 -0
  167. package/src/console/inferhub/types.ts +183 -0
  168. package/src/console/observability/contracts.ts +2 -41
  169. package/src/console/observability/store.ts +176 -371
  170. package/src/console/providers/catalog/public-model-store.ts +0 -0
  171. package/src/console/providers/catalog/store.ts +23 -36
  172. package/src/console/quota/account-quota-shared.ts +52 -52
  173. package/src/console/quota/account-quota-tenant.ts +16 -24
  174. package/src/console/quota/refresh.ts +441 -441
  175. package/src/console/routing/pools/import-parse.ts +206 -206
  176. package/src/console/routing/pools/operations.ts +543 -543
  177. package/src/console/routing/pools/probe-verdict.ts +132 -132
  178. package/src/console/routing/pools/routes.ts +181 -181
  179. package/src/console/settings/contracts.ts +42 -40
  180. package/src/console/settings/store.ts +25 -25
  181. package/src/console/share/share-stats.ts +22 -4
  182. package/src/console/share/share-usage.ts +30 -4
  183. package/src/console/shared/cursor.ts +15 -0
  184. package/src/console/updates/routes.ts +12 -0
  185. package/src/main.ts +13 -5
  186. package/src/network/pool/resolver.ts +1104 -1104
  187. package/src/observability/client-name.ts +22 -0
  188. package/src/observability/event-log.ts +5 -29
  189. package/src/observability/event-query.ts +690 -0
  190. package/src/observability/log-ring.ts +26 -2
  191. package/src/observability/log-sanitize.ts +118 -0
  192. package/src/observability/logger.ts +17 -13
  193. package/src/observability/metrics.ts +35 -0
  194. package/src/observability/payload-capture.ts +114 -41
  195. package/src/observability/payload-store.ts +249 -24
  196. package/src/observability/raw-event-writer.ts +118 -0
  197. package/src/observability/runtime-metrics.ts +71 -1
  198. package/src/observability/telemetry-buffer.ts +100 -17
  199. package/src/observability/telemetry-reconciliation.ts +417 -0
  200. package/src/persistence/boot.ts +20 -9
  201. package/src/persistence/db-pg.ts +13 -0
  202. package/src/persistence/db-sqlite.ts +569 -497
  203. package/src/persistence/dialect.ts +3 -2
  204. package/src/persistence/migrate.ts +181 -181
  205. package/src/persistence/schema.ts +1496 -1406
  206. package/src/persistence/shared-cache.ts +5 -1
  207. package/src/persistence/stores/api-key-store.ts +581 -581
  208. package/src/persistence/stores/telemetry-store.ts +124 -63
  209. package/src/protocol/reasoning-text.ts +30 -0
  210. package/src/protocol/response/chat.ts +33 -7
  211. package/src/protocol/response/codex.ts +14 -2
  212. package/src/protocol/response/messages.ts +5 -2
  213. package/src/providers/authentication/oauth-flow-store.ts +3 -10
  214. package/src/providers/bundled-discovery-providers.ts +122 -122
  215. package/src/providers/default-registry.ts +10 -0
  216. package/src/providers/discovery/catalog/catalog-blob.ts +18 -18
  217. package/src/providers/discovery/models-dev-catalog.ts +354 -354
  218. package/src/providers/discovery/probe-phases.ts +43 -7
  219. package/src/providers/discovery/probing-service.ts +70 -32
  220. package/src/providers/integrations/antigravity/antigravity.ts +9 -2
  221. package/src/providers/integrations/buddy/buddy-catalog-shared.ts +85 -17
  222. package/src/providers/integrations/buddy/buddy-chat-shared.ts +934 -934
  223. package/src/providers/integrations/buddy/buddy-checkin.ts +5 -9
  224. package/src/providers/integrations/buddy/buddy-discovery-shared.ts +3 -7
  225. package/src/providers/integrations/buddy/buddy-quota-shared.ts +3 -1
  226. package/src/providers/integrations/buddy/codebuddy/cn-provider.ts +57 -49
  227. package/src/providers/integrations/buddy/codebuddy/provider.ts +180 -180
  228. package/src/providers/integrations/buddy/workbuddy/provider.ts +206 -206
  229. package/src/providers/integrations/buddy/workbuddy/quota.ts +161 -161
  230. package/src/providers/integrations/claude/claude-quota.ts +2 -2
  231. package/src/providers/integrations/claude/claude-user-profiles.ts +3 -2
  232. package/src/providers/integrations/claude/claude.ts +3 -4
  233. package/src/providers/integrations/cline/cline.ts +94 -8
  234. package/src/providers/integrations/codex/codex.ts +61 -37
  235. package/src/providers/integrations/cursor/cursor-catalog.ts +368 -368
  236. package/src/providers/integrations/cursor/cursor-http2.ts +300 -300
  237. package/src/providers/integrations/cursor/cursor-identity.ts +2 -4
  238. package/src/providers/integrations/cursor/cursor-request.ts +11 -0
  239. package/src/providers/integrations/cursor/cursor.ts +29 -17
  240. package/src/providers/integrations/cursor/protocol/framing.ts +129 -129
  241. package/src/providers/integrations/cursor/protocol/messages.ts +2380 -2380
  242. package/src/providers/integrations/cursor/protocol/struct.ts +305 -305
  243. package/src/providers/integrations/cursor/protocol/tool-canonical.ts +275 -0
  244. package/src/providers/integrations/grok/grok-quota.ts +279 -279
  245. package/src/providers/integrations/grok/grok-ratelimit-store.ts +73 -73
  246. package/src/providers/integrations/grok/grok-ratelimit.ts +218 -218
  247. package/src/providers/integrations/hermes.ts +7 -1
  248. package/src/providers/integrations/inferhub.ts +201 -73
  249. package/src/providers/integrations/kilo/kilo.ts +152 -152
  250. package/src/providers/integrations/kiro/kiro-machine-id.ts +2 -4
  251. package/src/providers/integrations/openrouter/provider.ts +129 -129
  252. package/src/providers/integrations/qoder/qoder-quota.ts +193 -192
  253. package/src/providers/integrations/qoder/qoder-realm.ts +106 -106
  254. package/src/providers/integrations/trae/trae-oauth.ts +4 -26
  255. package/src/providers/integrations/trae/trae-quota.ts +389 -384
  256. package/src/providers/integrations/trae/trae-realm.ts +15 -2
  257. package/src/providers/integrations/xiaomi-mimo/think-stream.ts +100 -24
  258. package/src/providers/integrations/zcode/oauth.ts +1 -1
  259. package/src/providers/operations/account-health-service.ts +605 -279
  260. package/src/providers/provider-metadata.ts +12 -1
  261. package/src/providers/provider-model-definition.ts +373 -373
  262. package/src/providers/quota/quota-contracts.ts +3 -7
  263. package/src/providers/search/search-provider.ts +2 -6
  264. package/src/security/admission/contracts.ts +10 -0
  265. package/src/security/admission/in-memory-store.ts +89 -14
  266. package/src/security/admission/lua.ts +191 -182
  267. package/src/security/admission/redis-store.ts +30 -0
  268. package/src/security/admission/service.ts +18 -0
  269. package/src/security/api-key-auth-cache.ts +96 -0
  270. package/src/security/api-key-auth.ts +11 -100
  271. package/src/security/client-router-fingerprint.ts +3 -7
  272. package/src/security/ip-boundary.ts +7 -3
  273. package/src/shared/coerce.ts +67 -0
  274. package/src/shared/hash.ts +6 -0
  275. package/src/shared/time.ts +7 -0
  276. package/src/transport/dispatch/attempt-finalize.ts +26 -138
  277. package/src/transport/dispatch/attempt-loop.ts +103 -81
  278. package/src/transport/dispatch/capture-policy.ts +78 -0
  279. package/src/transport/dispatch/proxy-request.ts +4 -2
  280. package/src/transport/dispatch/streaming/encoder.ts +111 -5
  281. package/src/transport/dispatch/streaming/stream.ts +19 -2
  282. package/src/transport/dispatch/streaming/thinking-loop.ts +300 -0
  283. package/src/transport/failure-policy.ts +27 -0
  284. package/src/transport/guards/abuse-guard.ts +2 -2
  285. package/src/transport/guards/auth-guard.ts +17 -0
  286. package/src/transport/guards/body-policy.ts +3 -2
  287. package/src/transport/guards/concurrency-guard.ts +89 -13
  288. package/src/transport/guards/early-rejection-capture.ts +43 -3
  289. package/src/transport/guards/error-lifecycle.ts +10 -2
  290. package/src/transport/guards/guard-errors.ts +23 -0
  291. package/src/transport/guards/headroom-guard.ts +41 -7
  292. package/src/transport/guards/pipeline.ts +24 -0
  293. package/src/transport/guards/request-context.ts +2 -1
  294. package/src/transport/routing/admission.ts +51 -9
  295. package/src/transport/routing/bounded-lru.ts +49 -0
  296. package/src/transport/routing/candidate-index.ts +88 -0
  297. package/src/transport/routing/reservations.ts +121 -7
  298. package/src/transport/routing/round-robin.ts +10 -22
  299. package/src/transport/routing/route-catalog.ts +26 -13
  300. package/src/transport/routing/route-model.ts +22 -2
  301. package/src/transport/routing/router.ts +53 -31
  302. package/src/transport/surface/chat/encode.ts +2 -3
  303. package/src/transport/surface/chat/parse.ts +2 -1
  304. package/src/transport/surface/dialects.ts +11 -3
  305. package/src/transport/surface/messages/encode.ts +2 -1
  306. package/src/transport/surface/messages/parse.ts +3 -1
  307. package/src/transport/surface/messages/stream.ts +2 -6
  308. package/src/workers/maintenance-lease.ts +200 -0
  309. package/src/workers/scheduler.ts +101 -1
  310. package/dist/dashboard/assets/BannedPage-Cd30Z0b9.js.gz +0 -0
  311. package/dist/dashboard/assets/CliToolDetailPage-wf5u-ww6.js.gz +0 -0
  312. package/dist/dashboard/assets/CliToolsPage-DykPvuck.js.gz +0 -0
  313. package/dist/dashboard/assets/CombosPage-DwvSD3vs.js.gz +0 -0
  314. package/dist/dashboard/assets/ConsoleLogPage-CIr7KDex.js.gz +0 -0
  315. package/dist/dashboard/assets/CustomizationPage-82X0DDdH.js.gz +0 -0
  316. package/dist/dashboard/assets/HealthEventsModal-BYPucTTn.js +0 -1
  317. package/dist/dashboard/assets/HealthEventsModal-BYPucTTn.js.gz +0 -0
  318. package/dist/dashboard/assets/LoginPage-CS5mPuVF.js.gz +0 -0
  319. package/dist/dashboard/assets/ModelPicker-C0UCUsVJ.js.gz +0 -0
  320. package/dist/dashboard/assets/OverviewPage-Dk_ew3YU.js +0 -1
  321. package/dist/dashboard/assets/OverviewPage-Dk_ew3YU.js.gz +0 -0
  322. package/dist/dashboard/assets/ProviderDetailPage-CAiRn2Q0.js +0 -3
  323. package/dist/dashboard/assets/ProviderDetailPage-CAiRn2Q0.js.gz +0 -0
  324. package/dist/dashboard/assets/ProvidersPage-vdja5ZPK.js.gz +0 -0
  325. package/dist/dashboard/assets/ProxyPage-BB4CNm4-.js.gz +0 -0
  326. package/dist/dashboard/assets/QuotaPage-FEc4iNyq.js +0 -1
  327. package/dist/dashboard/assets/QuotaPage-FEc4iNyq.js.gz +0 -0
  328. package/dist/dashboard/assets/SettingsPage-Dlfi5L4N.js +0 -1
  329. package/dist/dashboard/assets/SettingsPage-Dlfi5L4N.js.gz +0 -0
  330. package/dist/dashboard/assets/SetupPage-FadUOl9S.js.gz +0 -0
  331. package/dist/dashboard/assets/SortableList-Cfy6_Khv.js.gz +0 -0
  332. package/dist/dashboard/assets/StudioPage-CM92fgJf.js.gz +0 -0
  333. package/dist/dashboard/assets/UsagePage-BT4MyQeT.js.gz +0 -0
  334. package/dist/dashboard/assets/api-keys-DQYLQxpW.js.gz +0 -0
  335. package/dist/dashboard/assets/app-CX_Z8cMs.css +0 -2
  336. package/dist/dashboard/assets/app-CX_Z8cMs.css.gz +0 -0
  337. package/dist/dashboard/assets/assert-C9FD_JwJ.js.gz +0 -0
  338. package/dist/dashboard/assets/badge-Bvyr7D0u.js.gz +0 -0
  339. package/dist/dashboard/assets/banner-DIvKAZih.js.gz +0 -0
  340. package/dist/dashboard/assets/card-D5_gYIS_.js.gz +0 -0
  341. package/dist/dashboard/assets/cli-tools-CNqzrfdb.js.gz +0 -0
  342. package/dist/dashboard/assets/clipboard-button-B6vDBtbm.js.gz +0 -0
  343. package/dist/dashboard/assets/dialog-CpjrgdwJ.js.gz +0 -0
  344. package/dist/dashboard/assets/drawer-BWmt3b5y.js.gz +0 -0
  345. package/dist/dashboard/assets/entry-B-gvimrg.js +0 -1
  346. package/dist/dashboard/assets/entry-B-gvimrg.js.gz +0 -0
  347. package/dist/dashboard/assets/entry-C_oakV3z.js.gz +0 -0
  348. package/dist/dashboard/assets/entry-Dj0cHfEc.js +0 -4
  349. package/dist/dashboard/assets/entry-Dj0cHfEc.js.gz +0 -0
  350. package/dist/dashboard/assets/github-badge-RRHHWGxh.js.gz +0 -0
  351. package/dist/dashboard/assets/index-Bhey_rg_.js.gz +0 -0
  352. package/dist/dashboard/assets/inline-C2_EQ5Uf.js.gz +0 -0
  353. package/dist/dashboard/assets/input-BMRlQ450.js.gz +0 -0
  354. package/dist/dashboard/assets/layout-XR5xP8ew.js.gz +0 -0
  355. package/dist/dashboard/assets/list-row-Cw07dioc.js.gz +0 -0
  356. package/dist/dashboard/assets/live-BivGfuUs.js.gz +0 -0
  357. package/dist/dashboard/assets/model-bans-CmffjLuG.js.gz +0 -0
  358. package/dist/dashboard/assets/mutation-46P0oWMe.js.gz +0 -0
  359. package/dist/dashboard/assets/numbers-DrdA9oSM.js.gz +0 -0
  360. package/dist/dashboard/assets/query-keys-BFhGpwR1.js.gz +0 -0
  361. package/dist/dashboard/assets/routing-CAb4F37R.js.gz +0 -0
  362. package/dist/dashboard/assets/select-CxVVB__N.js.gz +0 -0
  363. package/dist/dashboard/assets/settings-DNX9sydi.js.gz +0 -0
  364. package/dist/dashboard/assets/stack-uPOwM9Hs.js.gz +0 -0
  365. package/dist/dashboard/assets/state-D1Yz79iL.js.gz +0 -0
  366. package/dist/dashboard/assets/switch-sevsRrRo.js.gz +0 -0
  367. package/dist/dashboard/assets/useMutation-Dem0lXQj.js.gz +0 -0
  368. package/src/observability/event-compaction.ts +0 -317
  369. package/src/observability/event-day-index.ts +0 -439
@@ -1,497 +1,569 @@
1
- /** Embedded backend: bun:sqlite in the gateway's data dir. */
2
- import { Database, type SQLQueryBindings } from "bun:sqlite";
3
- import { drizzle } from "drizzle-orm/bun-sqlite";
4
- import { getTableColumns, getTableName } from "drizzle-orm";
5
- import { randomUUID } from "node:crypto";
6
- import { mkdirSync, readFileSync } from "node:fs";
7
- import { dirname } from "node:path";
8
- import { resolvePackagePath } from "../config/package-paths";
9
- import { fullSchema, type DatabaseHandle } from "./db-handle";
10
- import {
11
- BASELINE_MIGRATION_ID,
12
- assertMigrationHistorySupported,
13
- baselineSchemaMarker,
14
- acceptedBaselineSchemaMarkers,
15
- MIGRATION_LEDGER_TABLE,
16
- migrationFiles,
17
- migrationIdFor,
18
- splitStatements,
19
- } from "./migrate";
20
-
21
- /**
22
- * Ledger DDL for the SQLite backend.
23
- *
24
- * The shared ledger DDL is PostgreSQL (`timestamptz`, `now()`), and this
25
- * backend never sends PostgreSQL DDL. The shape is the same table the
26
- * readiness check reads, so a ledger written here is a ledger read there.
27
- */
28
- const SQLITE_LEDGER_DDL = `CREATE TABLE IF NOT EXISTS ${MIGRATION_LEDGER_TABLE} (
29
- migration_id text PRIMARY KEY,
30
- applied_at text NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
31
- )`;
32
-
33
- /**
34
- * Opens the embedded database at `file`.
35
- *
36
- * `:memory:` is the test backend and is used as given. A filesystem path has
37
- * its parent created first, because bun:sqlite does not create directories.
38
- * Foreign keys are off unless asked for, so the pragma is set on every
39
- * connection rather than trusted to a default. WAL keeps a reader from
40
- * blocking the single writer.
41
- */
42
- /**
43
- * Databases whose `prepare`/`exec` have already been wrapped.
44
- *
45
- * Keyed weakly so a closed in-memory database is not retained by the registry.
46
- */
47
- const SQLITE_PREPARED = new WeakSet<Database>();
48
-
49
- export function createSqliteDatabase(file: string): Database {
50
- if (file !== ":memory:") mkdirSync(dirname(file), { recursive: true });
51
- const db = new Database(file);
52
- db.exec("PRAGMA foreign_keys = ON");
53
- if (file !== ":memory:") applyFilePragmas(db);
54
- // Guarded: this function has to run per database, but a caller may hand it
55
- // one that already went through here (a test builds a handle over a booted
56
- // handle). Wrapping `prepare` twice ran the translation twice, and the
57
- // second pass mangled SQL the first had already rewritten — `unrecognized
58
- // token: ":"` from a placeholder the first pass produced.
59
- if (SQLITE_PREPARED.has(db)) return db;
60
- SQLITE_PREPARED.add(db);
61
- const prepare = db.prepare.bind(db);
62
- db.prepare = ((text: string) => {
63
- const statement = prepare(sqliteSql(text));
64
- // bun:sqlite rejects a Date binding ("Binding expected string, TypedArray,
65
- // boolean, number, bigint or null"), and Drizzle's pg-core timestamp
66
- // columns bind `Date` objects. The schema is shared with PostgreSQL, so
67
- // the conversion lives here: every timestamp column is `text`, and an ISO
68
- // string is exactly what those columns store.
69
- const bind = (value: unknown): SQLQueryBindings =>
70
- value instanceof Date ? value.toISOString() : (value as SQLQueryBindings);
71
- const run = statement.run.bind(statement);
72
- const all = statement.all.bind(statement);
73
- const get = statement.get.bind(statement);
74
- // Drizzle's bun-sqlite session reads rows through `values`, not `all`.
75
- const values = statement.values.bind(statement);
76
- const bound = (args: unknown[]): SQLQueryBindings[] => args.map(bind);
77
- statement.run = ((...args: unknown[]) => run(...bound(args))) as typeof statement.run;
78
- statement.all = ((...args: unknown[]) => all(...bound(args))) as typeof statement.all;
79
- statement.get = ((...args: unknown[]) => get(...bound(args))) as typeof statement.get;
80
- statement.values = ((...args: unknown[]) => values(...bound(args))) as typeof statement.values;
81
- return statement;
82
- }) as Database["prepare"];
83
- const exec = db.exec.bind(db);
84
- db.exec = ((text: string) => exec(sqliteSql(text))) as Database["exec"];
85
- return db;
86
- }
87
-
88
- /**
89
- * Teaches the shared pg-core columns to read what bun:sqlite returns.
90
- *
91
- * The schema is written for node-postgres, whose driver hands booleans back
92
- * as booleans and timestamps as `Date`. bun:sqlite hands back the stored
93
- * value unchanged: an integer for a boolean, text for a timestamp. A
94
- * column's `mapFromDriverValue` is what turns a driver value into the type
95
- * the schema declares, and Drizzle calls it for every read — a select, a
96
- * join, a `returning()`. The pg-core boolean column returns its value
97
- * unchanged and the timestamp column only converts strings, so neither
98
- * produces what the schema promises from bun:sqlite's values. Replacing the
99
- * mapper on each column instance fixes every read at once. Writes are
100
- * untouched: booleans already bind as integers, and `Date` bindings are
101
- * converted to ISO strings in `createSqliteDatabase`.
102
- */
103
- function installSqliteColumnMapping(schema: Record<string, unknown>): void {
104
-
105
- for (const value of Object.values(schema)) {
106
- const columns = tableColumns(value);
107
- if (columns === undefined) continue;
108
- const table = getTableName(Object.values(columns)[0]!.table as Parameters<typeof getTableName>[0]);
109
- for (const column of Object.values(columns)) {
110
- if (column.columnType === "PgBoolean") {
111
- column.mapFromDriverValue = (read) => read === null ? null : read === 1 || read === true;
112
- } else if (column.columnType === "PgTimestamp") {
113
- const convert = column.mapFromDriverValue.bind(column);
114
- column.mapFromDriverValue = (read) => {
115
- // A column declared `text` keeps whatever it was bound as, so a
116
- // timestamp can come back as the ISO string the writer produces, or
117
- // as a number — or the digits of one — when a row was restored from
118
- // a source that stored epoch milliseconds. `new Date` reads the
119
- // digits as a year and returns an invalid date, so those are
120
- // handled before the string conversion.
121
- if (typeof read === "number") return new Date(read);
122
- if (typeof read === "string" && /^\d+$/.test(read)) return new Date(Number(read));
123
- return convert(read);
124
- };
125
- }
126
- // Registered after the branches above replace it, so execute and the
127
- // builder call the same function. A column neither branch touches keeps
128
- // its own mapper, which is what already stringifies a numeric and parses
129
- // jsonb. execute is the one read that never called it.
130
- if (table !== undefined) {
131
- columnMapping.set(mappingKey(table, column.name), column.mapFromDriverValue.bind(column));
132
- }
133
- }
134
- }
135
- }
136
-
137
- interface MappedColumn {
138
- readonly name: string;
139
- readonly columnType: string;
140
- readonly table: object;
141
- mapFromDriverValue: (value: unknown) => unknown;
142
- }
143
-
144
- /**
145
- * `table.column` to the mapper that turns bun:sqlite's stored value into the
146
- * type the schema declares. `db.execute` reads through `prepare().all()`, which
147
- * never calls a column mapper, so the alias applies these itself. A name that
148
- * two tables share maps to the same type everywhere in this schema, and a
149
- * column with no entry is returned as stored.
150
- */
151
- const columnMapping = new Map<string, (value: unknown) => unknown>();
152
-
153
- function mappingKey(table: string, column: string): string {
154
- return `${table}.${column}`;
155
- }
156
-
157
- /** One row from `prepare().all()`, with every known column mapped. */
158
- function mapExecuteRow(row: Record<string, unknown>, table: string | undefined): Record<string, unknown> {
159
- if (table === undefined) return row;
160
- const mapped: Record<string, unknown> = {};
161
- for (const [column, value] of Object.entries(row)) {
162
- const convert = columnMapping.get(mappingKey(table, column));
163
- mapped[column] = value === null || convert === undefined ? value : convert(value);
164
- }
165
- return mapped;
166
- }
167
-
168
- /**
169
- * The table a raw statement reads, when it reads exactly one.
170
- *
171
- * `SELECT 1` and a join read columns from nowhere or from several tables, and
172
- * neither can be mapped by name. Both are returned as stored, which is what
173
- * they already were.
174
- */
175
- interface RenderedStatement {
176
- readonly sql: string;
177
- readonly params: SQLQueryBindings[];
178
- }
179
-
180
- /**
181
- * A Drizzle `sql` value as bun:sqlite runs it: `?` placeholders and the values
182
- * that fill them, in order.
183
- *
184
- * A chunk is text, a nested statement, or a bound value. Text is concatenated,
185
- * a nested statement is rendered in place, and everything else is one `?`.
186
- * `Date` values are converted here too, because this path prepares through the
187
- * raw database rather than through the wrapper in `createSqliteDatabase`.
188
- */
189
- function renderExecute(query: unknown): RenderedStatement | undefined {
190
- const chunks = (query as { queryChunks?: unknown[] }).queryChunks;
191
- if (chunks === undefined) return undefined;
192
- const params: SQLQueryBindings[] = [];
193
- const sql = chunks.map((chunk) => renderChunk(chunk, params)).join("");
194
- return { sql, params };
195
- }
196
-
197
- function renderChunk(chunk: unknown, params: SQLQueryBindings[]): string {
198
- if (Array.isArray(chunk)) {
199
- return `(${chunk.map((part) => renderChunk(part, params)).join(", ")})`;
200
- }
201
- if (typeof chunk === "object" && chunk !== null && "queryChunks" in chunk) {
202
- return (chunk as { queryChunks: unknown[] }).queryChunks
203
- .map((part) => renderChunk(part, params))
204
- .join("");
205
- }
206
- if (typeof chunk === "object" && chunk !== null && Array.isArray((chunk as { value?: unknown }).value)) {
207
- return ((chunk as { value: string[] }).value).join("");
208
- }
209
- // A table, column, or quoted name is SQL text, not a bound value. Rendering
210
- // one as a placeholder produces `DELETE FROM ?`, which SQLite rejects with
211
- // `near "?": syntax error` — the failure the payload sweep logs every pass.
212
- const kind = drizzleKind(chunk);
213
- if (kind === "Table") {
214
- return `"${getTableName(chunk as Parameters<typeof getTableName>[0])}"`;
215
- }
216
- if (kind === "Name") return `"${(chunk as { value: string }).value}"`;
217
- if (kind === "Column") return `"${(chunk as { name: string }).name}"`;
218
- const value = chunk instanceof Date ? chunk.toISOString() : chunk;
219
- params.push(value as SQLQueryBindings);
220
- return "?";
221
- }
222
-
223
-
224
- function drizzleKind(chunk: unknown): string | undefined {
225
- if (typeof chunk !== "object" || chunk === null) return undefined;
226
- // A pg column's own kind is `PgUUID` or `PgTimestamp`, not `Column`. Walk
227
- // the constructors: the base `Column` marks every one of them.
228
- let ctor = chunk.constructor as { [entityKind]?: unknown } | null;
229
- while (ctor !== null && ctor !== Function.prototype) {
230
- const kind = ctor[entityKind];
231
- if (kind === "Column" || kind === "Table" || kind === "Name") return kind;
232
- ctor = Object.getPrototypeOf(ctor) as { [entityKind]?: unknown } | null;
233
- }
234
- return undefined;
235
- }
236
-
237
- const entityKind = Symbol.for("drizzle:entityKind");
238
- function singleTable(sql: string): string | undefined {
239
- const tables = [...sql.matchAll(/\b(?:from|join)\s+"?([a-z0-9_]+)"?/gi)].map((match) => match[1]);
240
- const distinct = new Set(tables);
241
- return distinct.size === 1 ? [...distinct][0] : undefined;
242
- }
243
-
244
- /** The columns of one Drizzle table, or undefined for everything else in the schema. */
245
- function tableColumns(value: unknown): Record<string, MappedColumn> | undefined {
246
- if (typeof value !== "object" || value === null) return undefined;
247
- try {
248
- const columns = getTableColumns(value as Parameters<typeof getTableColumns>[0]);
249
- return Object.keys(columns).length > 0 ? columns as Record<string, MappedColumn> : undefined;
250
- } catch {
251
- return undefined;
252
- }
253
- }
254
-
255
- /**
256
- * The handle stores run on.
257
- *
258
- * `db` is the bun-sqlite Drizzle instance. It is not the node-postgres
259
- * `CartethyiaDatabase` the stores are typed against — the two query builders
260
- * are different types — so the conversion happens once, here. The stores
261
- * exercise only the builder surface both drivers share.
262
- */
263
- /**
264
- * The three statements the auth path runs on a cache miss, prepared once.
265
- *
266
- * Drizzle rebuilds the query on every call, and that rebuild measured 60x the
267
- * cost of the lookup itself. These are static SQL against an index, so there
268
- * is nothing for a builder to decide per call. The column list is exactly the
269
- * `api_keys` row the store maps, in the order `findActiveByHash` reads it.
270
- */
271
- const AUTH_KEY_COLUMNS = [
272
- "id", "tenant_id", "key_hash", "key_mode", "parent_key_id", "issued_client_ip",
273
- "issued_client_ip_key", "label", "scopes", "requests_per_minute", "daily_token_limit",
274
- "monthly_token_limit", "lifetime_token_budget", "lifetime_tokens_consumed",
275
- "max_concurrent_requests", "model_access_mode", "model_list", "client_router_denylist",
276
- "model_prefix", "created_at", "enabled", "revoked_at", "key_prefix", "key_encrypted",
277
- "notes_title", "notes_subtitle", "notes_body", "is_default", "sort_index",
278
- ].join(", ");
279
-
280
- export interface SqliteAuthStatements {
281
- /** One active key by its hash. The auth path's first read. */
282
- readonly byHash: { get: (hash: string) => Record<string, unknown> | null };
283
- /** One active key by its id. Run only for a key that has a parent. */
284
- readonly byId: { get: (id: string) => Record<string, unknown> | null };
285
- /** Lifetime consumption across one key's children, revoked included. */
286
- readonly childrenConsumed: { get: (parentId: string) => { total: number } | null };
287
- }
288
-
289
- export function prepareAuthStatements(sqlite: Database): SqliteAuthStatements {
290
- const active = `enabled = 1 AND revoked_at IS NULL`;
291
- return {
292
- byHash: sqlite.prepare(
293
- `SELECT ${AUTH_KEY_COLUMNS} FROM api_keys WHERE key_hash = ? AND ${active} LIMIT 1`,
294
- ) as SqliteAuthStatements["byHash"],
295
- byId: sqlite.prepare(
296
- `SELECT ${AUTH_KEY_COLUMNS} FROM api_keys WHERE id = ? AND ${active} LIMIT 1`,
297
- ) as SqliteAuthStatements["byId"],
298
- childrenConsumed: sqlite.prepare(
299
- `SELECT coalesce(sum(lifetime_tokens_consumed), 0) AS total FROM api_keys WHERE parent_key_id = ?`,
300
- ) as SqliteAuthStatements["childrenConsumed"],
301
- };
302
- }
303
-
304
-
305
- // A reloaded module must repatch the columns even when the handle is the
306
- // one booted before the reload. See `buildSqliteHandle`.
307
- installSqliteColumnMapping(fullSchema);
308
-
309
- export function buildSqliteHandle(sqlite: Database): DatabaseHandle {
310
- // Reinstalled on every call, not guarded. `bun --hot` keeps the booted
311
- // handle in `globalThis` and never rebuilds it, so a mapping installed
312
- // once at boot dies with the module it patched: booleans came back as
313
- // integers and login rejected a valid user as `unknown_user`. The
314
- // reloaded module calls this again and repatches the live columns.
315
- installSqliteColumnMapping(fullSchema);
316
- const built = drizzle(sqlite, { schema: fullSchema });
317
- // node-postgres exposes `execute` for raw SQL. bun-sqlite exposes `run` and
318
- // `all` instead, and the stores were written against `execute`. The alias
319
- // goes through `exec` rather than `run`: `run` prepares a statement per
320
- // call, and a raw statement here carries its values inlined, so there is
321
- // nothing to bind. Affected rows are read back from `changes()`.
322
- const execute = (query: unknown): { rowCount: number; rows: unknown[] } => {
323
- // Rendered the way the bun-sqlite session renders it: `?` placeholders and
324
- // a params list. Joining the chunks kept only their text, so an
325
- // interpolated value was dropped and the statement reached SQLite with a
326
- // hole in it.
327
- const rendered = renderExecute(query);
328
- if (rendered === undefined) throw new Error("sqlite execute: query has no SQL text");
329
- // `all` for a statement that returns rows, `exec` otherwise. Readiness
330
- // reads the migration ledger through `execute` and decides "applied" from
331
- // `result.rows`; an alias that only reported `changes()` made every select
332
- // look empty, so a migrated database stayed "not ready" forever.
333
- const returnsRows = /^\s*(select|with|pragma)\b/i.test(rendered.sql);
334
- if (returnsRows) {
335
- // `all()` returns stored values, not mapped ones, so a timestamp comes
336
- // back as text and a boolean as 0 or 1. The builder path maps through
337
- // the column; this is the only read that does not, and the backup
338
- // import compares these values against the builder's. Map them here.
339
- const rows = sqlite.prepare(rendered.sql).all(...rendered.params) as Record<string, unknown>[];
340
- const table = singleTable(rendered.sql);
341
- return { rowCount: rows.length, rows: rows.map((row) => mapExecuteRow(row, table)) };
342
- }
343
- sqlite.prepare(rendered.sql).run(...rendered.params);
344
- const changed = sqlite.prepare("SELECT changes() AS c").get() as { c: number } | null;
345
- return { rowCount: changed?.c ?? 0, rows: [] };
346
- };
347
- // A transaction callback receives a fresh builder, not this handle, so the
348
- // `execute` alias above would not reach it. First-boot setup takes its lock
349
- // through `transaction.execute`, and without the alias that call throws and
350
- // setup can never complete on the SQLite backend.
351
- const transaction = built.transaction.bind(built);
352
- const db = Object.assign(built, {
353
- execute,
354
- transaction: (fn: (tx: unknown) => unknown) =>
355
- transaction((tx: object) => fn(Object.assign(tx, { execute }))),
356
- });
357
- return {
358
- kind: "sqlite",
359
- db: db as unknown as DatabaseHandle["db"],
360
- sqlite,
361
- auth: prepareAuthStatements(sqlite),
362
- query: (text, params) => {
363
- // bun:sqlite types `all` as a tuple spread, which a `readonly unknown[]`
364
- // cannot satisfy. The values are the bound parameters the caller built;
365
- // the cast is confined to this one call.
366
- const bound = (params ?? []) as Parameters<ReturnType<Database["prepare"]>["all"]>;
367
- const rows = sqlite.prepare(text).all(...bound) as unknown[];
368
- return Promise.resolve({ rows });
369
- },
370
- exec: (script) => {
371
- sqlite.exec(script);
372
- return Promise.resolve();
373
- },
374
- close: () => {
375
- sqlite.close();
376
- return Promise.resolve();
377
- },
378
- };
379
- }
380
-
381
- /**
382
- * Applies the SQLite migration set in order.
383
- *
384
- * Same ledger, same skip-when-recorded rule, and the same one-file-per-
385
- * transaction atomicity as the PostgreSQL runner. Two deliberate absences,
386
- * both consequences of being the only process: no advisory lock, because a
387
- * second process cannot open the file for writing, and no
388
- * `to_regclass` probe, because SQLite has no schemas — `sqlite_master`
389
- * answers the same question.
390
- */
391
- /**
392
- * Rewrites the two PostgreSQL functions the schema's defaults emit.
393
- *
394
- * `gen_random_uuid()` becomes a quoted uuid. `now()` becomes a quoted UTC
395
- * timestamp. Both are matched only as function calls, so a column or string
396
- * that merely contains the words is left alone.
397
- */
398
- /**
399
- * Rewrites the two PostgreSQL functions the schema's defaults emit, and only
400
- * those. A statement that contains neither is returned as-is, so the common
401
- * case — every read, and every write that supplies its own values — pays one
402
- * `includes` and nothing else.
403
- */
404
- function sqliteSql(text: string): string {
405
- if (!text.includes("gen_random_uuid()") && !text.includes("now()")) return text;
406
- return text
407
- .replaceAll("gen_random_uuid()", () => `'${randomUUID()}'`)
408
- .replaceAll("now()", () => `'${new Date().toISOString()}'`);
409
- }
410
-
411
- /**
412
- * Pragmas for a file-backed database. Each one removes a stall or a fsync the
413
- * default pays per commit, and none of them weakens crash safety below what
414
- * the usage flush already accepts (at most the last second of deltas).
415
- *
416
- * `synchronous = NORMAL` is durable against a process crash under WAL; only a
417
- * power loss can drop the last commit. `busy_timeout` makes a writer wait
418
- * instead of failing instantly when a sweep holds the lock. The cache is 8 MB:
419
- * large enough for auth and the active catalog working set, without reserving
420
- * 64 MB that an idle gateway never touches. `temp_store` keeps sorts off disk.
421
- * The checkpoint interval keeps a request from stalling on one.
422
- */
423
- function applyFilePragmas(db: Database): void {
424
- db.exec("PRAGMA journal_mode = WAL");
425
- db.exec("PRAGMA synchronous = NORMAL");
426
- db.exec("PRAGMA busy_timeout = 5000");
427
- db.exec("PRAGMA cache_size = -8192");
428
- db.exec("PRAGMA temp_store = MEMORY");
429
- db.exec("PRAGMA wal_autocheckpoint = 1000");
430
- }
431
-
432
- /** The SQLite set lives beside the PostgreSQL one, never inside it. */
433
- export function resolveSqliteMigrationsFolder(): string {
434
- return resolvePackagePath("migrations", "lite-sqlite3");
435
- }
436
-
437
- export async function applySqliteMigrations(
438
- sqlite: Database,
439
- folder = resolveSqliteMigrationsFolder(),
440
- ): Promise<void> {
441
- sqlite.exec(SQLITE_LEDGER_DDL);
442
- const appliedRows = sqlite
443
- .prepare(`SELECT migration_id FROM ${MIGRATION_LEDGER_TABLE}`)
444
- .all() as { migration_id: string }[];
445
- const applied = new Set(appliedRows.map((row) => row.migration_id));
446
-
447
- const tenants = sqlite
448
- .prepare("SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'tenants'")
449
- .get() as { present: number } | null;
450
- const files = migrationFiles(folder);
451
- const baselineFile = files.find((file) => migrationIdFor(file) === BASELINE_MIGRATION_ID);
452
- const baselineSource =
453
- baselineFile === undefined ? undefined : readFileSync(baselineFile, "utf8");
454
- const baselineMarker =
455
- baselineSource === undefined ? undefined : baselineSchemaMarker(baselineSource);
456
- assertMigrationHistorySupported(
457
- applied,
458
- [
459
- ...files.map(migrationIdFor),
460
- ...(baselineMarker === undefined ? [] : [baselineMarker]),
461
- ...(baselineSource === undefined ? [] : acceptedBaselineSchemaMarkers(baselineSource)),
462
- ],
463
- tenants !== null,
464
- baselineMarker,
465
- );
466
-
467
- for (const file of files) {
468
- const migrationId = migrationIdFor(file);
469
- if (applied.has(migrationId)) continue;
470
- const source = readFileSync(file, "utf8");
471
- const record = sqlite.prepare(
472
- `INSERT INTO ${MIGRATION_LEDGER_TABLE} (migration_id) VALUES (?)`,
473
- );
474
- try {
475
- sqlite.transaction(() => {
476
- for (const statement of splitStatements(source)) {
477
- // The generated baseline opens with a comment block, and a
478
- // breakpoint at the end of a file leaves an empty part. `exec`
479
- // rejects both, so only text that still contains SQL is sent.
480
- const sql = statement.replace(/--[^\n]*/g, "").trim();
481
- if (sql.length === 0) continue;
482
- sqlite.exec(sql);
483
- }
484
- record.run(migrationId);
485
- if (migrationId === BASELINE_MIGRATION_ID && baselineMarker !== undefined) {
486
- record.run(baselineMarker);
487
- }
488
- })();
489
- applied.add(migrationId);
490
- if (migrationId === BASELINE_MIGRATION_ID && baselineMarker !== undefined) {
491
- applied.add(baselineMarker);
492
- }
493
- } catch (error) {
494
- throw new Error(`Migration ${migrationId} failed: ${String(error)}`, { cause: error });
495
- }
496
- }
497
- }
1
+ /** Embedded backend: bun:sqlite in the gateway's data dir. */
2
+ import { Database, type SQLQueryBindings } from "bun:sqlite";
3
+ import type { Statement } from "bun:sqlite";
4
+ import { drizzle } from "drizzle-orm/bun-sqlite";
5
+ import { getTableColumns, getTableName } from "drizzle-orm";
6
+ import { randomUUID } from "node:crypto";
7
+ import { mkdirSync, readFileSync } from "node:fs";
8
+ import { dirname } from "node:path";
9
+ import { resolvePackagePath } from "../config/package-paths";
10
+ import { fullSchema, type DatabaseHandle } from "./db-handle";
11
+ import {
12
+ BASELINE_MIGRATION_ID,
13
+ assertMigrationHistorySupported,
14
+ baselineSchemaMarker,
15
+ acceptedBaselineSchemaMarkers,
16
+ MIGRATION_LEDGER_TABLE,
17
+ migrationFiles,
18
+ migrationIdFor,
19
+ splitStatements,
20
+ } from "./migrate";
21
+
22
+ /**
23
+ * Ledger DDL for the SQLite backend.
24
+ *
25
+ * The shared ledger DDL is PostgreSQL (`timestamptz`, `now()`), and this
26
+ * backend never sends PostgreSQL DDL. The shape is the same table the
27
+ * readiness check reads, so a ledger written here is a ledger read there.
28
+ */
29
+ const SQLITE_LEDGER_DDL = `CREATE TABLE IF NOT EXISTS ${MIGRATION_LEDGER_TABLE} (
30
+ migration_id text PRIMARY KEY,
31
+ applied_at text NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
32
+ )`;
33
+
34
+ /**
35
+ * Opens the embedded database at `file`.
36
+ *
37
+ * `:memory:` is the test backend and is used as given. A filesystem path has
38
+ * its parent created first, because bun:sqlite does not create directories.
39
+ * Foreign keys are off unless asked for, so the pragma is set on every
40
+ * connection rather than trusted to a default. WAL keeps a reader from
41
+ * blocking the single writer.
42
+ */
43
+ /**
44
+ * Databases whose `prepare`/`exec` have already been wrapped.
45
+ *
46
+ * Keyed weakly so a closed in-memory database is not retained by the registry.
47
+ */
48
+ const SQLITE_PREPARED = new WeakSet<Database>();
49
+
50
+ /**
51
+ * The statement cache of each wrapped database.
52
+ *
53
+ * Kept beside the weak set above rather than inside a closure so `close` can
54
+ * drop the whole map: a live cache would otherwise keep the statements it
55
+ * holds reachable past a `close(true)`, which is exactly what that call is
56
+ * meant to release.
57
+ */
58
+ const SQLITE_STATEMENT_CACHES = new WeakMap<Database, Map<string, Statement>>();
59
+
60
+ /**
61
+ * Compiled statements kept per database, keyed by translated SQL text.
62
+ *
63
+ * Drizzle compiles a fresh statement for every query execution
64
+ * (`SQLiteBunSession.prepareQuery`), and `Database.prepare` — unlike
65
+ * `Database.query` — does not cache. A lite-mode gateway runs ~19 scheduled
66
+ * tasks while idle, each executing the same handful of queries, so an uncached
67
+ * path compiles a new statement (and five new closures) per tick and never
68
+ * finalizes one: RSS climbs for hours with no traffic at all.
69
+ *
70
+ * Bounded because the SQL text is produced by a fixed set of Drizzle builders —
71
+ * the key space is small and stable, not caller-supplied.
72
+ *
73
+ * Only SQL that `sqliteSql` left alone is cached. A rewrite inlines a value
74
+ * into the text, and the two functions it rewrites (`now()`,
75
+ * `gen_random_uuid()`) produce a different literal on every call — caching
76
+ * those would pin a query to the value the first execution happened to inline.
77
+ */
78
+ const SQLITE_STATEMENT_CACHE_MAX = 256;
79
+
80
+ export function createSqliteDatabase(file: string): Database {
81
+ if (file !== ":memory:") mkdirSync(dirname(file), { recursive: true });
82
+ const db = new Database(file);
83
+ db.exec("PRAGMA foreign_keys = ON");
84
+ if (file !== ":memory:") applyFilePragmas(db);
85
+ // Guarded: this function has to run per database, but a caller may hand it
86
+ // one that already went through here (a test builds a handle over a booted
87
+ // handle). Wrapping `prepare` twice ran the translation twice, and the
88
+ // second pass mangled SQL the first had already rewritten — `unrecognized
89
+ // token: ":"` from a placeholder the first pass produced.
90
+ if (SQLITE_PREPARED.has(db)) return db;
91
+ SQLITE_PREPARED.add(db);
92
+ const statementCache = new Map<string, Statement>();
93
+ // Per database, never shared: a statement is only usable on the connection
94
+ // that compiled it, and the whole map is dropped on close.
95
+ SQLITE_STATEMENT_CACHES.set(db, statementCache);
96
+ const prepare = db.prepare.bind(db);
97
+ db.prepare = ((text: string) => {
98
+ // `true` means the text was left untouched, so it is a key that will
99
+ // compile to the same statement every time it is asked for again.
100
+ const [translated, cacheable] = sqliteSql(text);
101
+ if (cacheable) {
102
+ const cached = statementCache.get(translated);
103
+ // Returned as-is: a cached statement already carries the wrapped methods
104
+ // below, and re-wrapping it would bind the wrapper's own wrapper.
105
+ if (cached !== undefined) return cached;
106
+ }
107
+ const statement = prepare(translated);
108
+ // bun:sqlite rejects a Date binding ("Binding expected string, TypedArray,
109
+ // boolean, number, bigint or null"), and Drizzle's pg-core timestamp
110
+ // columns bind `Date` objects. The schema is shared with PostgreSQL, so
111
+ // the conversion lives here: every timestamp column is `text`, and an ISO
112
+ // string is exactly what those columns store.
113
+ const bind = (value: unknown): SQLQueryBindings =>
114
+ value instanceof Date ? value.toISOString() : (value as SQLQueryBindings);
115
+ const run = statement.run.bind(statement);
116
+ const all = statement.all.bind(statement);
117
+ const get = statement.get.bind(statement);
118
+ // Drizzle's bun-sqlite session reads rows through `values`, not `all`.
119
+ const values = statement.values.bind(statement);
120
+ const bound = (args: unknown[]): SQLQueryBindings[] => args.map(bind);
121
+ statement.run = ((...args: unknown[]) => run(...bound(args))) as typeof statement.run;
122
+ statement.all = ((...args: unknown[]) => all(...bound(args))) as typeof statement.all;
123
+ statement.get = ((...args: unknown[]) => get(...bound(args))) as typeof statement.get;
124
+ statement.values = ((...args: unknown[]) => values(...bound(args))) as typeof statement.values;
125
+ if (cacheable) {
126
+ // Insertion-order eviction: `Map` iterates oldest-first, so the first key
127
+ // is the least recently inserted one. Simple, and enough because the key
128
+ // set is small and stable — a hit-count policy would only add state.
129
+ if (statementCache.size >= SQLITE_STATEMENT_CACHE_MAX) {
130
+ const oldest = statementCache.keys().next();
131
+ if (oldest.done !== true) statementCache.delete(oldest.value);
132
+ }
133
+ statementCache.set(translated, statement);
134
+ }
135
+ return statement;
136
+ }) as Database["prepare"];
137
+ const exec = db.exec.bind(db);
138
+ db.exec = ((text: string) => exec(sqliteSql(text)[0])) as Database["exec"];
139
+ return db;
140
+ }
141
+
142
+ /**
143
+ * Teaches the shared pg-core columns to read what bun:sqlite returns.
144
+ *
145
+ * The schema is written for node-postgres, whose driver hands booleans back
146
+ * as booleans and timestamps as `Date`. bun:sqlite hands back the stored
147
+ * value unchanged: an integer for a boolean, text for a timestamp. A
148
+ * column's `mapFromDriverValue` is what turns a driver value into the type
149
+ * the schema declares, and Drizzle calls it for every read — a select, a
150
+ * join, a `returning()`. The pg-core boolean column returns its value
151
+ * unchanged and the timestamp column only converts strings, so neither
152
+ * produces what the schema promises from bun:sqlite's values. Replacing the
153
+ * mapper on each column instance fixes every read at once. Writes are
154
+ * untouched: booleans already bind as integers, and `Date` bindings are
155
+ * converted to ISO strings in `createSqliteDatabase`.
156
+ */
157
+ function installSqliteColumnMapping(schema: Record<string, unknown>): void {
158
+
159
+ for (const value of Object.values(schema)) {
160
+ const columns = tableColumns(value);
161
+ if (columns === undefined) continue;
162
+ const table = getTableName(Object.values(columns)[0]!.table as Parameters<typeof getTableName>[0]);
163
+ for (const column of Object.values(columns)) {
164
+ if (column.columnType === "PgBoolean") {
165
+ column.mapFromDriverValue = (read) => read === null ? null : read === 1 || read === true;
166
+ } else if (column.columnType === "PgTimestamp") {
167
+ const convert = column.mapFromDriverValue.bind(column);
168
+ column.mapFromDriverValue = (read) => {
169
+ // A column declared `text` keeps whatever it was bound as, so a
170
+ // timestamp can come back as the ISO string the writer produces, or
171
+ // as a number — or the digits of one — when a row was restored from
172
+ // a source that stored epoch milliseconds. `new Date` reads the
173
+ // digits as a year and returns an invalid date, so those are
174
+ // handled before the string conversion.
175
+ if (typeof read === "number") return new Date(read);
176
+ if (typeof read === "string" && /^\d+$/.test(read)) return new Date(Number(read));
177
+ return convert(read);
178
+ };
179
+ }
180
+ // Registered after the branches above replace it, so execute and the
181
+ // builder call the same function. A column neither branch touches keeps
182
+ // its own mapper, which is what already stringifies a numeric and parses
183
+ // jsonb. execute is the one read that never called it.
184
+ if (table !== undefined) {
185
+ columnMapping.set(mappingKey(table, column.name), column.mapFromDriverValue.bind(column));
186
+ }
187
+ }
188
+ }
189
+ }
190
+
191
+ interface MappedColumn {
192
+ readonly name: string;
193
+ readonly columnType: string;
194
+ readonly table: object;
195
+ mapFromDriverValue: (value: unknown) => unknown;
196
+ }
197
+
198
+ /**
199
+ * `table.column` to the mapper that turns bun:sqlite's stored value into the
200
+ * type the schema declares. `db.execute` reads through `prepare().all()`, which
201
+ * never calls a column mapper, so the alias applies these itself. A name that
202
+ * two tables share maps to the same type everywhere in this schema, and a
203
+ * column with no entry is returned as stored.
204
+ */
205
+ const columnMapping = new Map<string, (value: unknown) => unknown>();
206
+
207
+ function mappingKey(table: string, column: string): string {
208
+ return `${table}.${column}`;
209
+ }
210
+
211
+ /** One row from `prepare().all()`, with every known column mapped. */
212
+ function mapExecuteRow(row: Record<string, unknown>, table: string | undefined): Record<string, unknown> {
213
+ if (table === undefined) return row;
214
+ const mapped: Record<string, unknown> = {};
215
+ for (const [column, value] of Object.entries(row)) {
216
+ const convert = columnMapping.get(mappingKey(table, column));
217
+ mapped[column] = value === null || convert === undefined ? value : convert(value);
218
+ }
219
+ return mapped;
220
+ }
221
+
222
+ /**
223
+ * The table a raw statement reads, when it reads exactly one.
224
+ *
225
+ * `SELECT 1` and a join read columns from nowhere or from several tables, and
226
+ * neither can be mapped by name. Both are returned as stored, which is what
227
+ * they already were.
228
+ */
229
+ interface RenderedStatement {
230
+ readonly sql: string;
231
+ readonly params: SQLQueryBindings[];
232
+ }
233
+
234
+ /**
235
+ * A Drizzle `sql` value as bun:sqlite runs it: `?` placeholders and the values
236
+ * that fill them, in order.
237
+ *
238
+ * A chunk is text, a nested statement, or a bound value. Text is concatenated,
239
+ * a nested statement is rendered in place, and everything else is one `?`.
240
+ * `Date` values are converted here too, because this path prepares through the
241
+ * raw database rather than through the wrapper in `createSqliteDatabase`.
242
+ */
243
+ function renderExecute(query: unknown): RenderedStatement | undefined {
244
+ const chunks = (query as { queryChunks?: unknown[] }).queryChunks;
245
+ if (chunks === undefined) return undefined;
246
+ const params: SQLQueryBindings[] = [];
247
+ const sql = chunks.map((chunk) => renderChunk(chunk, params)).join("");
248
+ return { sql, params };
249
+ }
250
+
251
+ function renderChunk(chunk: unknown, params: SQLQueryBindings[]): string {
252
+ if (Array.isArray(chunk)) {
253
+ return `(${chunk.map((part) => renderChunk(part, params)).join(", ")})`;
254
+ }
255
+ if (typeof chunk === "object" && chunk !== null && "queryChunks" in chunk) {
256
+ return (chunk as { queryChunks: unknown[] }).queryChunks
257
+ .map((part) => renderChunk(part, params))
258
+ .join("");
259
+ }
260
+ if (typeof chunk === "object" && chunk !== null && Array.isArray((chunk as { value?: unknown }).value)) {
261
+ return ((chunk as { value: string[] }).value).join("");
262
+ }
263
+ // A table, column, or quoted name is SQL text, not a bound value. Rendering
264
+ // one as a placeholder produces `DELETE FROM ?`, which SQLite rejects with
265
+ // `near "?": syntax error` — the failure the payload sweep logs every pass.
266
+ const kind = drizzleKind(chunk);
267
+ if (kind === "Table") {
268
+ return `"${getTableName(chunk as Parameters<typeof getTableName>[0])}"`;
269
+ }
270
+ if (kind === "Name") return `"${(chunk as { value: string }).value}"`;
271
+ if (kind === "Column") return `"${(chunk as { name: string }).name}"`;
272
+ const value = chunk instanceof Date ? chunk.toISOString() : chunk;
273
+ params.push(value as SQLQueryBindings);
274
+ return "?";
275
+ }
276
+
277
+
278
+ function drizzleKind(chunk: unknown): string | undefined {
279
+ if (typeof chunk !== "object" || chunk === null) return undefined;
280
+ // A pg column's own kind is `PgUUID` or `PgTimestamp`, not `Column`. Walk
281
+ // the constructors: the base `Column` marks every one of them.
282
+ let ctor = chunk.constructor as { [entityKind]?: unknown } | null;
283
+ while (ctor !== null && ctor !== Function.prototype) {
284
+ const kind = ctor[entityKind];
285
+ if (kind === "Column" || kind === "Table" || kind === "Name") return kind;
286
+ ctor = Object.getPrototypeOf(ctor) as { [entityKind]?: unknown } | null;
287
+ }
288
+ return undefined;
289
+ }
290
+
291
+ const entityKind = Symbol.for("drizzle:entityKind");
292
+ function singleTable(sql: string): string | undefined {
293
+ const tables = [...sql.matchAll(/\b(?:from|join)\s+"?([a-z0-9_]+)"?/gi)].map((match) => match[1]);
294
+ const distinct = new Set(tables);
295
+ return distinct.size === 1 ? [...distinct][0] : undefined;
296
+ }
297
+
298
+ /** The columns of one Drizzle table, or undefined for everything else in the schema. */
299
+ function tableColumns(value: unknown): Record<string, MappedColumn> | undefined {
300
+ if (typeof value !== "object" || value === null) return undefined;
301
+ try {
302
+ const columns = getTableColumns(value as Parameters<typeof getTableColumns>[0]);
303
+ return Object.keys(columns).length > 0 ? columns as Record<string, MappedColumn> : undefined;
304
+ } catch {
305
+ return undefined;
306
+ }
307
+ }
308
+
309
+ /**
310
+ * The handle stores run on.
311
+ *
312
+ * `db` is the bun-sqlite Drizzle instance. It is not the node-postgres
313
+ * `CartethyiaDatabase` the stores are typed against — the two query builders
314
+ * are different types — so the conversion happens once, here. The stores
315
+ * exercise only the builder surface both drivers share.
316
+ */
317
+ /**
318
+ * The three statements the auth path runs on a cache miss, prepared once.
319
+ *
320
+ * Drizzle rebuilds the query on every call, and that rebuild measured 60x the
321
+ * cost of the lookup itself. These are static SQL against an index, so there
322
+ * is nothing for a builder to decide per call. The column list is exactly the
323
+ * `api_keys` row the store maps, in the order `findActiveByHash` reads it.
324
+ */
325
+ const AUTH_KEY_COLUMNS = [
326
+ "id", "tenant_id", "key_hash", "key_mode", "parent_key_id", "issued_client_ip",
327
+ "issued_client_ip_key", "label", "scopes", "requests_per_minute", "daily_token_limit",
328
+ "monthly_token_limit", "lifetime_token_budget", "lifetime_tokens_consumed",
329
+ "max_concurrent_requests", "model_access_mode", "model_list", "client_router_denylist",
330
+ "model_prefix", "created_at", "enabled", "revoked_at", "key_prefix", "key_encrypted",
331
+ "notes_title", "notes_subtitle", "notes_body", "is_default", "sort_index",
332
+ ].join(", ");
333
+
334
+ export interface SqliteAuthStatements {
335
+ /** One active key by its hash. The auth path's first read. */
336
+ readonly byHash: { get: (hash: string) => Record<string, unknown> | null };
337
+ /** One active key by its id. Run only for a key that has a parent. */
338
+ readonly byId: { get: (id: string) => Record<string, unknown> | null };
339
+ /** Lifetime consumption across one key's children, revoked included. */
340
+ readonly childrenConsumed: { get: (parentId: string) => { total: number } | null };
341
+ }
342
+
343
+ export function prepareAuthStatements(sqlite: Database): SqliteAuthStatements {
344
+ const active = `enabled = 1 AND revoked_at IS NULL`;
345
+ // `query`, not `prepare`: bun's `prepare` compiles a statement per call and
346
+ // never finalizes one, while `query` caches the compiled statement behind an
347
+ // LRU of its own. All three are constant text run once per authenticated
348
+ // request, so they are the hottest statements in the process.
349
+ return {
350
+ byHash: sqlite.query(
351
+ `SELECT ${AUTH_KEY_COLUMNS} FROM api_keys WHERE key_hash = ? AND ${active} LIMIT 1`,
352
+ ) as SqliteAuthStatements["byHash"],
353
+ byId: sqlite.query(
354
+ `SELECT ${AUTH_KEY_COLUMNS} FROM api_keys WHERE id = ? AND ${active} LIMIT 1`,
355
+ ) as SqliteAuthStatements["byId"],
356
+ childrenConsumed: sqlite.query(
357
+ `SELECT coalesce(sum(lifetime_tokens_consumed), 0) AS total FROM api_keys WHERE parent_key_id = ?`,
358
+ ) as SqliteAuthStatements["childrenConsumed"],
359
+ };
360
+ }
361
+
362
+
363
+ // A reloaded module must repatch the columns even when the handle is the
364
+ // one booted before the reload. See `buildSqliteHandle`.
365
+ installSqliteColumnMapping(fullSchema);
366
+
367
+ export function buildSqliteHandle(sqlite: Database): DatabaseHandle {
368
+ // Reinstalled on every call, not guarded. `bun --hot` keeps the booted
369
+ // handle in `globalThis` and never rebuilds it, so a mapping installed
370
+ // once at boot dies with the module it patched: booleans came back as
371
+ // integers and login rejected a valid user as `unknown_user`. The
372
+ // reloaded module calls this again and repatches the live columns.
373
+ installSqliteColumnMapping(fullSchema);
374
+ const built = drizzle(sqlite, { schema: fullSchema });
375
+ // node-postgres exposes `execute` for raw SQL. bun-sqlite exposes `run` and
376
+ // `all` instead, and the stores were written against `execute`. The alias
377
+ // goes through `exec` rather than `run`: `run` prepares a statement per
378
+ // call, and a raw statement here carries its values inlined, so there is
379
+ // nothing to bind. Affected rows are read back from `changes()`.
380
+ const execute = (query: unknown): { rowCount: number; rows: unknown[] } => {
381
+ // Rendered the way the bun-sqlite session renders it: `?` placeholders and
382
+ // a params list. Joining the chunks kept only their text, so an
383
+ // interpolated value was dropped and the statement reached SQLite with a
384
+ // hole in it.
385
+ const rendered = renderExecute(query);
386
+ if (rendered === undefined) throw new Error("sqlite execute: query has no SQL text");
387
+ // `all` for a statement that returns rows, `exec` otherwise. Readiness
388
+ // reads the migration ledger through `execute` and decides "applied" from
389
+ // `result.rows`; an alias that only reported `changes()` made every select
390
+ // look empty, so a migrated database stayed "not ready" forever.
391
+ const returnsRows = /^\s*(select|with|pragma)\b/i.test(rendered.sql);
392
+ if (returnsRows) {
393
+ // `all()` returns stored values, not mapped ones, so a timestamp comes
394
+ // back as text and a boolean as 0 or 1. The builder path maps through
395
+ // the column; this is the only read that does not, and the backup
396
+ // import compares these values against the builder's. Map them here.
397
+ const rows = sqlite.prepare(rendered.sql).all(...rendered.params) as Record<string, unknown>[];
398
+ const table = singleTable(rendered.sql);
399
+ return { rowCount: rows.length, rows: rows.map((row) => mapExecuteRow(row, table)) };
400
+ }
401
+ sqlite.prepare(rendered.sql).run(...rendered.params);
402
+ const changed = sqlite.prepare("SELECT changes() AS c").get() as { c: number } | null;
403
+ return { rowCount: changed?.c ?? 0, rows: [] };
404
+ };
405
+ // A transaction callback receives a fresh builder, not this handle, so the
406
+ // `execute` alias above would not reach it. First-boot setup takes its lock
407
+ // through `transaction.execute`, and without the alias that call throws and
408
+ // setup can never complete on the SQLite backend.
409
+ const transaction = built.transaction.bind(built);
410
+ const db = Object.assign(built, {
411
+ execute,
412
+ transaction: (fn: (tx: unknown) => unknown) =>
413
+ transaction((tx: object) => fn(Object.assign(tx, { execute }))),
414
+ });
415
+ return {
416
+ kind: "sqlite",
417
+ db: db as unknown as DatabaseHandle["db"],
418
+ sqlite,
419
+ auth: prepareAuthStatements(sqlite),
420
+ query: (text, params) => {
421
+ // bun:sqlite types `all` as a tuple spread, which a `readonly unknown[]`
422
+ // cannot satisfy. The values are the bound parameters the caller built;
423
+ // the cast is confined to this one call.
424
+ const bound = (params ?? []) as Parameters<ReturnType<Database["prepare"]>["all"]>;
425
+ const rows = sqlite.prepare(text).all(...bound) as unknown[];
426
+ return Promise.resolve({ rows });
427
+ },
428
+ exec: (script) => {
429
+ sqlite.exec(script);
430
+ return Promise.resolve();
431
+ },
432
+ close: () => {
433
+ // The cache has to go first: `close(true)` releases the connection
434
+ // immediately, and a map still holding the statements would keep them
435
+ // reachable after the handle is gone.
436
+ SQLITE_STATEMENT_CACHES.get(sqlite)?.clear();
437
+ SQLITE_STATEMENT_CACHES.delete(sqlite);
438
+ // `true`, not the default: without it bun uses `sqlite3_close_v2`, which
439
+ // defers the release until every outstanding statement is finalized or
440
+ // collected. Nothing here finalizes explicitly, so the default left the
441
+ // connection — and its pages — held indefinitely, which is the idle
442
+ // growth this backend was seeing. `sqlite3_close` finalizes them now.
443
+ sqlite.close(true);
444
+ return Promise.resolve();
445
+ },
446
+ };
447
+ }
448
+
449
+ /**
450
+ * Applies the SQLite migration set in order.
451
+ *
452
+ * Same ledger, same skip-when-recorded rule, and the same one-file-per-
453
+ * transaction atomicity as the PostgreSQL runner. Two deliberate absences,
454
+ * both consequences of being the only process: no advisory lock, because a
455
+ * second process cannot open the file for writing, and no
456
+ * `to_regclass` probe, because SQLite has no schemas — `sqlite_master`
457
+ * answers the same question.
458
+ */
459
+ /**
460
+ * Rewrites the two PostgreSQL functions the schema's defaults emit.
461
+ *
462
+ * `gen_random_uuid()` becomes a quoted uuid. `now()` becomes a quoted UTC
463
+ * timestamp. Both are matched only as function calls, so a column or string
464
+ * that merely contains the words is left alone.
465
+ */
466
+ /**
467
+ * Rewrites the two PostgreSQL functions the schema's defaults emit, and only
468
+ * those. A statement that contains neither is returned as-is, so the common
469
+ * case — every read, and every write that supplies its own values — pays one
470
+ * `includes` and nothing else.
471
+ *
472
+ * The second element tells the caller whether the text is a stable cache key.
473
+ * Both rewrites substitute a value that differs per call, so a rewritten
474
+ * statement must be compiled fresh every time; see `SQLITE_STATEMENT_CACHE_MAX`.
475
+ */
476
+ function sqliteSql(text: string): [sql: string, cacheable: boolean] {
477
+ if (!text.includes("gen_random_uuid()") && !text.includes("now()")) return [text, true];
478
+ return [text
479
+ .replaceAll("gen_random_uuid()", () => `'${randomUUID()}'`)
480
+ .replaceAll("now()", () => `'${new Date().toISOString()}'`), false];
481
+ }
482
+
483
+ /**
484
+ * Pragmas for a file-backed database. Each one removes a stall or a fsync the
485
+ * default pays per commit, and none of them weakens crash safety below what
486
+ * the usage flush already accepts (at most the last second of deltas).
487
+ *
488
+ * `synchronous = NORMAL` is durable against a process crash under WAL; only a
489
+ * power loss can drop the last commit. `busy_timeout` makes a writer wait
490
+ * instead of failing instantly when a sweep holds the lock. The cache is 8 MB:
491
+ * large enough for auth and the active catalog working set, without reserving
492
+ * 64 MB that an idle gateway never touches. `temp_store` keeps sorts off disk.
493
+ * The checkpoint interval keeps a request from stalling on one.
494
+ */
495
+ function applyFilePragmas(db: Database): void {
496
+ db.exec("PRAGMA journal_mode = WAL");
497
+ db.exec("PRAGMA synchronous = NORMAL");
498
+ db.exec("PRAGMA busy_timeout = 5000");
499
+ db.exec("PRAGMA cache_size = -8192");
500
+ db.exec("PRAGMA temp_store = MEMORY");
501
+ db.exec("PRAGMA wal_autocheckpoint = 1000");
502
+ }
503
+
504
+ /** The SQLite set lives beside the PostgreSQL one, never inside it. */
505
+ export function resolveSqliteMigrationsFolder(): string {
506
+ return resolvePackagePath("migrations", "lite-sqlite3");
507
+ }
508
+
509
+ export async function applySqliteMigrations(
510
+ sqlite: Database,
511
+ folder = resolveSqliteMigrationsFolder(),
512
+ ): Promise<void> {
513
+ sqlite.exec(SQLITE_LEDGER_DDL);
514
+ const appliedRows = sqlite
515
+ .prepare(`SELECT migration_id FROM ${MIGRATION_LEDGER_TABLE}`)
516
+ .all() as { migration_id: string }[];
517
+ const applied = new Set(appliedRows.map((row) => row.migration_id));
518
+
519
+ const tenants = sqlite
520
+ .prepare("SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'tenants'")
521
+ .get() as { present: number } | null;
522
+ const files = migrationFiles(folder);
523
+ const baselineFile = files.find((file) => migrationIdFor(file) === BASELINE_MIGRATION_ID);
524
+ const baselineSource =
525
+ baselineFile === undefined ? undefined : readFileSync(baselineFile, "utf8");
526
+ const baselineMarker =
527
+ baselineSource === undefined ? undefined : baselineSchemaMarker(baselineSource);
528
+ assertMigrationHistorySupported(
529
+ applied,
530
+ [
531
+ ...files.map(migrationIdFor),
532
+ ...(baselineMarker === undefined ? [] : [baselineMarker]),
533
+ ...(baselineSource === undefined ? [] : acceptedBaselineSchemaMarkers(baselineSource)),
534
+ ],
535
+ tenants !== null,
536
+ baselineMarker,
537
+ );
538
+
539
+ for (const file of files) {
540
+ const migrationId = migrationIdFor(file);
541
+ if (applied.has(migrationId)) continue;
542
+ const source = readFileSync(file, "utf8");
543
+ const record = sqlite.prepare(
544
+ `INSERT INTO ${MIGRATION_LEDGER_TABLE} (migration_id) VALUES (?)`,
545
+ );
546
+ try {
547
+ sqlite.transaction(() => {
548
+ for (const statement of splitStatements(source)) {
549
+ // The generated baseline opens with a comment block, and a
550
+ // breakpoint at the end of a file leaves an empty part. `exec`
551
+ // rejects both, so only text that still contains SQL is sent.
552
+ const sql = statement.replace(/--[^\n]*/g, "").trim();
553
+ if (sql.length === 0) continue;
554
+ sqlite.exec(sql);
555
+ }
556
+ record.run(migrationId);
557
+ if (migrationId === BASELINE_MIGRATION_ID && baselineMarker !== undefined) {
558
+ record.run(baselineMarker);
559
+ }
560
+ })();
561
+ applied.add(migrationId);
562
+ if (migrationId === BASELINE_MIGRATION_ID && baselineMarker !== undefined) {
563
+ applied.add(baselineMarker);
564
+ }
565
+ } catch (error) {
566
+ throw new Error(`Migration ${migrationId} failed: ${String(error)}`, { cause: error });
567
+ }
568
+ }
569
+ }