@raybase/core 0.0.2 → 0.3.4

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 (435) hide show
  1. package/LICENSE +21 -0
  2. package/dist/.admin-build/AdminApp.css +2 -0
  3. package/dist/.admin-build/AdminApp.js +77 -0
  4. package/dist/.admin-docs-build/AdminDocsApp.css +2 -0
  5. package/dist/.admin-docs-build/AdminDocsApp.js +45 -0
  6. package/dist/actions.d.ts +347 -0
  7. package/dist/actions.d.ts.map +1 -0
  8. package/dist/actions.js +661 -0
  9. package/dist/actions.js.map +1 -0
  10. package/dist/admin-assets/admin.7738f50720a8ed7b.css +2 -0
  11. package/dist/admin-assets/admin.8678cb61db34dc81.js +77 -0
  12. package/dist/admin-boot.d.ts +94 -0
  13. package/dist/admin-boot.d.ts.map +1 -0
  14. package/dist/admin-boot.js +139 -0
  15. package/dist/admin-boot.js.map +1 -0
  16. package/dist/admin-docs-content.d.ts +95 -0
  17. package/dist/admin-docs-content.d.ts.map +1 -0
  18. package/dist/admin-docs-content.js +463 -0
  19. package/dist/admin-docs-content.js.map +1 -0
  20. package/dist/admin-docs-ui.d.ts +11 -0
  21. package/dist/admin-docs-ui.d.ts.map +1 -0
  22. package/dist/admin-docs-ui.js +23 -0
  23. package/dist/admin-docs-ui.js.map +1 -0
  24. package/dist/admin-kit.d.ts +29 -0
  25. package/dist/admin-kit.js +1557 -0
  26. package/dist/admin-role.d.ts +8 -0
  27. package/dist/admin-role.d.ts.map +1 -0
  28. package/dist/admin-role.js +22 -0
  29. package/dist/admin-role.js.map +1 -0
  30. package/dist/admin-ui.d.ts +3 -0
  31. package/dist/admin-ui.d.ts.map +1 -0
  32. package/dist/admin-ui.js +16 -0
  33. package/dist/admin-ui.js.map +1 -0
  34. package/dist/agent-ai-chat.d.ts +127 -0
  35. package/dist/agent-ai-chat.d.ts.map +1 -0
  36. package/dist/agent-ai-chat.js +5975 -0
  37. package/dist/agent-ai-chat.js.map +1 -0
  38. package/dist/agent-chapter-archive.d.ts +214 -0
  39. package/dist/agent-chapter-archive.d.ts.map +1 -0
  40. package/dist/agent-chapter-archive.js +696 -0
  41. package/dist/agent-chapter-archive.js.map +1 -0
  42. package/dist/agent-chapter-search.d.ts +116 -0
  43. package/dist/agent-chapter-search.d.ts.map +1 -0
  44. package/dist/agent-chapter-search.js +605 -0
  45. package/dist/agent-chapter-search.js.map +1 -0
  46. package/dist/agent-chapter-tools.d.ts +45 -0
  47. package/dist/agent-chapter-tools.d.ts.map +1 -0
  48. package/dist/agent-chapter-tools.js +344 -0
  49. package/dist/agent-chapter-tools.js.map +1 -0
  50. package/dist/agent-chapters.d.ts +228 -0
  51. package/dist/agent-chapters.d.ts.map +1 -0
  52. package/dist/agent-chapters.js +737 -0
  53. package/dist/agent-chapters.js.map +1 -0
  54. package/dist/agent-data-parts.d.ts +167 -0
  55. package/dist/agent-data-parts.d.ts.map +1 -0
  56. package/dist/agent-data-parts.js +68 -0
  57. package/dist/agent-data-parts.js.map +1 -0
  58. package/dist/agent-errors.d.ts +41 -0
  59. package/dist/agent-errors.d.ts.map +1 -0
  60. package/dist/agent-errors.js +191 -0
  61. package/dist/agent-errors.js.map +1 -0
  62. package/dist/agent-follow-up-store.d.ts +273 -0
  63. package/dist/agent-follow-up-store.d.ts.map +1 -0
  64. package/dist/agent-follow-up-store.js +703 -0
  65. package/dist/agent-follow-up-store.js.map +1 -0
  66. package/dist/agent-follow-ups.d.ts +314 -0
  67. package/dist/agent-follow-ups.d.ts.map +1 -0
  68. package/dist/agent-follow-ups.js +840 -0
  69. package/dist/agent-follow-ups.js.map +1 -0
  70. package/dist/agent-memory.d.ts +323 -0
  71. package/dist/agent-memory.d.ts.map +1 -0
  72. package/dist/agent-memory.js +1270 -0
  73. package/dist/agent-memory.js.map +1 -0
  74. package/dist/agent-message-parts.d.ts +58 -0
  75. package/dist/agent-message-parts.d.ts.map +1 -0
  76. package/dist/agent-message-parts.js +163 -0
  77. package/dist/agent-message-parts.js.map +1 -0
  78. package/dist/agent-remote-mcp-credentials.d.ts +154 -0
  79. package/dist/agent-remote-mcp-credentials.d.ts.map +1 -0
  80. package/dist/agent-remote-mcp-credentials.js +287 -0
  81. package/dist/agent-remote-mcp-credentials.js.map +1 -0
  82. package/dist/agent-remote-mcp-crypto.d.ts +32 -0
  83. package/dist/agent-remote-mcp-crypto.d.ts.map +1 -0
  84. package/dist/agent-remote-mcp-crypto.js +101 -0
  85. package/dist/agent-remote-mcp-crypto.js.map +1 -0
  86. package/dist/agent-remote-mcp-install.d.ts +299 -0
  87. package/dist/agent-remote-mcp-install.d.ts.map +1 -0
  88. package/dist/agent-remote-mcp-install.js +785 -0
  89. package/dist/agent-remote-mcp-install.js.map +1 -0
  90. package/dist/agent-remote-mcp-oauth.d.ts +73 -0
  91. package/dist/agent-remote-mcp-oauth.d.ts.map +1 -0
  92. package/dist/agent-remote-mcp-oauth.js +344 -0
  93. package/dist/agent-remote-mcp-oauth.js.map +1 -0
  94. package/dist/agent-remote-mcp-transport.d.ts +46 -0
  95. package/dist/agent-remote-mcp-transport.d.ts.map +1 -0
  96. package/dist/agent-remote-mcp-transport.js +225 -0
  97. package/dist/agent-remote-mcp-transport.js.map +1 -0
  98. package/dist/agent-remote-mcp.d.ts +147 -0
  99. package/dist/agent-remote-mcp.d.ts.map +1 -0
  100. package/dist/agent-remote-mcp.js +681 -0
  101. package/dist/agent-remote-mcp.js.map +1 -0
  102. package/dist/agent-room-redaction.d.ts +66 -0
  103. package/dist/agent-room-redaction.d.ts.map +1 -0
  104. package/dist/agent-room-redaction.js +264 -0
  105. package/dist/agent-room-redaction.js.map +1 -0
  106. package/dist/agent-rooms.d.ts +209 -0
  107. package/dist/agent-rooms.d.ts.map +1 -0
  108. package/dist/agent-rooms.js +244 -0
  109. package/dist/agent-rooms.js.map +1 -0
  110. package/dist/agent-runtime.d.ts +541 -0
  111. package/dist/agent-runtime.d.ts.map +1 -0
  112. package/dist/agent-runtime.js +4260 -0
  113. package/dist/agent-runtime.js.map +1 -0
  114. package/dist/agent-skill-archive.d.ts +20 -0
  115. package/dist/agent-skill-archive.d.ts.map +1 -0
  116. package/dist/agent-skill-archive.js +316 -0
  117. package/dist/agent-skill-archive.js.map +1 -0
  118. package/dist/agent-skill-discovery.d.ts +96 -0
  119. package/dist/agent-skill-discovery.d.ts.map +1 -0
  120. package/dist/agent-skill-discovery.js +464 -0
  121. package/dist/agent-skill-discovery.js.map +1 -0
  122. package/dist/agent-skill-format.d.ts +57 -0
  123. package/dist/agent-skill-format.d.ts.map +1 -0
  124. package/dist/agent-skill-format.js +238 -0
  125. package/dist/agent-skill-format.js.map +1 -0
  126. package/dist/agent-skill-import.d.ts +64 -0
  127. package/dist/agent-skill-import.d.ts.map +1 -0
  128. package/dist/agent-skill-import.js +295 -0
  129. package/dist/agent-skill-import.js.map +1 -0
  130. package/dist/agent-skill-publish.d.ts +51 -0
  131. package/dist/agent-skill-publish.d.ts.map +1 -0
  132. package/dist/agent-skill-publish.js +136 -0
  133. package/dist/agent-skill-publish.js.map +1 -0
  134. package/dist/agent-skill-store.d.ts +252 -0
  135. package/dist/agent-skill-store.d.ts.map +1 -0
  136. package/dist/agent-skill-store.js +514 -0
  137. package/dist/agent-skill-store.js.map +1 -0
  138. package/dist/agent-skills.d.ts +85 -0
  139. package/dist/agent-skills.d.ts.map +1 -0
  140. package/dist/agent-skills.js +291 -0
  141. package/dist/agent-skills.js.map +1 -0
  142. package/dist/agent-transcript-pager.d.ts +77 -0
  143. package/dist/agent-transcript-pager.d.ts.map +1 -0
  144. package/dist/agent-transcript-pager.js +210 -0
  145. package/dist/agent-transcript-pager.js.map +1 -0
  146. package/dist/agent-user-data-parts.d.ts +20 -0
  147. package/dist/agent-user-data-parts.d.ts.map +1 -0
  148. package/dist/agent-user-data-parts.js +161 -0
  149. package/dist/agent-user-data-parts.js.map +1 -0
  150. package/dist/agent-user-transcript.d.ts +46 -0
  151. package/dist/agent-user-transcript.d.ts.map +1 -0
  152. package/dist/agent-user-transcript.js +106 -0
  153. package/dist/agent-user-transcript.js.map +1 -0
  154. package/dist/agent-voice-notes.d.ts +15 -0
  155. package/dist/agent-voice-notes.d.ts.map +1 -0
  156. package/dist/agent-voice-notes.js +17 -0
  157. package/dist/agent-voice-notes.js.map +1 -0
  158. package/dist/agent-wakes.d.ts +213 -0
  159. package/dist/agent-wakes.d.ts.map +1 -0
  160. package/dist/agent-wakes.js +534 -0
  161. package/dist/agent-wakes.js.map +1 -0
  162. package/dist/agent.d.ts +1780 -0
  163. package/dist/agent.d.ts.map +1 -0
  164. package/dist/agent.js +3123 -0
  165. package/dist/agent.js.map +1 -0
  166. package/dist/ai.d.ts +583 -0
  167. package/dist/ai.d.ts.map +1 -0
  168. package/dist/ai.js +1858 -0
  169. package/dist/ai.js.map +1 -0
  170. package/dist/app-manifest.d.ts +255 -0
  171. package/dist/app-manifest.d.ts.map +1 -0
  172. package/dist/app-manifest.js +372 -0
  173. package/dist/app-manifest.js.map +1 -0
  174. package/dist/app-users.d.ts +30 -0
  175. package/dist/app-users.d.ts.map +1 -0
  176. package/dist/app-users.js +91 -0
  177. package/dist/app-users.js.map +1 -0
  178. package/dist/audit-archive.d.ts +85 -0
  179. package/dist/audit-archive.d.ts.map +1 -0
  180. package/dist/audit-archive.js +178 -0
  181. package/dist/audit-archive.js.map +1 -0
  182. package/dist/audit.d.ts +54 -0
  183. package/dist/audit.d.ts.map +1 -0
  184. package/dist/audit.js +95 -0
  185. package/dist/audit.js.map +1 -0
  186. package/dist/auth-logger.d.ts +28 -0
  187. package/dist/auth-logger.d.ts.map +1 -0
  188. package/dist/auth-logger.js +53 -0
  189. package/dist/auth-logger.js.map +1 -0
  190. package/dist/auth-password-secret.d.ts +59 -0
  191. package/dist/auth-password-secret.d.ts.map +1 -0
  192. package/dist/auth-password-secret.js +409 -0
  193. package/dist/auth-password-secret.js.map +1 -0
  194. package/dist/auth-schema.d.ts +120 -0
  195. package/dist/auth-schema.d.ts.map +1 -0
  196. package/dist/auth-schema.js +285 -0
  197. package/dist/auth-schema.js.map +1 -0
  198. package/dist/auth.d.ts +321 -0
  199. package/dist/auth.d.ts.map +1 -0
  200. package/dist/auth.js +1562 -0
  201. package/dist/auth.js.map +1 -0
  202. package/dist/backup-archive.d.ts +257 -0
  203. package/dist/backup-archive.d.ts.map +1 -0
  204. package/dist/backup-archive.js +2068 -0
  205. package/dist/backup-archive.js.map +1 -0
  206. package/dist/backup-crypto.d.ts +313 -0
  207. package/dist/backup-crypto.d.ts.map +1 -0
  208. package/dist/backup-crypto.js +693 -0
  209. package/dist/backup-crypto.js.map +1 -0
  210. package/dist/backup-format.d.ts +200 -0
  211. package/dist/backup-format.d.ts.map +1 -0
  212. package/dist/backup-format.js +1247 -0
  213. package/dist/backup-format.js.map +1 -0
  214. package/dist/cli.d.ts +9 -0
  215. package/dist/cli.d.ts.map +1 -0
  216. package/dist/cli.js +15 -0
  217. package/dist/cli.js.map +1 -0
  218. package/dist/clock.d.ts +35 -0
  219. package/dist/clock.d.ts.map +1 -0
  220. package/dist/clock.js +83 -0
  221. package/dist/clock.js.map +1 -0
  222. package/dist/data-client.d.ts +187 -0
  223. package/dist/data-client.d.ts.map +1 -0
  224. package/dist/data-client.js +590 -0
  225. package/dist/data-client.js.map +1 -0
  226. package/dist/data-secrets.d.ts +164 -0
  227. package/dist/data-secrets.d.ts.map +1 -0
  228. package/dist/data-secrets.js +383 -0
  229. package/dist/data-secrets.js.map +1 -0
  230. package/dist/db.d.ts +453 -0
  231. package/dist/db.d.ts.map +1 -0
  232. package/dist/db.js +324 -0
  233. package/dist/db.js.map +1 -0
  234. package/dist/dev-activity.d.ts +48 -0
  235. package/dist/dev-activity.d.ts.map +1 -0
  236. package/dist/dev-activity.js +50 -0
  237. package/dist/dev-activity.js.map +1 -0
  238. package/dist/do.d.ts +429 -0
  239. package/dist/do.d.ts.map +1 -0
  240. package/dist/do.js +4526 -0
  241. package/dist/do.js.map +1 -0
  242. package/dist/email.d.ts +137 -0
  243. package/dist/email.d.ts.map +1 -0
  244. package/dist/email.js +527 -0
  245. package/dist/email.js.map +1 -0
  246. package/dist/generated/admin-assets.d.ts +16 -0
  247. package/dist/generated/admin-assets.d.ts.map +1 -0
  248. package/dist/generated/admin-assets.js +16 -0
  249. package/dist/generated/admin-assets.js.map +1 -0
  250. package/dist/generated/admin-docs-assets.d.ts +3 -0
  251. package/dist/generated/admin-docs-assets.d.ts.map +1 -0
  252. package/dist/generated/admin-docs-assets.js +3 -0
  253. package/dist/generated/admin-docs-assets.js.map +1 -0
  254. package/dist/handoff-consumer.d.ts +79 -0
  255. package/dist/handoff-consumer.d.ts.map +1 -0
  256. package/dist/handoff-consumer.js +300 -0
  257. package/dist/handoff-consumer.js.map +1 -0
  258. package/dist/handoff.d.ts +70 -0
  259. package/dist/handoff.d.ts.map +1 -0
  260. package/dist/handoff.js +228 -0
  261. package/dist/handoff.js.map +1 -0
  262. package/dist/host-auth.d.ts +47 -0
  263. package/dist/host-auth.d.ts.map +1 -0
  264. package/dist/host-auth.js +59 -0
  265. package/dist/host-auth.js.map +1 -0
  266. package/dist/host-user.d.ts +15 -0
  267. package/dist/host-user.d.ts.map +1 -0
  268. package/dist/host-user.js +23 -0
  269. package/dist/host-user.js.map +1 -0
  270. package/dist/html.d.ts +35 -0
  271. package/dist/html.d.ts.map +1 -0
  272. package/dist/html.js +634 -0
  273. package/dist/html.js.map +1 -0
  274. package/dist/http-error.d.ts +13 -0
  275. package/dist/http-error.d.ts.map +1 -0
  276. package/dist/http-error.js +15 -0
  277. package/dist/http-error.js.map +1 -0
  278. package/dist/imports.d.ts +219 -0
  279. package/dist/imports.d.ts.map +1 -0
  280. package/dist/imports.js +571 -0
  281. package/dist/imports.js.map +1 -0
  282. package/dist/index.d.ts +78 -0
  283. package/dist/index.d.ts.map +1 -0
  284. package/dist/index.js +76 -0
  285. package/dist/index.js.map +1 -0
  286. package/dist/inference-tape.d.ts +64 -0
  287. package/dist/inference-tape.d.ts.map +1 -0
  288. package/dist/inference-tape.js +278 -0
  289. package/dist/inference-tape.js.map +1 -0
  290. package/dist/internal.d.ts +15 -0
  291. package/dist/internal.d.ts.map +1 -0
  292. package/dist/internal.js +48 -0
  293. package/dist/internal.js.map +1 -0
  294. package/dist/jobs.d.ts +796 -0
  295. package/dist/jobs.d.ts.map +1 -0
  296. package/dist/jobs.js +538 -0
  297. package/dist/jobs.js.map +1 -0
  298. package/dist/json-schema.d.ts +91 -0
  299. package/dist/json-schema.d.ts.map +1 -0
  300. package/dist/json-schema.js +884 -0
  301. package/dist/json-schema.js.map +1 -0
  302. package/dist/knowledge.d.ts +33 -0
  303. package/dist/knowledge.d.ts.map +1 -0
  304. package/dist/knowledge.js +348 -0
  305. package/dist/knowledge.js.map +1 -0
  306. package/dist/local-dev-chrome.d.ts +29 -0
  307. package/dist/local-dev-chrome.d.ts.map +1 -0
  308. package/dist/local-dev-chrome.js +514 -0
  309. package/dist/local-dev-chrome.js.map +1 -0
  310. package/dist/local-dev-personas.d.ts +15 -0
  311. package/dist/local-dev-personas.d.ts.map +1 -0
  312. package/dist/local-dev-personas.js +46 -0
  313. package/dist/local-dev-personas.js.map +1 -0
  314. package/dist/mcp.d.ts +567 -0
  315. package/dist/mcp.d.ts.map +1 -0
  316. package/dist/mcp.js +2480 -0
  317. package/dist/mcp.js.map +1 -0
  318. package/dist/modules.d.ts +145 -0
  319. package/dist/modules.d.ts.map +1 -0
  320. package/dist/modules.js +164 -0
  321. package/dist/modules.js.map +1 -0
  322. package/dist/notification-client.d.ts +94 -0
  323. package/dist/notification-client.d.ts.map +1 -0
  324. package/dist/notification-client.js +127 -0
  325. package/dist/notification-client.js.map +1 -0
  326. package/dist/notifications.d.ts +447 -0
  327. package/dist/notifications.d.ts.map +1 -0
  328. package/dist/notifications.js +1625 -0
  329. package/dist/notifications.js.map +1 -0
  330. package/dist/operator-data-query.d.ts +117 -0
  331. package/dist/operator-data-query.d.ts.map +1 -0
  332. package/dist/operator-data-query.js +431 -0
  333. package/dist/operator-data-query.js.map +1 -0
  334. package/dist/operator-mcp.d.ts +63 -0
  335. package/dist/operator-mcp.d.ts.map +1 -0
  336. package/dist/operator-mcp.js +2828 -0
  337. package/dist/operator-mcp.js.map +1 -0
  338. package/dist/outbound-https.d.ts +24 -0
  339. package/dist/outbound-https.d.ts.map +1 -0
  340. package/dist/outbound-https.js +172 -0
  341. package/dist/outbound-https.js.map +1 -0
  342. package/dist/pitr.d.ts +60 -0
  343. package/dist/pitr.d.ts.map +1 -0
  344. package/dist/pitr.js +94 -0
  345. package/dist/pitr.js.map +1 -0
  346. package/dist/platform.d.ts +199 -0
  347. package/dist/platform.d.ts.map +1 -0
  348. package/dist/platform.js +196 -0
  349. package/dist/platform.js.map +1 -0
  350. package/dist/policies.d.ts +115 -0
  351. package/dist/policies.d.ts.map +1 -0
  352. package/dist/policies.js +158 -0
  353. package/dist/policies.js.map +1 -0
  354. package/dist/principal.d.ts +143 -0
  355. package/dist/principal.d.ts.map +1 -0
  356. package/dist/principal.js +174 -0
  357. package/dist/principal.js.map +1 -0
  358. package/dist/realtime.d.ts +280 -0
  359. package/dist/realtime.d.ts.map +1 -0
  360. package/dist/realtime.js +578 -0
  361. package/dist/realtime.js.map +1 -0
  362. package/dist/retrieval.d.ts +263 -0
  363. package/dist/retrieval.d.ts.map +1 -0
  364. package/dist/retrieval.js +602 -0
  365. package/dist/retrieval.js.map +1 -0
  366. package/dist/runtime-error-client.d.ts +10 -0
  367. package/dist/runtime-error-client.d.ts.map +1 -0
  368. package/dist/runtime-error-client.js +70 -0
  369. package/dist/runtime-error-client.js.map +1 -0
  370. package/dist/runtime-errors.d.ts +180 -0
  371. package/dist/runtime-errors.d.ts.map +1 -0
  372. package/dist/runtime-errors.js +572 -0
  373. package/dist/runtime-errors.js.map +1 -0
  374. package/dist/schema.d.ts +434 -0
  375. package/dist/schema.d.ts.map +1 -0
  376. package/dist/schema.js +232 -0
  377. package/dist/schema.js.map +1 -0
  378. package/dist/search.d.ts +216 -0
  379. package/dist/search.d.ts.map +1 -0
  380. package/dist/search.js +785 -0
  381. package/dist/search.js.map +1 -0
  382. package/dist/settings.d.ts +191 -0
  383. package/dist/settings.d.ts.map +1 -0
  384. package/dist/settings.js +478 -0
  385. package/dist/settings.js.map +1 -0
  386. package/dist/sql.d.ts +7 -0
  387. package/dist/sql.d.ts.map +1 -0
  388. package/dist/sql.js +30 -0
  389. package/dist/sql.js.map +1 -0
  390. package/dist/storage-client.d.ts +69 -0
  391. package/dist/storage-client.d.ts.map +1 -0
  392. package/dist/storage-client.js +469 -0
  393. package/dist/storage-client.js.map +1 -0
  394. package/dist/storage.d.ts +21 -0
  395. package/dist/storage.d.ts.map +1 -0
  396. package/dist/storage.js +82 -0
  397. package/dist/storage.js.map +1 -0
  398. package/dist/subject-domains.d.ts +15 -0
  399. package/dist/subject-domains.d.ts.map +1 -0
  400. package/dist/subject-domains.js +38 -0
  401. package/dist/subject-domains.js.map +1 -0
  402. package/dist/support.d.ts +132 -0
  403. package/dist/support.d.ts.map +1 -0
  404. package/dist/support.js +483 -0
  405. package/dist/support.js.map +1 -0
  406. package/dist/system-tables.d.ts +12 -0
  407. package/dist/system-tables.d.ts.map +1 -0
  408. package/dist/system-tables.js +225 -0
  409. package/dist/system-tables.js.map +1 -0
  410. package/dist/test.d.ts +57 -0
  411. package/dist/test.d.ts.map +1 -0
  412. package/dist/test.js +454 -0
  413. package/dist/test.js.map +1 -0
  414. package/dist/vite.d.ts +82 -0
  415. package/dist/vite.d.ts.map +1 -0
  416. package/dist/vite.js +82 -0
  417. package/dist/vite.js.map +1 -0
  418. package/dist/webpush-crypto.d.ts +39 -0
  419. package/dist/webpush-crypto.d.ts.map +1 -0
  420. package/dist/webpush-crypto.js +213 -0
  421. package/dist/webpush-crypto.js.map +1 -0
  422. package/dist/webpush-private.d.ts +11 -0
  423. package/dist/webpush-private.d.ts.map +1 -0
  424. package/dist/webpush-private.js +11 -0
  425. package/dist/webpush-private.js.map +1 -0
  426. package/dist/webpush.d.ts +86 -0
  427. package/dist/webpush.d.ts.map +1 -0
  428. package/dist/webpush.js +375 -0
  429. package/dist/webpush.js.map +1 -0
  430. package/dist/worker.d.ts +185 -0
  431. package/dist/worker.d.ts.map +1 -0
  432. package/dist/worker.js +13021 -0
  433. package/dist/worker.js.map +1 -0
  434. package/package.json +161 -8
  435. package/README.md +0 -3
package/dist/do.js ADDED
@@ -0,0 +1,4526 @@
1
+ import { DurableObject } from "cloudflare:workers";
2
+ import { withHostUserRoleCeiling } from "./host-user.js";
3
+ import { appNowIso, devTimeTravelStateKey, hydrateAppClock, toRealMs, wallNowIso, wallNowMs } from "./clock.js";
4
+ import { agentFollowUpAlarmWork, dispatchDueAgentFollowUps, listUnhandledLapsedAgentFollowUps } from "./agent-follow-up-store.js";
5
+ import { syncDueAgentSkillSources } from "./agent-skill-import.js";
6
+ import { claimDueAgentSchedules } from "./agent.js";
7
+ import { claimDueAgentWakes } from "./agent-wakes.js";
8
+ import { batchChildIdempotencyKey, batchContinuationIdempotencyKey, nextCronRun } from "./jobs.js";
9
+ import { normalizeStoredAdminRole } from "./admin-role.js";
10
+ import { auditArchiveObjectKey, isoToSqliteTimestamp, serializeAuditArchiveRows, sha256Hex, validateAuditArchiveWindow } from "./audit-archive.js";
11
+ import { raybaseApplicationSystemTableSql, raybaseSystemTableSql } from "./auth-schema.js";
12
+ import { RaybaseDataReadDeniedError } from "./data-client.js";
13
+ import { backupDatabaseProtocolLimits, raybaseBackupRestoreJournalTableName, raybaseBackupRestoreSchemasTableName, raybaseBackupRestoreSessionsTableName, raybaseBackupStateTableName } from "./db.js";
14
+ import { RaybaseBackupError } from "./backup-crypto.js";
15
+ import { backupFormatLimits, encodeBackupLogicalRecord } from "./backup-format.js";
16
+ import { raybaseImportSystemTableSql } from "./imports.js";
17
+ import { classifyReadPolicy } from "./policies.js";
18
+ import { mergeRaybaseRuntimeClaims } from "./platform.js";
19
+ import { assertRealtimeListOptions, projectChangeForSubscriber } from "./realtime.js";
20
+ import { isRaybaseSearchInternalTable, raybaseSearchSchemaSql } from "./search.js";
21
+ import { createTableSql } from "./schema.js";
22
+ import { randomId } from "./sql.js";
23
+ const realtimeSchemaKey = "raybase:realtime:schema";
24
+ const agentAlarmClaimsKey = "raybase:agent-alarm:claims";
25
+ const realtimeConfigKey = "raybase:realtime:config";
26
+ const realtimeAuthKey = "raybase:realtime:auth";
27
+ const realtimeSweepMs = 60_000;
28
+ const followUpLapseRetryMs = 60_000;
29
+ const followUpOutstandingRetryMs = 60_000;
30
+ const followUpSafetySweepMs = 5 * 60_000;
31
+ const backupStateTable = raybaseBackupStateTableName;
32
+ const backupRestoreJournalTable = raybaseBackupRestoreJournalTableName;
33
+ const backupRestoreSchemasTable = raybaseBackupRestoreSchemasTableName;
34
+ const backupRestoreSessionsTable = raybaseBackupRestoreSessionsTableName;
35
+ const backupOperationalTables = new Set([
36
+ backupStateTable,
37
+ backupRestoreJournalTable,
38
+ backupRestoreSchemasTable,
39
+ backupRestoreSessionsTable
40
+ ]);
41
+ const sha256HexPattern = /^[0-9a-f]{64}$/;
42
+ const backupOperationIdPattern = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,127}$/;
43
+ function splitSqlStatements(sql) {
44
+ // Drizzle migration files separate statements with an explicit marker, which
45
+ // is the only safe way to split SQL that may contain ";" inside literals or
46
+ // trigger bodies. Legacy full-schema bundles have no marker and contain only
47
+ // generated CREATE TABLE statements, so the ";" split stays correct there.
48
+ const statements = sql.includes("--> statement-breakpoint") ? sql.split(/-->\s*statement-breakpoint/) : sql.split(";");
49
+ return statements
50
+ .map((statement) => statement.replace(/;\s*$/, "").trim())
51
+ .filter(Boolean);
52
+ }
53
+ function sqlIdentifier(value) {
54
+ return `"${value.replace(/"/g, '""')}"`;
55
+ }
56
+ function backupError(code, message) {
57
+ throw new RaybaseBackupError(code, message);
58
+ }
59
+ function validateBackupOperationId(value, label = "Backup operation id") {
60
+ if (!backupOperationIdPattern.test(value)) {
61
+ backupError("BACKUP_LIMIT_EXCEEDED", `${label} is invalid`);
62
+ }
63
+ return value;
64
+ }
65
+ function validateBackupDigest(value, label) {
66
+ if (!sha256HexPattern.test(value)) {
67
+ backupError("BACKUP_ARCHIVE_CORRUPT", `${label} must be a lowercase SHA-256 digest`);
68
+ }
69
+ return value;
70
+ }
71
+ function backupNonNegativeInteger(value, label) {
72
+ if (!Number.isSafeInteger(value) || value < 0) {
73
+ backupError("BACKUP_LIMIT_EXCEEDED", `${label} must be a non-negative safe integer`);
74
+ }
75
+ return value;
76
+ }
77
+ function backupLogicalValue(value) {
78
+ if (value === null)
79
+ return { type: "null" };
80
+ if (typeof value === "string") {
81
+ if (new TextEncoder().encode(value).byteLength > backupFormatLimits.textValueBytes) {
82
+ backupError("BACKUP_LIMIT_EXCEEDED", "SQLite text value exceeds the logical backup limit");
83
+ }
84
+ return { type: "text", value };
85
+ }
86
+ if (typeof value === "number") {
87
+ if (!Number.isFinite(value)) {
88
+ backupError("BACKUP_LIMIT_EXCEEDED", "SQLite numeric value is not finite");
89
+ }
90
+ if (Number.isInteger(value)) {
91
+ if (!Number.isSafeInteger(value)) {
92
+ backupError("BACKUP_LIMIT_EXCEEDED", "SQLite integer cannot be represented exactly by the Workers SQL API");
93
+ }
94
+ return { type: "integer", value: BigInt(value) };
95
+ }
96
+ return { type: "real", value };
97
+ }
98
+ const bytes = new Uint8Array(value.slice(0));
99
+ if (bytes.byteLength > backupFormatLimits.blobValueBytes) {
100
+ backupError("BACKUP_LIMIT_EXCEEDED", "SQLite BLOB exceeds the logical backup limit");
101
+ }
102
+ return { type: "blob", value: bytes };
103
+ }
104
+ function logicalValueBinding(value) {
105
+ switch (value.type) {
106
+ case "null":
107
+ return null;
108
+ case "text":
109
+ case "real":
110
+ return value.value;
111
+ case "integer": {
112
+ const number = Number(value.value);
113
+ if (!Number.isSafeInteger(number) || BigInt(number) !== value.value) {
114
+ backupError("BACKUP_LIMIT_EXCEEDED", "Restore integer cannot be represented exactly by the Workers SQL API");
115
+ }
116
+ return number;
117
+ }
118
+ case "blob":
119
+ return value.value.slice().buffer;
120
+ }
121
+ }
122
+ function concatBackupBytes(chunks) {
123
+ const length = chunks.reduce((total, chunk) => total + chunk.byteLength, 0);
124
+ const output = new Uint8Array(length);
125
+ let offset = 0;
126
+ for (const chunk of chunks) {
127
+ output.set(chunk, offset);
128
+ offset += chunk.byteLength;
129
+ }
130
+ return output;
131
+ }
132
+ function restoreStatusFromRow(row) {
133
+ if (!row || row.phase === "completed") {
134
+ return {
135
+ active: false,
136
+ operationId: row?.operationId ?? null,
137
+ phase: row?.phase ?? null,
138
+ undoBookmark: row?.undoBookmark ?? null,
139
+ sourceSha256: row?.sourceSha256 ?? null,
140
+ nextBatchIndex: Number(row?.nextBatchIndex ?? 0),
141
+ recordCount: Number(row?.recordCount ?? 0),
142
+ rowCount: Number(row?.rowCount ?? 0),
143
+ tableCount: Number(row?.tableCount ?? 0),
144
+ currentTableId: row?.currentTableId ?? null,
145
+ error: row?.error ?? null
146
+ };
147
+ }
148
+ return {
149
+ active: true,
150
+ operationId: row.operationId,
151
+ phase: row.phase,
152
+ undoBookmark: row.undoBookmark,
153
+ sourceSha256: row.sourceSha256,
154
+ nextBatchIndex: Number(row.nextBatchIndex),
155
+ recordCount: Number(row.recordCount),
156
+ rowCount: Number(row.rowCount),
157
+ tableCount: Number(row.tableCount),
158
+ currentTableId: row.currentTableId,
159
+ error: row.error
160
+ };
161
+ }
162
+ /**
163
+ * Tokenize only the SQLite syntax needed to identify a statement boundary.
164
+ * Quoted literals/identifiers and comments are consumed as lexical units, so
165
+ * their semicolons never become framing. Trigger-body semicolons are handled
166
+ * separately by isSingleSqliteCreateStatement.
167
+ */
168
+ function sqliteStatementFramingTokens(sql) {
169
+ const tokens = [];
170
+ let index = 0;
171
+ while (index < sql.length) {
172
+ const char = sql[index];
173
+ const next = sql[index + 1];
174
+ if (/\s/.test(char)) {
175
+ index += 1;
176
+ continue;
177
+ }
178
+ if (char === "-" && next === "-") {
179
+ index += 2;
180
+ while (index < sql.length && sql[index] !== "\n" && sql[index] !== "\r")
181
+ index += 1;
182
+ continue;
183
+ }
184
+ if (char === "/" && next === "*") {
185
+ const end = sql.indexOf("*/", index + 2);
186
+ if (end < 0)
187
+ return null;
188
+ index = end + 2;
189
+ continue;
190
+ }
191
+ if (char === "'" || char === '"' || char === "`") {
192
+ const quote = char;
193
+ index += 1;
194
+ let closed = false;
195
+ while (index < sql.length) {
196
+ if (sql[index] !== quote) {
197
+ index += 1;
198
+ continue;
199
+ }
200
+ if (sql[index + 1] === quote) {
201
+ index += 2;
202
+ continue;
203
+ }
204
+ index += 1;
205
+ closed = true;
206
+ break;
207
+ }
208
+ if (!closed)
209
+ return null;
210
+ tokens.push({ kind: "content" });
211
+ continue;
212
+ }
213
+ if (char === "[") {
214
+ const end = sql.indexOf("]", index + 1);
215
+ if (end < 0)
216
+ return null;
217
+ index = end + 1;
218
+ tokens.push({ kind: "content" });
219
+ continue;
220
+ }
221
+ if (char === ";") {
222
+ tokens.push({ kind: "semicolon" });
223
+ index += 1;
224
+ continue;
225
+ }
226
+ if (/[A-Za-z_]/.test(char)) {
227
+ const start = index;
228
+ index += 1;
229
+ while (index < sql.length && /[A-Za-z0-9_$]/.test(sql[index]))
230
+ index += 1;
231
+ tokens.push({ kind: "word", value: sql.slice(start, index).toUpperCase() });
232
+ continue;
233
+ }
234
+ tokens.push({ kind: "content" });
235
+ index += 1;
236
+ }
237
+ return tokens;
238
+ }
239
+ function isSingleSqliteCreateStatement(sql) {
240
+ const tokens = sqliteStatementFramingTokens(sql);
241
+ if (!tokens)
242
+ return false;
243
+ const trigger = /^CREATE\s+(?:UNIQUE\s+)?TRIGGER\b/i.test(sql);
244
+ if (!trigger) {
245
+ const terminator = tokens.findIndex((token) => token.kind === "semicolon");
246
+ return terminator < 0 || terminator === tokens.length - 1;
247
+ }
248
+ let bodyStarted = false;
249
+ let caseDepth = 0;
250
+ let bodyEnded = false;
251
+ let terminatorSeen = false;
252
+ for (const token of tokens) {
253
+ if (!bodyStarted) {
254
+ if (token.kind === "semicolon")
255
+ return false;
256
+ if (token.kind === "word" && token.value === "BEGIN")
257
+ bodyStarted = true;
258
+ continue;
259
+ }
260
+ if (!bodyEnded) {
261
+ if (token.kind === "word" && token.value === "CASE") {
262
+ caseDepth += 1;
263
+ }
264
+ else if (token.kind === "word" && token.value === "END") {
265
+ if (caseDepth > 0)
266
+ caseDepth -= 1;
267
+ else
268
+ bodyEnded = true;
269
+ }
270
+ continue;
271
+ }
272
+ if (token.kind !== "semicolon" || terminatorSeen)
273
+ return false;
274
+ terminatorSeen = true;
275
+ }
276
+ return bodyStarted && bodyEnded;
277
+ }
278
+ function validateRestoreDdl(sql) {
279
+ const trimmed = sql.trim();
280
+ if (!/^CREATE\s+(?:UNIQUE\s+)?(?:TABLE|INDEX|TRIGGER|VIEW)\b/i.test(trimmed)) {
281
+ backupError("BACKUP_ARCHIVE_CORRUPT", "Logical restore DDL must be a CREATE statement");
282
+ }
283
+ const lowered = trimmed.toLowerCase();
284
+ for (const tableName of backupOperationalTables) {
285
+ if (lowered.includes(tableName.toLowerCase())) {
286
+ backupError("BACKUP_ARCHIVE_CORRUPT", "Logical restore DDL targets Core backup-maintenance state");
287
+ }
288
+ }
289
+ if (trimmed.includes("--> statement-breakpoint") || trimmed.includes("\0")) {
290
+ backupError("BACKUP_ARCHIVE_CORRUPT", "Logical restore DDL contains unsupported statement framing");
291
+ }
292
+ if (!isSingleSqliteCreateStatement(trimmed)) {
293
+ backupError("BACKUP_ARCHIVE_CORRUPT", "Logical restore DDL contains more than one statement");
294
+ }
295
+ }
296
+ function systemIndexColumns(index) {
297
+ return Array.isArray(index) ? index : index.columns;
298
+ }
299
+ function systemIndexName(table, index) {
300
+ if (!Array.isArray(index) && index.name)
301
+ return index.name;
302
+ return `${table.name}_${systemIndexColumns(index).join("_")}_idx`;
303
+ }
304
+ function systemTableStatements(table) {
305
+ return [
306
+ createTableSql(table).replace(/;\s*$/, ""),
307
+ ...table.indexes.map((index) => {
308
+ const unique = !Array.isArray(index) && index.unique ? "UNIQUE " : "";
309
+ const where = !Array.isArray(index) && index.where ? ` WHERE ${index.where}` : "";
310
+ return `CREATE ${unique}INDEX IF NOT EXISTS ${sqlIdentifier(systemIndexName(table, index))} ON ${sqlIdentifier(table.name)} (${systemIndexColumns(index).map(sqlIdentifier).join(", ")})${where}`;
311
+ })
312
+ ];
313
+ }
314
+ function sqlLiteral(value) {
315
+ if (value === null || value === undefined)
316
+ return "NULL";
317
+ if (typeof value === "number")
318
+ return Number.isFinite(value) ? String(value) : "NULL";
319
+ if (typeof value === "boolean")
320
+ return value ? "1" : "0";
321
+ if (value instanceof ArrayBuffer) {
322
+ return `X'${Array.from(new Uint8Array(value))
323
+ .map((byte) => byte.toString(16).padStart(2, "0"))
324
+ .join("")}'`;
325
+ }
326
+ return `'${String(value).replace(/'/g, "''")}'`;
327
+ }
328
+ const statementSeparator = ";\n--> statement-breakpoint\n";
329
+ const jobSchemaSql = `
330
+ CREATE TABLE IF NOT EXISTS "_job_runs" (
331
+ "id" TEXT PRIMARY KEY NOT NULL,
332
+ "jobName" TEXT NOT NULL,
333
+ "status" TEXT NOT NULL,
334
+ "inputJson" TEXT NOT NULL,
335
+ "inputHash" TEXT,
336
+ "idempotencyKey" TEXT,
337
+ "actorKind" TEXT NOT NULL,
338
+ "actorId" TEXT,
339
+ "runAsKind" TEXT NOT NULL,
340
+ "runAsId" TEXT,
341
+ "attempt" INTEGER NOT NULL DEFAULT 0,
342
+ "maxAttempts" INTEGER NOT NULL DEFAULT 1,
343
+ "queueMessageId" TEXT,
344
+ "workflowInstanceId" TEXT,
345
+ "leaseOwner" TEXT,
346
+ "leaseExpiresAt" TEXT,
347
+ "createdAt" TEXT NOT NULL,
348
+ "availableAt" TEXT NOT NULL,
349
+ "startedAt" TEXT,
350
+ "endedAt" TEXT,
351
+ "expiresAt" TEXT,
352
+ "progressJson" TEXT,
353
+ "resultJson" TEXT,
354
+ "errorJson" TEXT,
355
+ "artifactJson" TEXT,
356
+ "createdBy" TEXT,
357
+ "retryOfRunId" TEXT,
358
+ "scheduleKey" TEXT,
359
+ "scheduleGeneration" INTEGER,
360
+ "scheduleBookedAt" TEXT,
361
+ "concurrencyKey" TEXT,
362
+ "rateLimitKey" TEXT,
363
+ "deferrals" INTEGER NOT NULL DEFAULT 0,
364
+ "deferredAt" TEXT,
365
+ "deferredReason" TEXT,
366
+ "wokenAt" TEXT
367
+ );
368
+
369
+ CREATE UNIQUE INDEX IF NOT EXISTS "_job_runs_idempotency_idx"
370
+ ON "_job_runs" ("jobName", "idempotencyKey")
371
+ WHERE "idempotencyKey" IS NOT NULL;
372
+ CREATE INDEX IF NOT EXISTS "_job_runs_status_available_idx" ON "_job_runs" ("status", "availableAt");
373
+ CREATE INDEX IF NOT EXISTS "_job_runs_status_ended_idx" ON "_job_runs" ("status", "endedAt");
374
+ CREATE INDEX IF NOT EXISTS "_job_runs_created_idx" ON "_job_runs" ("createdAt");
375
+ CREATE INDEX IF NOT EXISTS "_job_runs_actor_idx" ON "_job_runs" ("actorKind", "actorId", "createdAt");
376
+
377
+ CREATE TABLE IF NOT EXISTS "_job_events" (
378
+ "id" TEXT PRIMARY KEY NOT NULL,
379
+ "runId" TEXT NOT NULL,
380
+ "type" TEXT NOT NULL,
381
+ "level" TEXT NOT NULL,
382
+ "message" TEXT,
383
+ "dataJson" TEXT,
384
+ "createdAt" TEXT NOT NULL
385
+ );
386
+
387
+ CREATE INDEX IF NOT EXISTS "_job_events_run_created_idx" ON "_job_events" ("runId", "createdAt");
388
+ CREATE INDEX IF NOT EXISTS "_job_events_created_idx" ON "_job_events" ("createdAt");
389
+
390
+ CREATE TABLE IF NOT EXISTS "_job_schedules" (
391
+ "id" TEXT PRIMARY KEY NOT NULL,
392
+ "jobName" TEXT NOT NULL,
393
+ "key" TEXT NOT NULL,
394
+ "kind" TEXT NOT NULL,
395
+ "status" TEXT NOT NULL,
396
+ "inputJson" TEXT NOT NULL,
397
+ "cron" TEXT,
398
+ "timezone" TEXT,
399
+ "enabled" INTEGER NOT NULL DEFAULT 1,
400
+ "nextRunAt" TEXT,
401
+ "bookedAt" TEXT,
402
+ "generation" INTEGER NOT NULL DEFAULT 1,
403
+ "dispatchAttempts" INTEGER NOT NULL DEFAULT 0,
404
+ "dispatchLeaseExpiresAt" TEXT,
405
+ "lastDispatchAt" TEXT,
406
+ "lastDispatchError" TEXT,
407
+ "suspendedAt" TEXT,
408
+ "cancelledAt" TEXT,
409
+ "cancellationReason" TEXT,
410
+ "lastRunAt" TEXT,
411
+ "createdAt" TEXT NOT NULL,
412
+ "updatedAt" TEXT NOT NULL,
413
+ "createdBy" TEXT,
414
+ "service" TEXT,
415
+ "actorKind" TEXT,
416
+ "actorId" TEXT
417
+ );
418
+
419
+ CREATE TABLE IF NOT EXISTS "_job_claim_limits" (
420
+ "jobName" TEXT PRIMARY KEY NOT NULL,
421
+ "max" INTEGER,
422
+ "perKeyMax" INTEGER,
423
+ "updatedAt" TEXT NOT NULL
424
+ );
425
+
426
+ CREATE TABLE IF NOT EXISTS "_job_rate_buckets" (
427
+ "key" TEXT PRIMARY KEY NOT NULL,
428
+ "tokens" REAL NOT NULL,
429
+ "refilledAt" TEXT NOT NULL,
430
+ "fullAt" TEXT NOT NULL
431
+ );
432
+
433
+ CREATE TABLE IF NOT EXISTS "_job_stats_daily" (
434
+ "day" TEXT NOT NULL,
435
+ "jobName" TEXT NOT NULL,
436
+ "status" TEXT NOT NULL,
437
+ "count" INTEGER NOT NULL,
438
+ "totalDurationMs" INTEGER,
439
+ PRIMARY KEY ("day", "jobName", "status")
440
+ );
441
+
442
+ CREATE TABLE IF NOT EXISTS "_job_batches" (
443
+ "id" TEXT PRIMARY KEY NOT NULL,
444
+ "name" TEXT NOT NULL,
445
+ "key" TEXT NOT NULL,
446
+ "status" TEXT NOT NULL,
447
+ "onCompleteJson" TEXT NOT NULL,
448
+ "onFailureJson" TEXT,
449
+ "total" INTEGER NOT NULL DEFAULT 0,
450
+ "pending" INTEGER NOT NULL DEFAULT 0,
451
+ "succeeded" INTEGER NOT NULL DEFAULT 0,
452
+ "failed" INTEGER NOT NULL DEFAULT 0,
453
+ "cancelled" INTEGER NOT NULL DEFAULT 0,
454
+ "denied" INTEGER NOT NULL DEFAULT 0,
455
+ "expired" INTEGER NOT NULL DEFAULT 0,
456
+ "continuationRunId" TEXT,
457
+ "continuationJob" TEXT,
458
+ "continuationDispatchedAt" TEXT,
459
+ "lastDispatchError" TEXT,
460
+ "cancelledAt" TEXT,
461
+ "cancellationReason" TEXT,
462
+ "createdAt" TEXT NOT NULL,
463
+ "updatedAt" TEXT NOT NULL,
464
+ "sealedAt" TEXT,
465
+ "completedAt" TEXT,
466
+ "finishedAt" TEXT,
467
+ "expiresAt" TEXT,
468
+ "createdBy" TEXT,
469
+ "actorKind" TEXT NOT NULL,
470
+ "actorId" TEXT
471
+ );
472
+
473
+ CREATE UNIQUE INDEX IF NOT EXISTS "_job_batches_key_idx" ON "_job_batches" ("key");
474
+ CREATE INDEX IF NOT EXISTS "_job_batches_status_created_idx" ON "_job_batches" ("status", "createdAt");
475
+ CREATE INDEX IF NOT EXISTS "_job_batches_dispatch_idx" ON "_job_batches" ("status", "continuationDispatchedAt");
476
+ CREATE INDEX IF NOT EXISTS "_job_batches_expires_idx" ON "_job_batches" ("expiresAt");
477
+ `;
478
+ const terminalJobStatuses = new Set(["succeeded", "failed", "denied", "cancelled", "expired"]);
479
+ const maxJobScheduleDispatchAttempts = 5;
480
+ const defaultCompactionBatchSize = 100;
481
+ const defaultFailedRetentionMs = 60 * 24 * 60 * 60 * 1000;
482
+ const defaultCompactionDelayMs = 10 * 60 * 1000;
483
+ const defaultAuditArchiveLimitBytes = 8 * 1024 * 1024;
484
+ const aiBudgetSchemaSql = `
485
+ CREATE TABLE IF NOT EXISTS "_ai_budget_windows" (
486
+ "id" TEXT PRIMARY KEY NOT NULL,
487
+ "scope" TEXT NOT NULL,
488
+ "scopeId" TEXT NOT NULL,
489
+ "windowStart" TEXT NOT NULL,
490
+ "windowEnd" TEXT NOT NULL,
491
+ "requestCount" INTEGER NOT NULL,
492
+ "estimatedOutputTokens" INTEGER NOT NULL,
493
+ "estimatedNeurons" INTEGER,
494
+ "updatedAt" TEXT NOT NULL
495
+ );
496
+ CREATE INDEX IF NOT EXISTS "_ai_budget_windows_scope_scopeId_windowStart_idx"
497
+ ON "_ai_budget_windows" ("scope", "scopeId", "windowStart");
498
+ CREATE INDEX IF NOT EXISTS "_ai_budget_windows_windowEnd_idx" ON "_ai_budget_windows" ("windowEnd");
499
+ `;
500
+ function archiveMetadataFromManifest(row) {
501
+ return {
502
+ windowStart: row.windowStart,
503
+ windowEnd: row.windowEnd,
504
+ objectKey: row.objectKey,
505
+ rowCount: Number(row.rowCount),
506
+ firstCreatedAt: row.firstCreatedAt,
507
+ lastCreatedAt: row.lastCreatedAt,
508
+ sha256: row.sha256
509
+ };
510
+ }
511
+ function assertArchiveMetadataMatches(actual, expected) {
512
+ const fields = [
513
+ "windowStart",
514
+ "windowEnd",
515
+ "objectKey",
516
+ "rowCount",
517
+ "firstCreatedAt",
518
+ "lastCreatedAt",
519
+ "sha256"
520
+ ];
521
+ for (const field of fields) {
522
+ if (actual[field] !== expected[field]) {
523
+ throw new Error(`Audit archive manifest mismatch for ${field}`);
524
+ }
525
+ }
526
+ }
527
+ function assertConfirmInput(input) {
528
+ validateAuditArchiveWindow(input);
529
+ if (input.objectKey !== auditArchiveObjectKey(input.windowStart)) {
530
+ throw new Error("Audit archive objectKey does not match the window");
531
+ }
532
+ if (!Number.isInteger(input.rowCount) || input.rowCount <= 0) {
533
+ throw new Error("Audit archive rowCount must be a positive integer");
534
+ }
535
+ if (!input.firstCreatedAt || !input.lastCreatedAt) {
536
+ throw new Error("Audit archive firstCreatedAt and lastCreatedAt are required for non-empty archives");
537
+ }
538
+ if (!/^[0-9a-f]{64}$/.test(input.sha256)) {
539
+ throw new Error("Audit archive sha256 must be a hex SHA-256 digest");
540
+ }
541
+ return {
542
+ windowStart: input.windowStart,
543
+ windowEnd: input.windowEnd,
544
+ objectKey: input.objectKey,
545
+ rowCount: input.rowCount,
546
+ firstCreatedAt: input.firstCreatedAt,
547
+ lastCreatedAt: input.lastCreatedAt,
548
+ sha256: input.sha256
549
+ };
550
+ }
551
+ function dependencyOrder(tables) {
552
+ const pending = new Map(tables.map((table) => [table.name, table]));
553
+ const ordered = [];
554
+ while (pending.size) {
555
+ const ready = [...pending.values()].filter((table) => table.references.every((name) => !pending.has(name)));
556
+ const batch = ready.length ? ready : [pending.values().next().value];
557
+ for (const table of batch.sort((left, right) => left.name.localeCompare(right.name))) {
558
+ ordered.push(table);
559
+ pending.delete(table.name);
560
+ }
561
+ }
562
+ return ordered;
563
+ }
564
+ function rowToJobRun(row) {
565
+ if (!row)
566
+ return null;
567
+ return {
568
+ id: String(row.id),
569
+ jobName: String(row.jobName),
570
+ status: String(row.status),
571
+ inputJson: String(row.inputJson),
572
+ inputHash: row.inputHash === null ? null : String(row.inputHash),
573
+ idempotencyKey: row.idempotencyKey === null ? null : String(row.idempotencyKey),
574
+ actorKind: String(row.actorKind),
575
+ actorId: row.actorId === null ? null : String(row.actorId),
576
+ runAsKind: String(row.runAsKind),
577
+ runAsId: row.runAsId === null ? null : String(row.runAsId),
578
+ attempt: Number(row.attempt ?? 0),
579
+ maxAttempts: Number(row.maxAttempts ?? 1),
580
+ queueMessageId: row.queueMessageId === null ? null : String(row.queueMessageId),
581
+ workflowInstanceId: row.workflowInstanceId === null ? null : String(row.workflowInstanceId),
582
+ leaseOwner: row.leaseOwner === null ? null : String(row.leaseOwner),
583
+ leaseExpiresAt: row.leaseExpiresAt === null ? null : String(row.leaseExpiresAt),
584
+ createdAt: String(row.createdAt),
585
+ availableAt: String(row.availableAt),
586
+ startedAt: row.startedAt === null ? null : String(row.startedAt),
587
+ endedAt: row.endedAt === null ? null : String(row.endedAt),
588
+ expiresAt: row.expiresAt === null ? null : String(row.expiresAt),
589
+ progressJson: row.progressJson === null ? null : String(row.progressJson),
590
+ resultJson: row.resultJson === null ? null : String(row.resultJson),
591
+ errorJson: row.errorJson === null ? null : String(row.errorJson),
592
+ artifactJson: row.artifactJson === null ? null : String(row.artifactJson),
593
+ createdBy: row.createdBy === null ? null : String(row.createdBy),
594
+ retryOfRunId: row.retryOfRunId === null ? null : String(row.retryOfRunId),
595
+ scheduleKey: row.scheduleKey == null ? null : String(row.scheduleKey),
596
+ scheduleGeneration: row.scheduleGeneration == null ? null : Number(row.scheduleGeneration),
597
+ scheduleBookedAt: row.scheduleBookedAt == null ? null : String(row.scheduleBookedAt),
598
+ batchId: row.batchId == null ? null : String(row.batchId),
599
+ batchSeq: row.batchSeq == null ? null : Number(row.batchSeq),
600
+ continuationOfBatchId: row.continuationOfBatchId == null ? null : String(row.continuationOfBatchId),
601
+ concurrencyKey: row.concurrencyKey == null ? null : String(row.concurrencyKey),
602
+ rateLimitKey: row.rateLimitKey == null ? null : String(row.rateLimitKey),
603
+ deferrals: Number(row.deferrals ?? 0),
604
+ deferredAt: row.deferredAt == null ? null : String(row.deferredAt),
605
+ deferredReason: row.deferredReason == null ? null : String(row.deferredReason),
606
+ wokenAt: row.wokenAt == null ? null : String(row.wokenAt)
607
+ };
608
+ }
609
+ function nullableString(value) {
610
+ return value == null ? null : String(value);
611
+ }
612
+ function batchContinuationFromJson(json) {
613
+ if (json == null)
614
+ return null;
615
+ const template = JSON.parse(String(json));
616
+ return { job: template.jobName, input: JSON.parse(template.inputJson) };
617
+ }
618
+ // Two batch creations with one key define the same batch when they name the
619
+ // same continuation jobs with the same input; who created it does not matter.
620
+ function sameContinuation(stored, next) {
621
+ if (stored == null || next == null)
622
+ return stored == null && next == null;
623
+ const left = JSON.parse(String(stored));
624
+ const right = JSON.parse(next);
625
+ return left.jobName === right.jobName && left.inputJson === right.inputJson;
626
+ }
627
+ function rowToJobBatch(row) {
628
+ return {
629
+ id: String(row.id),
630
+ name: String(row.name),
631
+ key: String(row.key),
632
+ status: String(row.status),
633
+ counts: {
634
+ total: Number(row.total ?? 0),
635
+ pending: Number(row.pending ?? 0),
636
+ succeeded: Number(row.succeeded ?? 0),
637
+ failed: Number(row.failed ?? 0),
638
+ cancelled: Number(row.cancelled ?? 0),
639
+ denied: Number(row.denied ?? 0),
640
+ expired: Number(row.expired ?? 0)
641
+ },
642
+ onComplete: batchContinuationFromJson(row.onCompleteJson),
643
+ onFailure: batchContinuationFromJson(row.onFailureJson),
644
+ continuationRunId: nullableString(row.continuationRunId),
645
+ continuationJob: nullableString(row.continuationJob),
646
+ continuationDispatchedAt: nullableString(row.continuationDispatchedAt),
647
+ lastDispatchError: nullableString(row.lastDispatchError),
648
+ cancelledAt: nullableString(row.cancelledAt),
649
+ cancellationReason: nullableString(row.cancellationReason),
650
+ createdAt: String(row.createdAt),
651
+ updatedAt: String(row.updatedAt),
652
+ sealedAt: nullableString(row.sealedAt),
653
+ completedAt: nullableString(row.completedAt),
654
+ finishedAt: nullableString(row.finishedAt),
655
+ expiresAt: nullableString(row.expiresAt),
656
+ createdBy: nullableString(row.createdBy),
657
+ actorKind: String(row.actorKind),
658
+ actorId: nullableString(row.actorId)
659
+ };
660
+ }
661
+ function parseStoredJson(value) {
662
+ if (value == null)
663
+ return null;
664
+ try {
665
+ return JSON.parse(String(value));
666
+ }
667
+ catch {
668
+ return String(value);
669
+ }
670
+ }
671
+ function rowToJobBatchChild(row, batchId) {
672
+ const scope = batchChildIdempotencyKey(batchId, "");
673
+ const key = row.idempotencyKey == null ? null : String(row.idempotencyKey);
674
+ return {
675
+ runId: String(row.id),
676
+ seq: Number(row.batchSeq),
677
+ jobName: String(row.jobName),
678
+ status: String(row.status),
679
+ idempotencyKey: key?.startsWith(scope) ? key.slice(scope.length) : key,
680
+ attempt: Number(row.attempt ?? 0),
681
+ input: parseStoredJson(row.inputJson),
682
+ result: parseStoredJson(row.resultJson),
683
+ error: parseStoredJson(row.errorJson),
684
+ createdAt: String(row.createdAt),
685
+ endedAt: nullableString(row.endedAt)
686
+ };
687
+ }
688
+ const batchCountColumns = {
689
+ succeeded: "succeeded",
690
+ failed: "failed",
691
+ cancelled: "cancelled",
692
+ denied: "denied",
693
+ expired: "expired"
694
+ };
695
+ /** How long a woken run holds the slot that woke it while its message is in flight. */
696
+ const jobWakeReservationMs = 30_000;
697
+ /** How often the alarm looks for parked runs that a lost or failed wake left waiting. */
698
+ const jobWakeSweepMs = 60_000;
699
+ const jobWakeRetryMs = 10_000;
700
+ const parkedDeferralReasons = `('concurrency', 'concurrency-key')`;
701
+ function jobSlotPools(runs) {
702
+ return {
703
+ jobNames: [...new Set(runs.map((run) => run.jobName))],
704
+ keys: [...new Set(runs.map((run) => run.concurrencyKey).filter((key) => key !== null))]
705
+ };
706
+ }
707
+ function jobDeferralMessage(jobName, deferral) {
708
+ if (deferral.reason === "concurrency") {
709
+ return `Waiting for a concurrency slot: ${deferral.inFlight} of ${deferral.limit} ${jobName} runs in flight`;
710
+ }
711
+ if (deferral.reason === "concurrency-key") {
712
+ return `Waiting for a concurrency slot: ${deferral.inFlight} of ${deferral.limit} runs in flight for key ${deferral.key}`;
713
+ }
714
+ const seconds = Math.max(1, Math.ceil((deferral.retryAfterMs ?? 0) / 1000));
715
+ return `Waiting for rate limit ${deferral.key ? `key ${deferral.key}` : jobName}: next start in ${seconds}s`;
716
+ }
717
+ function rowToJobSchedule(row) {
718
+ return {
719
+ id: String(row.id),
720
+ jobName: String(row.jobName),
721
+ key: String(row.key),
722
+ kind: String(row.kind),
723
+ status: String(row.status),
724
+ inputJson: String(row.inputJson),
725
+ cron: row.cron === null ? null : String(row.cron),
726
+ timezone: row.timezone === null ? null : String(row.timezone),
727
+ bookedAt: row.bookedAt === null ? null : String(row.bookedAt),
728
+ generation: Number(row.generation ?? 1),
729
+ dispatchAttempts: Number(row.dispatchAttempts ?? 0),
730
+ dispatchLeaseExpiresAt: row.dispatchLeaseExpiresAt === null ? null : String(row.dispatchLeaseExpiresAt),
731
+ lastDispatchAt: row.lastDispatchAt === null ? null : String(row.lastDispatchAt),
732
+ lastDispatchError: row.lastDispatchError === null ? null : String(row.lastDispatchError),
733
+ suspendedAt: row.suspendedAt === null ? null : String(row.suspendedAt),
734
+ cancelledAt: row.cancelledAt === null ? null : String(row.cancelledAt),
735
+ cancellationReason: row.cancellationReason === null ? null : String(row.cancellationReason),
736
+ lastRunAt: row.lastRunAt === null ? null : String(row.lastRunAt),
737
+ createdAt: String(row.createdAt),
738
+ updatedAt: String(row.updatedAt),
739
+ createdBy: row.createdBy === null ? null : String(row.createdBy),
740
+ service: row.service === null ? null : String(row.service),
741
+ actorKind: (row.actorKind == null
742
+ ? row.createdBy == null ? "service" : "user"
743
+ : String(row.actorKind)),
744
+ actorId: row.actorId == null
745
+ ? row.createdBy == null ? row.service == null ? null : String(row.service) : String(row.createdBy)
746
+ : String(row.actorId)
747
+ };
748
+ }
749
+ function sqliteChanges(storage) {
750
+ const row = storage.sql.exec(`SELECT changes() AS "changes"`).toArray()[0];
751
+ return Number(row?.changes ?? 0);
752
+ }
753
+ export class RaybaseDatabaseObject extends DurableObject {
754
+ initialized = false;
755
+ ensuredSchemaBundles = new Set();
756
+ realtimeSchema = null;
757
+ realtimeConfig = null;
758
+ realtimeAuth = {};
759
+ runtimeOptions() {
760
+ return {};
761
+ }
762
+ ensureBackupProtocolSchemaSync() {
763
+ this.ctx.storage.sql.exec(`CREATE TABLE IF NOT EXISTS ${sqlIdentifier(backupStateTable)} (
764
+ "id" INTEGER PRIMARY KEY NOT NULL CHECK ("id" = 1),
765
+ "epoch" TEXT NOT NULL,
766
+ "generation" INTEGER NOT NULL DEFAULT 0,
767
+ "gateKind" TEXT,
768
+ "gateOperationId" TEXT
769
+ )`);
770
+ this.ctx.storage.sql.exec(`INSERT OR IGNORE INTO ${sqlIdentifier(backupStateTable)}
771
+ ("id", "epoch", "generation", "gateKind", "gateOperationId")
772
+ VALUES (1, ?, 0, NULL, NULL)`, crypto.randomUUID());
773
+ this.ctx.storage.sql.exec(`CREATE TABLE IF NOT EXISTS ${sqlIdentifier(backupRestoreJournalTable)} (
774
+ "operationId" TEXT PRIMARY KEY NOT NULL,
775
+ "sourceSha256" TEXT NOT NULL,
776
+ "archiveId" TEXT NOT NULL,
777
+ "targetId" TEXT NOT NULL,
778
+ "undoBookmark" TEXT NOT NULL,
779
+ "phase" TEXT NOT NULL,
780
+ "expectedRecordCount" INTEGER NOT NULL,
781
+ "expectedRowCount" INTEGER NOT NULL,
782
+ "expectedTableCount" INTEGER NOT NULL,
783
+ "plaintextSha256" TEXT NOT NULL,
784
+ "nextBatchIndex" INTEGER NOT NULL DEFAULT 0,
785
+ "lastBatchSha256" TEXT,
786
+ "recordCount" INTEGER NOT NULL DEFAULT 0,
787
+ "rowCount" INTEGER NOT NULL DEFAULT 0,
788
+ "tableCount" INTEGER NOT NULL DEFAULT 0,
789
+ "currentTableId" TEXT,
790
+ "schemaReplaced" INTEGER NOT NULL DEFAULT 0,
791
+ "metadataSeen" INTEGER NOT NULL DEFAULT 0,
792
+ "logicalEndSeen" INTEGER NOT NULL DEFAULT 0,
793
+ "error" TEXT,
794
+ "createdAt" TEXT NOT NULL,
795
+ "updatedAt" TEXT NOT NULL
796
+ )`);
797
+ this.ctx.storage.sql.exec(`CREATE TABLE IF NOT EXISTS ${sqlIdentifier(backupRestoreSchemasTable)} (
798
+ "operationId" TEXT NOT NULL,
799
+ "tableId" TEXT NOT NULL,
800
+ "tableName" TEXT NOT NULL,
801
+ "columnsJson" TEXT NOT NULL,
802
+ "insertSql" TEXT NOT NULL,
803
+ PRIMARY KEY ("operationId", "tableId")
804
+ )`);
805
+ this.ctx.storage.sql.exec(`CREATE TABLE IF NOT EXISTS ${sqlIdentifier(backupRestoreSessionsTable)} (
806
+ "operationId" TEXT NOT NULL,
807
+ "sessionId" TEXT NOT NULL,
808
+ "sessionToken" TEXT NOT NULL,
809
+ "expiresAt" INTEGER NOT NULL,
810
+ "userId" TEXT NOT NULL,
811
+ "email" TEXT NOT NULL,
812
+ "name" TEXT NOT NULL,
813
+ "emailVerified" INTEGER NOT NULL,
814
+ "operatorRole" TEXT NOT NULL,
815
+ "appRoles" TEXT,
816
+ "banned" INTEGER,
817
+ PRIMARY KEY ("operationId", "sessionToken")
818
+ )`);
819
+ }
820
+ backupMutationStateSync() {
821
+ this.ensureBackupProtocolSchemaSync();
822
+ const row = this.ctx.storage.sql
823
+ .exec(`SELECT "epoch", "generation", "gateKind", "gateOperationId"
824
+ FROM ${sqlIdentifier(backupStateTable)} WHERE "id" = 1`)
825
+ .toArray()[0];
826
+ if (!row)
827
+ throw new Error("Core backup mutation state is missing");
828
+ return {
829
+ epoch: String(row.epoch),
830
+ generation: Number(row.generation),
831
+ gateKind: row.gateKind,
832
+ gateOperationId: row.gateOperationId
833
+ };
834
+ }
835
+ backupMutationMarkerSync() {
836
+ const totalChanges = this.ctx.storage.sql
837
+ .exec(`SELECT total_changes() AS "totalChanges"`)
838
+ .toArray()[0]?.totalChanges;
839
+ const schemaVersion = JSON.stringify(this.ctx.storage.sql
840
+ .exec(`SELECT "type", "name", "sql" FROM "sqlite_master"
841
+ WHERE "name" NOT LIKE 'sqlite_%'
842
+ ORDER BY "type", "name"`)
843
+ .toArray());
844
+ return {
845
+ totalChanges: Number(totalChanges ?? 0),
846
+ schemaVersion,
847
+ applicationId: 0,
848
+ userVersion: 0
849
+ };
850
+ }
851
+ backupMutationMarkerChanged(left, right) {
852
+ return (left.totalChanges !== right.totalChanges ||
853
+ left.schemaVersion !== right.schemaVersion ||
854
+ left.applicationId !== right.applicationId ||
855
+ left.userVersion !== right.userVersion);
856
+ }
857
+ assertMutationAllowedSync(allowed) {
858
+ const state = this.backupMutationStateSync();
859
+ if (!state.gateKind)
860
+ return;
861
+ if (allowed &&
862
+ state.gateKind === allowed.kind &&
863
+ state.gateOperationId === allowed.operationId) {
864
+ return;
865
+ }
866
+ throw new RaybaseBackupError("BACKUP_RESTORE_MAINTENANCE_REQUIRED", state.gateKind === "restore"
867
+ ? "Database restore maintenance is active"
868
+ : "Database writes are temporarily gated for a consistent backup");
869
+ }
870
+ assertReadableSync() {
871
+ const state = this.backupMutationStateSync();
872
+ if (state.gateKind === "restore") {
873
+ throw new RaybaseBackupError("BACKUP_RESTORE_MAINTENANCE_REQUIRED", "Database restore maintenance is active");
874
+ }
875
+ }
876
+ mutateSync(callback, allowed) {
877
+ let result;
878
+ this.ctx.storage.transactionSync(() => {
879
+ this.ensureBackupProtocolSchemaSync();
880
+ this.assertMutationAllowedSync(allowed);
881
+ const before = this.backupMutationMarkerSync();
882
+ result = callback();
883
+ const after = this.backupMutationMarkerSync();
884
+ if (this.backupMutationMarkerChanged(before, after)) {
885
+ this.ctx.storage.sql.exec(`UPDATE ${sqlIdentifier(backupStateTable)}
886
+ SET "generation" = "generation" + 1 WHERE "id" = 1`);
887
+ }
888
+ });
889
+ return result;
890
+ }
891
+ assertPublicSqlDoesNotTargetBackupState(sql) {
892
+ const lowered = sql.toLowerCase();
893
+ for (const tableName of backupOperationalTables) {
894
+ if (lowered.includes(tableName.toLowerCase())) {
895
+ throw new Error("Core backup-maintenance tables are not available through public SQL execution");
896
+ }
897
+ }
898
+ }
899
+ publicRowsSync(sql, params) {
900
+ let rows;
901
+ this.ctx.storage.transactionSync(() => {
902
+ this.ensureBackupProtocolSchemaSync();
903
+ const before = this.backupMutationMarkerSync();
904
+ rows = this.ctx.storage.sql.exec(sql, ...params).toArray();
905
+ const after = this.backupMutationMarkerSync();
906
+ if (this.backupMutationMarkerChanged(before, after)) {
907
+ // all()/first() historically support statements with RETURNING. Delay
908
+ // the gate check until SQLite proves the statement mutated state: a
909
+ // blocked mutation then rolls back with this transaction, while actual
910
+ // reads remain available during an operator-selected snapshot gate.
911
+ this.assertMutationAllowedSync();
912
+ this.ctx.storage.sql.exec(`UPDATE ${sqlIdentifier(backupStateTable)}
913
+ SET "generation" = "generation" + 1 WHERE "id" = 1`);
914
+ }
915
+ });
916
+ return rows;
917
+ }
918
+ trustedReadRowsSync(sql, params) {
919
+ return this.ctx.storage.sql.exec(sql, ...params).toArray();
920
+ }
921
+ backupSchemaObjectsSync() {
922
+ return this.ctx.storage.sql
923
+ .exec(`SELECT "type", "name", "tbl_name" AS "tableName", "sql"
924
+ FROM "sqlite_master"
925
+ WHERE "type" IN ('table', 'index', 'trigger', 'view')
926
+ AND "name" NOT LIKE 'sqlite_%'
927
+ AND "name" NOT LIKE '_cf_%'
928
+ AND "sql" IS NOT NULL
929
+ ORDER BY CASE "type"
930
+ WHEN 'table' THEN 0
931
+ WHEN 'index' THEN 1
932
+ WHEN 'trigger' THEN 2
933
+ ELSE 3
934
+ END, "name"`)
935
+ .toArray()
936
+ .filter((object) => (!backupOperationalTables.has(object.name) &&
937
+ !backupOperationalTables.has(object.tableName) &&
938
+ object.name !== "_changes" &&
939
+ object.tableName !== "_changes" &&
940
+ !isRaybaseSearchInternalTable(object.name) &&
941
+ !isRaybaseSearchInternalTable(object.tableName)))
942
+ .map((object) => ({
943
+ type: String(object.type),
944
+ name: String(object.name),
945
+ tableName: String(object.tableName),
946
+ sql: String(object.sql)
947
+ }));
948
+ }
949
+ async backupSnapshotPlan() {
950
+ const state = this.backupMutationStateSync();
951
+ if (state.gateKind === "restore") {
952
+ backupError("BACKUP_RESTORE_MAINTENANCE_REQUIRED", "Database restore maintenance is active");
953
+ }
954
+ const objects = this.backupSchemaObjectsSync();
955
+ const rawTables = objects
956
+ .filter((object) => object.type === "table")
957
+ .map((object) => ({
958
+ name: object.name,
959
+ sql: object.sql,
960
+ references: this.ctx.storage.sql
961
+ .exec(`PRAGMA foreign_key_list(${sqlIdentifier(object.name)})`)
962
+ .toArray()
963
+ .map((foreignKey) => String(foreignKey.table))
964
+ }));
965
+ if (rawTables.length > backupDatabaseProtocolLimits.maximumTables) {
966
+ backupError("BACKUP_LIMIT_EXCEEDED", "Database has too many tables for a logical backup");
967
+ }
968
+ const ordered = dependencyOrder(rawTables);
969
+ const tables = ordered.map((table, index) => {
970
+ if (/^CREATE\s+VIRTUAL\s+TABLE\b/i.test(table.sql)) {
971
+ backupError("BACKUP_LIMIT_EXCEEDED", `Virtual table ${table.name} is not supported by format-2 logical snapshots`);
972
+ }
973
+ const columnRows = this.ctx.storage.sql
974
+ .exec(`PRAGMA table_xinfo(${sqlIdentifier(table.name)})`)
975
+ .toArray();
976
+ const insertable = columnRows.filter((column) => Number(column.hidden ?? 0) === 0);
977
+ if (!insertable.length || insertable.length > backupFormatLimits.columnsPerTable) {
978
+ backupError("BACKUP_LIMIT_EXCEEDED", `Table ${table.name} has an unsupported column layout`);
979
+ }
980
+ const columns = insertable.map((column) => ({
981
+ name: String(column.name),
982
+ ...(column.type ? { declaredType: String(column.type) } : {})
983
+ }));
984
+ const names = new Set(columnRows.map((column) => String(column.name).toLowerCase()));
985
+ let pageAlias = "__raybase_page_key";
986
+ while (names.has(pageAlias.toLowerCase()))
987
+ pageAlias += "_";
988
+ const withoutRowid = /\bWITHOUT\s+ROWID\b/i.test(table.sql);
989
+ let pagination;
990
+ let paginationSqlColumns;
991
+ if (withoutRowid) {
992
+ const primaryKey = columnRows
993
+ .filter((column) => Number(column.pk) > 0)
994
+ .sort((left, right) => Number(left.pk) - Number(right.pk))
995
+ .map((column) => String(column.name));
996
+ if (!primaryKey.length) {
997
+ backupError("BACKUP_LIMIT_EXCEEDED", `WITHOUT ROWID table ${table.name} has no usable primary key`);
998
+ }
999
+ pagination = { kind: "primary-key", columns: primaryKey };
1000
+ paginationSqlColumns = primaryKey;
1001
+ }
1002
+ else {
1003
+ const rowidColumn = ["_rowid_", "rowid", "oid"].find((name) => !names.has(name));
1004
+ if (!rowidColumn) {
1005
+ backupError("BACKUP_LIMIT_EXCEEDED", `Table ${table.name} shadows every SQLite rowid name`);
1006
+ }
1007
+ pagination = { kind: "rowid", column: rowidColumn };
1008
+ paginationSqlColumns = [rowidColumn];
1009
+ }
1010
+ return {
1011
+ tableId: `table-${index + 1}`,
1012
+ tableName: table.name,
1013
+ createSql: table.sql,
1014
+ columns,
1015
+ insertSql: `INSERT INTO ${sqlIdentifier(table.name)} (${columns.map((column) => sqlIdentifier(column.name)).join(", ")}) VALUES (${columns.map(() => "?").join(", ")})`,
1016
+ pagination,
1017
+ paginationSqlColumns,
1018
+ pageAlias,
1019
+ references: table.references
1020
+ };
1021
+ });
1022
+ const secondaryDdl = objects
1023
+ .filter((object) => object.type !== "table")
1024
+ .map((object) => object.sql);
1025
+ // Cloudflare SqlStorage intentionally rejects application_id/user_version
1026
+ // PRAGMAs. Format 2 keeps the optional fields deterministic at zero rather
1027
+ // than bypassing the supported Durable Object SQL surface.
1028
+ const applicationId = 0;
1029
+ const userVersion = 0;
1030
+ const fingerprintInput = JSON.stringify({
1031
+ applicationId,
1032
+ userVersion,
1033
+ objects: objects.map(({ type, name, tableName, sql }) => ({ type, name, tableName, sql }))
1034
+ });
1035
+ const schemaFingerprint = await sha256Hex(fingerprintInput);
1036
+ return {
1037
+ epoch: state.epoch,
1038
+ generation: state.generation,
1039
+ databaseBytes: this.ctx.storage.sql.databaseSize,
1040
+ applicationId,
1041
+ userVersion,
1042
+ schemaFingerprint,
1043
+ tables,
1044
+ secondaryDdl
1045
+ };
1046
+ }
1047
+ assertSnapshotMatches(plan, expected) {
1048
+ validateBackupOperationId(expected.operationId);
1049
+ if (plan.epoch !== expected.epoch ||
1050
+ plan.generation !== expected.generation ||
1051
+ plan.schemaFingerprint !== expected.schemaFingerprint) {
1052
+ backupError("BACKUP_SNAPSHOT_INCONSISTENT", "Database generation or schema changed during the logical snapshot");
1053
+ }
1054
+ const state = this.backupMutationStateSync();
1055
+ if (state.gateKind === "snapshot" &&
1056
+ state.gateOperationId !== expected.operationId) {
1057
+ backupError("BACKUP_RESTORE_MAINTENANCE_REQUIRED", "A different write-gated backup operation is active");
1058
+ }
1059
+ }
1060
+ snapshotMetadataRecord(plan) {
1061
+ return {
1062
+ kind: "database-metadata",
1063
+ applicationId: plan.applicationId,
1064
+ userVersion: plan.userVersion,
1065
+ schemaFingerprint: plan.schemaFingerprint,
1066
+ metadata: {
1067
+ snapshotEpoch: plan.epoch,
1068
+ snapshotGeneration: plan.generation
1069
+ }
1070
+ };
1071
+ }
1072
+ snapshotTableSchemaRecord(table) {
1073
+ return {
1074
+ kind: "table-schema",
1075
+ tableId: table.tableId,
1076
+ tableName: table.tableName,
1077
+ columns: table.columns,
1078
+ insertSql: table.insertSql,
1079
+ pagination: table.pagination
1080
+ };
1081
+ }
1082
+ snapshotRowsSync(table, after, limit) {
1083
+ const selectedColumns = table.columns.map((column) => sqlIdentifier(column.name));
1084
+ const bindings = [];
1085
+ let where = "";
1086
+ let order;
1087
+ if (table.pagination.kind === "rowid") {
1088
+ selectedColumns.push(`${sqlIdentifier(table.paginationSqlColumns[0])} AS ${sqlIdentifier(table.pageAlias)}`);
1089
+ order = sqlIdentifier(table.paginationSqlColumns[0]);
1090
+ if (after) {
1091
+ if (after.length !== 1) {
1092
+ backupError("BACKUP_ARCHIVE_CORRUPT", "Rowid snapshot cursor has the wrong key width");
1093
+ }
1094
+ where = ` WHERE ${sqlIdentifier(table.paginationSqlColumns[0])} > ?`;
1095
+ bindings.push(logicalValueBinding(after[0]));
1096
+ }
1097
+ }
1098
+ else {
1099
+ order = table.paginationSqlColumns.map(sqlIdentifier).join(", ");
1100
+ if (after) {
1101
+ if (after.length !== table.paginationSqlColumns.length) {
1102
+ backupError("BACKUP_ARCHIVE_CORRUPT", "Primary-key snapshot cursor has the wrong key width");
1103
+ }
1104
+ where = ` WHERE (${table.paginationSqlColumns.map(sqlIdentifier).join(", ")}) > (${after.map(() => "?").join(", ")})`;
1105
+ bindings.push(...after.map(logicalValueBinding));
1106
+ }
1107
+ }
1108
+ const rows = this.ctx.storage.sql
1109
+ .exec(`SELECT ${selectedColumns.join(", ")}
1110
+ FROM ${sqlIdentifier(table.tableName)}${where}
1111
+ ORDER BY ${order}
1112
+ LIMIT ?`, ...bindings, limit)
1113
+ .toArray();
1114
+ return rows.map((row) => {
1115
+ const values = table.columns.map((column) => backupLogicalValue(row[column.name] ?? null));
1116
+ const key = table.pagination.kind === "rowid"
1117
+ ? [backupLogicalValue(row[table.pageAlias] ?? null)]
1118
+ : table.paginationSqlColumns.map((column) => backupLogicalValue(row[column] ?? null));
1119
+ if (key.some((value) => value.type === "null")) {
1120
+ backupError("BACKUP_SNAPSHOT_INCONSISTENT", `Table ${table.tableName} produced a null pagination key`);
1121
+ }
1122
+ return {
1123
+ record: { kind: "row", tableId: table.tableId, values },
1124
+ key
1125
+ };
1126
+ });
1127
+ }
1128
+ backupRestoreJournalSync(operationId) {
1129
+ const where = operationId ? `WHERE "operationId" = ?` : `WHERE "phase" != 'completed'`;
1130
+ return this.ctx.storage.sql
1131
+ .exec(`SELECT * FROM ${sqlIdentifier(backupRestoreJournalTable)}
1132
+ ${where}
1133
+ ORDER BY "createdAt" DESC LIMIT 1`, ...(operationId ? [operationId] : []))
1134
+ .toArray()[0];
1135
+ }
1136
+ replaceRestorableSchemaSync() {
1137
+ const rawTables = this.ctx.storage.sql
1138
+ .exec(`SELECT "name" FROM "sqlite_master"
1139
+ WHERE "type" = 'table'
1140
+ AND "name" NOT LIKE 'sqlite_%'
1141
+ AND "name" NOT LIKE '_cf_%'`)
1142
+ .toArray()
1143
+ .filter((table) => (!backupOperationalTables.has(table.name) &&
1144
+ !isRaybaseSearchInternalTable(table.name)));
1145
+ const tableNames = new Set(rawTables.map((table) => table.name));
1146
+ const tables = dependencyOrder(rawTables.map((table) => ({
1147
+ ...table,
1148
+ sql: "",
1149
+ references: this.ctx.storage.sql
1150
+ .exec(`PRAGMA foreign_key_list(${sqlIdentifier(table.name)})`)
1151
+ .toArray()
1152
+ .map((foreignKey) => String(foreignKey.table))
1153
+ .filter((name) => tableNames.has(name))
1154
+ })));
1155
+ for (const table of [...tables].reverse()) {
1156
+ this.ctx.storage.sql.exec(`DROP TABLE IF EXISTS ${sqlIdentifier(table.name)}`);
1157
+ }
1158
+ }
1159
+ applyRestoreRecordSync(journal, record) {
1160
+ if (journal.logicalEndSeen) {
1161
+ backupError("BACKUP_ARCHIVE_CORRUPT", "Logical restore batch contains records after its end");
1162
+ }
1163
+ if (record.kind === "logical-end") {
1164
+ if (journal.currentTableId) {
1165
+ backupError("BACKUP_ARCHIVE_CORRUPT", "Logical restore ended before the current table closed");
1166
+ }
1167
+ if (record.recordCount !== journal.recordCount ||
1168
+ record.rowCount !== journal.rowCount ||
1169
+ record.tableCount !== journal.tableCount ||
1170
+ record.plaintextSha256 !== journal.plaintextSha256 ||
1171
+ record.recordCount !== journal.expectedRecordCount ||
1172
+ record.rowCount !== journal.expectedRowCount ||
1173
+ record.tableCount !== journal.expectedTableCount) {
1174
+ backupError("BACKUP_ARCHIVE_CORRUPT", "Logical restore end totals or digest do not match pass 1");
1175
+ }
1176
+ journal.logicalEndSeen = 1;
1177
+ return;
1178
+ }
1179
+ if (!journal.metadataSeen && record.kind !== "database-metadata") {
1180
+ backupError("BACKUP_ARCHIVE_CORRUPT", "Logical restore metadata must be the first record");
1181
+ }
1182
+ if (record.kind === "database-metadata") {
1183
+ if (journal.metadataSeen || journal.recordCount !== 0) {
1184
+ backupError("BACKUP_ARCHIVE_CORRUPT", "Logical restore contains duplicate database metadata");
1185
+ }
1186
+ journal.metadataSeen = 1;
1187
+ }
1188
+ else if (record.kind === "table-schema") {
1189
+ if (journal.currentTableId) {
1190
+ backupError("BACKUP_ARCHIVE_CORRUPT", "Logical restore schema appeared inside table rows");
1191
+ }
1192
+ if (backupOperationalTables.has(record.tableName) ||
1193
+ isRaybaseSearchInternalTable(record.tableName)) {
1194
+ backupError("BACKUP_ARCHIVE_CORRUPT", "Logical restore targets a Core operational table");
1195
+ }
1196
+ const expectedInsert = `INSERT INTO ${sqlIdentifier(record.tableName)} (${record.columns.map((column) => sqlIdentifier(column.name)).join(", ")}) VALUES (${record.columns.map(() => "?").join(", ")})`;
1197
+ if (record.insertSql !== expectedInsert) {
1198
+ backupError("BACKUP_ARCHIVE_CORRUPT", `Logical restore insert statement for ${record.tableName} is not canonical`);
1199
+ }
1200
+ this.ctx.storage.sql.exec(`INSERT INTO ${sqlIdentifier(backupRestoreSchemasTable)}
1201
+ ("operationId", "tableId", "tableName", "columnsJson", "insertSql")
1202
+ VALUES (?, ?, ?, ?, ?)`, journal.operationId, record.tableId, record.tableName, JSON.stringify(record.columns), record.insertSql);
1203
+ }
1204
+ else if (record.kind === "ddl") {
1205
+ if (journal.currentTableId) {
1206
+ backupError("BACKUP_ARCHIVE_CORRUPT", "Logical restore DDL appeared inside table rows");
1207
+ }
1208
+ validateRestoreDdl(record.sql);
1209
+ this.ctx.storage.sql.exec(record.sql);
1210
+ }
1211
+ else if (record.kind === "table-start") {
1212
+ if (journal.currentTableId) {
1213
+ backupError("BACKUP_ARCHIVE_CORRUPT", "Logical restore contains nested table starts");
1214
+ }
1215
+ const schema = this.ctx.storage.sql
1216
+ .exec(`SELECT "tableId" FROM ${sqlIdentifier(backupRestoreSchemasTable)}
1217
+ WHERE "operationId" = ? AND "tableId" = ?`, journal.operationId, record.tableId)
1218
+ .toArray()[0];
1219
+ if (!schema)
1220
+ backupError("BACKUP_ARCHIVE_CORRUPT", `Restore table ${record.tableId} has no schema`);
1221
+ journal.currentTableId = record.tableId;
1222
+ }
1223
+ else if (record.kind === "row") {
1224
+ if (journal.currentTableId !== record.tableId) {
1225
+ backupError("BACKUP_ARCHIVE_CORRUPT", `Restore row for ${record.tableId} is outside its table boundary`);
1226
+ }
1227
+ const schema = this.ctx.storage.sql
1228
+ .exec(`SELECT "columnsJson", "insertSql"
1229
+ FROM ${sqlIdentifier(backupRestoreSchemasTable)}
1230
+ WHERE "operationId" = ? AND "tableId" = ?`, journal.operationId, record.tableId)
1231
+ .toArray()[0];
1232
+ if (!schema)
1233
+ backupError("BACKUP_ARCHIVE_CORRUPT", `Restore row for ${record.tableId} has no schema`);
1234
+ const columns = JSON.parse(schema.columnsJson);
1235
+ if (record.values.length !== columns.length) {
1236
+ backupError("BACKUP_ARCHIVE_CORRUPT", `Restore row for ${record.tableId} has the wrong value count`);
1237
+ }
1238
+ this.ctx.storage.sql.exec(schema.insertSql, ...record.values.map(logicalValueBinding));
1239
+ journal.rowCount += 1;
1240
+ }
1241
+ else if (record.kind === "table-end") {
1242
+ if (journal.currentTableId !== record.tableId) {
1243
+ backupError("BACKUP_ARCHIVE_CORRUPT", `Restore table end for ${record.tableId} is out of order`);
1244
+ }
1245
+ journal.currentTableId = null;
1246
+ journal.tableCount += 1;
1247
+ }
1248
+ else {
1249
+ backupError("BACKUP_RECORD_VERSION_UNSUPPORTED", "Logical restore record kind is unsupported");
1250
+ }
1251
+ journal.recordCount += 1;
1252
+ }
1253
+ columnExists(tableName, columnName) {
1254
+ return this.ctx.storage.sql
1255
+ .exec(`PRAGMA table_info(${sqlLiteral(tableName)})`)
1256
+ .toArray()
1257
+ .some((row) => row.name === columnName);
1258
+ }
1259
+ tableExists(tableName) {
1260
+ return Boolean(this.ctx.storage.sql
1261
+ .exec(`SELECT "name" FROM "sqlite_master" WHERE "type" = 'table' AND "name" = ?`, tableName)
1262
+ .toArray()[0]);
1263
+ }
1264
+ renameColumnIfPresent(tableName, oldColumn, newColumn) {
1265
+ if (!this.tableExists(tableName))
1266
+ return;
1267
+ const hasOld = this.columnExists(tableName, oldColumn);
1268
+ const hasNew = this.columnExists(tableName, newColumn);
1269
+ if (hasOld && !hasNew) {
1270
+ this.ctx.storage.sql.exec(`ALTER TABLE ${sqlIdentifier(tableName)} RENAME COLUMN ${sqlIdentifier(oldColumn)} TO ${sqlIdentifier(newColumn)}`);
1271
+ return;
1272
+ }
1273
+ if (hasOld && hasNew) {
1274
+ this.ctx.storage.sql.exec(`UPDATE ${sqlIdentifier(tableName)}
1275
+ SET ${sqlIdentifier(newColumn)} = ${sqlIdentifier(oldColumn)}
1276
+ WHERE ${sqlIdentifier(newColumn)} IS NULL
1277
+ AND ${sqlIdentifier(oldColumn)} IS NOT NULL`);
1278
+ }
1279
+ }
1280
+ ensureAgentAiColumns() {
1281
+ this.renameColumnIfPresent("_ai_usage_events", "assistant", "agent");
1282
+ this.renameColumnIfPresent("_ai_action_events", "assistant", "agent");
1283
+ }
1284
+ ensureAiUsageEventsTable(table) {
1285
+ if (!table || !this.tableExists(table.name))
1286
+ return;
1287
+ const columns = this.ctx.storage.sql
1288
+ .exec(`PRAGMA table_info(${sqlLiteral(table.name)})`)
1289
+ .toArray();
1290
+ const byName = new Map(columns.map((column) => [column.name, column]));
1291
+ const userId = byName.get("userId");
1292
+ const needsRebuild = userId?.notnull === 1
1293
+ || byName.has("assistant")
1294
+ || Object.keys(table.columns).some((columnName) => !byName.has(columnName));
1295
+ if (!needsRebuild)
1296
+ return;
1297
+ const replacementName = `${table.name}__raybase_upgrade`;
1298
+ if (this.tableExists(replacementName)) {
1299
+ throw new Error(`Cannot upgrade ${table.name}: temporary table ${replacementName} already exists`);
1300
+ }
1301
+ this.ctx.storage.sql.exec(createTableSql({ ...table, name: replacementName }));
1302
+ const targetColumns = Object.keys(table.columns);
1303
+ const sourceExpressions = targetColumns.map((columnName) => {
1304
+ if (byName.has(columnName))
1305
+ return sqlIdentifier(columnName);
1306
+ const definition = table.columns[columnName];
1307
+ if (definition.required || definition.primaryKey) {
1308
+ throw new Error(`Cannot upgrade ${table.name}: required legacy column ${columnName} is missing`);
1309
+ }
1310
+ return "NULL";
1311
+ });
1312
+ this.ctx.storage.sql.exec(`INSERT INTO ${sqlIdentifier(replacementName)} (${targetColumns.map(sqlIdentifier).join(", ")})
1313
+ SELECT ${sourceExpressions.join(", ")} FROM ${sqlIdentifier(table.name)}`);
1314
+ this.ctx.storage.sql.exec(`DROP TABLE ${sqlIdentifier(table.name)}`);
1315
+ this.ctx.storage.sql.exec(`ALTER TABLE ${sqlIdentifier(replacementName)} RENAME TO ${sqlIdentifier(table.name)}`);
1316
+ }
1317
+ ensureJournal() {
1318
+ this.ctx.storage.sql.exec(`CREATE TABLE IF NOT EXISTS "_do_migrations" (
1319
+ "name" TEXT PRIMARY KEY NOT NULL,
1320
+ "checksum" TEXT NOT NULL,
1321
+ "appliedAt" TEXT NOT NULL
1322
+ )`);
1323
+ }
1324
+ ensureControlSchema() {
1325
+ this.ctx.storage.sql.exec("PRAGMA foreign_keys=ON");
1326
+ this.ensureAgentAiColumns();
1327
+ for (const statement of splitSqlStatements(`${raybaseSystemTableSql}\n${raybaseImportSystemTableSql}\n${raybaseSearchSchemaSql}`)) {
1328
+ this.ctx.storage.sql.exec(statement);
1329
+ }
1330
+ if (!this.columnExists("_auth_users", "operatorRole")) {
1331
+ this.ctx.storage.sql.exec(`ALTER TABLE "_auth_users" ADD COLUMN "operatorRole" TEXT`);
1332
+ }
1333
+ if (!this.columnExists("_invites", "note")) {
1334
+ this.ctx.storage.sql.exec(`ALTER TABLE "_invites" ADD COLUMN "note" TEXT`);
1335
+ }
1336
+ if (!this.columnExists("_audit", "actorKind")) {
1337
+ this.ctx.storage.sql.exec(`ALTER TABLE "_audit" ADD COLUMN "actorKind" TEXT`);
1338
+ }
1339
+ if (!this.columnExists("_audit", "runAsKind")) {
1340
+ this.ctx.storage.sql.exec(`ALTER TABLE "_audit" ADD COLUMN "runAsKind" TEXT`);
1341
+ }
1342
+ if (!this.columnExists("_audit", "runAsId")) {
1343
+ this.ctx.storage.sql.exec(`ALTER TABLE "_audit" ADD COLUMN "runAsId" TEXT`);
1344
+ }
1345
+ this.ensureAgentRunHeartbeatColumn();
1346
+ }
1347
+ /**
1348
+ * The liveness mark a running turn touches per model step (2026-08-25).
1349
+ *
1350
+ * System tables are created with IF NOT EXISTS, so an existing `_agent_runs`
1351
+ * never gains a column on its own. Nullable and coalesced to `startedAt` at
1352
+ * every reader, so a database that has not been through this yet behaves
1353
+ * exactly as it did before.
1354
+ */
1355
+ ensureAgentRunHeartbeatColumn() {
1356
+ if (!this.tableExists("_agent_runs"))
1357
+ return;
1358
+ if (this.columnExists("_agent_runs", "heartbeatAt"))
1359
+ return;
1360
+ this.ctx.storage.sql.exec(`ALTER TABLE "_agent_runs" ADD COLUMN "heartbeatAt" TEXT`);
1361
+ }
1362
+ ensureApplicationSchema(systemTables = []) {
1363
+ this.ctx.storage.sql.exec("PRAGMA foreign_keys=ON");
1364
+ for (const statement of splitSqlStatements(`${raybaseApplicationSystemTableSql}\n${raybaseImportSystemTableSql}\n${raybaseSearchSchemaSql}`)) {
1365
+ this.ctx.storage.sql.exec(statement);
1366
+ }
1367
+ this.ensureAgentAiColumns();
1368
+ this.ensureAiUsageEventsTable(systemTables.find((table) => table.name === "_ai_usage_events"));
1369
+ this.ensureAgentRunHeartbeatColumn();
1370
+ const seen = new Set();
1371
+ for (const table of systemTables) {
1372
+ if (seen.has(table.name))
1373
+ continue;
1374
+ seen.add(table.name);
1375
+ for (const statement of systemTableStatements(table)) {
1376
+ this.ctx.storage.sql.exec(statement);
1377
+ }
1378
+ }
1379
+ }
1380
+ async ensureRealtimeState() {
1381
+ if (!this.realtimeSchema)
1382
+ this.realtimeSchema = (await this.ctx.storage.get(realtimeSchemaKey)) ?? null;
1383
+ if (!this.realtimeConfig)
1384
+ this.realtimeConfig = (await this.ctx.storage.get(realtimeConfigKey)) ?? null;
1385
+ this.realtimeAuth = (await this.ctx.storage.get(realtimeAuthKey)) ?? this.realtimeAuth;
1386
+ }
1387
+ attachment(ws) {
1388
+ try {
1389
+ const value = ws.deserializeAttachment();
1390
+ if (value && typeof value === "object" && value.v === 1)
1391
+ return value;
1392
+ }
1393
+ catch {
1394
+ return null;
1395
+ }
1396
+ return null;
1397
+ }
1398
+ saveAttachment(ws, attachment) {
1399
+ ws.serializeAttachment(attachment);
1400
+ }
1401
+ send(ws, message) {
1402
+ const limit = this.realtimeConfig?.limits.maxBufferedAmount ?? 1024 * 1024;
1403
+ if (ws.bufferedAmount > limit) {
1404
+ const attachment = this.attachment(ws);
1405
+ for (const subscription of Object.values(attachment?.subscriptions ?? {})) {
1406
+ ws.send(JSON.stringify({ v: 1, type: "resync", subscriptionId: subscription.id, reason: "gap" }));
1407
+ }
1408
+ return false;
1409
+ }
1410
+ ws.send(JSON.stringify(message));
1411
+ return true;
1412
+ }
1413
+ sendError(ws, ref, code, message) {
1414
+ this.send(ws, { v: 1, ref, type: "error", code, message });
1415
+ }
1416
+ sendStatus(ws, ref, status) {
1417
+ this.send(ws, status.ref ? status : { ...status, ref });
1418
+ }
1419
+ parseState(state, maxBytes) {
1420
+ const encoded = JSON.stringify(state ?? {});
1421
+ if (new TextEncoder().encode(encoded).byteLength > maxBytes) {
1422
+ throw new Error("Presence state is too large");
1423
+ }
1424
+ if (state && typeof state === "object" && !Array.isArray(state)) {
1425
+ const forbidden = ["id", "name", "email", "role", "roles", "appRoles", "user", "connectionId", "joinedAt", "updatedAt"];
1426
+ for (const key of forbidden) {
1427
+ if (Object.prototype.hasOwnProperty.call(state, key)) {
1428
+ throw new Error(`Presence state cannot set identity field ${key}`);
1429
+ }
1430
+ }
1431
+ }
1432
+ return state ?? {};
1433
+ }
1434
+ parseRoles(value) {
1435
+ if (Array.isArray(value))
1436
+ return Array.from(new Set(["user", ...value.map(String).filter(Boolean)]));
1437
+ if (typeof value === "string" && value.trim()) {
1438
+ try {
1439
+ const parsed = JSON.parse(value);
1440
+ if (Array.isArray(parsed))
1441
+ return Array.from(new Set(["user", ...parsed.map(String).filter(Boolean)]));
1442
+ }
1443
+ catch {
1444
+ return Array.from(new Set(["user", ...value.split(",").map((role) => role.trim()).filter(Boolean)]));
1445
+ }
1446
+ return Array.from(new Set(["user", value]));
1447
+ }
1448
+ return ["user"];
1449
+ }
1450
+ expiresAtMs(value) {
1451
+ if (typeof value === "number")
1452
+ return value;
1453
+ const numeric = Number(value);
1454
+ if (Number.isFinite(numeric) && numeric > 0)
1455
+ return numeric;
1456
+ const parsed = new Date(value).getTime();
1457
+ return Number.isFinite(parsed) ? parsed : 0;
1458
+ }
1459
+ async revalidate(attachment) {
1460
+ const runtime = this.runtimeOptions();
1461
+ if (runtime.revalidateRealtimeSession) {
1462
+ return runtime.revalidateRealtimeSession({
1463
+ sessionToken: attachment.sessionToken,
1464
+ sessionId: attachment.sessionId,
1465
+ scopeId: attachment.user.organizationId ?? null,
1466
+ user: attachment.user
1467
+ }, this.env);
1468
+ }
1469
+ await this.ensureSchema({ migrations: [] });
1470
+ if (!attachment.sessionToken && !attachment.sessionId)
1471
+ return null;
1472
+ const row = this.ctx.storage.sql
1473
+ .exec(`SELECT s."id" AS "sessionId", s."token" AS "sessionToken", s."expiresAt", u."id" AS "userId",
1474
+ u."email", u."name", u."operatorRole", u."appRoles", u."banned"
1475
+ FROM "_auth_sessions" s INNER JOIN "_auth_users" u ON u."id" = s."userId"
1476
+ WHERE ${attachment.sessionToken ? 's."token" = ?' : 's."id" = ?'}
1477
+ LIMIT 1`, attachment.sessionToken ?? attachment.sessionId ?? "")
1478
+ .toArray()[0];
1479
+ if (!row || this.expiresAtMs(row.expiresAt) <= Date.now() || Boolean(row.banned))
1480
+ return null;
1481
+ const operatorRole = normalizeStoredAdminRole(row.operatorRole);
1482
+ return withHostUserRoleCeiling({
1483
+ id: row.userId,
1484
+ email: row.email,
1485
+ name: row.name,
1486
+ operatorRole,
1487
+ appRoles: this.parseRoles(row.appRoles),
1488
+ disabled: false
1489
+ });
1490
+ }
1491
+ tableFor(name) {
1492
+ const schema = this.realtimeSchema;
1493
+ if (!schema)
1494
+ return null;
1495
+ return Object.values(schema.tables).find((table) => table.name === name) ?? schema.tables[name] ?? null;
1496
+ }
1497
+ parseChangeRow(row) {
1498
+ return {
1499
+ seq: Number(row.seq),
1500
+ tableName: row.tableName,
1501
+ op: row.op,
1502
+ rowId: row.rowId,
1503
+ oldRow: row.oldRow ? JSON.parse(row.oldRow) : null,
1504
+ newRow: row.newRow ? JSON.parse(row.newRow) : null,
1505
+ actorId: row.actorId,
1506
+ adminOverride: Boolean(row.adminOverride),
1507
+ createdAt: row.createdAt
1508
+ };
1509
+ }
1510
+ async deliverChange(ws, attachment, subscription, change) {
1511
+ const table = this.tableFor(change.tableName);
1512
+ if (!table || table.name !== subscription.table)
1513
+ return;
1514
+ const event = projectChangeForSubscriber({
1515
+ table,
1516
+ subscriber: { user: attachment.user, adminOverride: false },
1517
+ op: change.op,
1518
+ seq: change.seq,
1519
+ oldRow: change.oldRow ?? null,
1520
+ newRow: change.newRow ?? null,
1521
+ options: subscription.options,
1522
+ events: subscription.events
1523
+ });
1524
+ if (!event)
1525
+ return;
1526
+ if (this.send(ws, { v: 1, type: "change", subscriptionId: subscription.id, event })) {
1527
+ subscription.lastSeq = Math.max(subscription.lastSeq ?? 0, change.seq);
1528
+ attachment.lastSeq = Math.max(attachment.lastSeq ?? 0, change.seq);
1529
+ this.saveAttachment(ws, attachment);
1530
+ }
1531
+ }
1532
+ async replay(ws, attachment, subscription, sinceSeq) {
1533
+ if (!sinceSeq)
1534
+ return;
1535
+ const config = this.realtimeConfig;
1536
+ if (!config)
1537
+ return;
1538
+ const stats = this.ctx.storage.sql
1539
+ .exec(`SELECT MIN("seq") AS "minSeq", MAX("seq") AS "maxSeq" FROM "_changes" WHERE "tableName" = ?`, subscription.table)
1540
+ .toArray()[0];
1541
+ const minSeq = stats?.minSeq === null || stats?.minSeq === undefined ? null : Number(stats.minSeq);
1542
+ const maxSeq = stats?.maxSeq === null || stats?.maxSeq === undefined ? null : Number(stats.maxSeq);
1543
+ if (!maxSeq || maxSeq <= sinceSeq)
1544
+ return;
1545
+ if (minSeq !== null && minSeq > sinceSeq + 1) {
1546
+ this.send(ws, { v: 1, type: "resync", subscriptionId: subscription.id, reason: "retention-expired", sinceSeq, latestSeq: maxSeq });
1547
+ return;
1548
+ }
1549
+ const rows = this.ctx.storage.sql
1550
+ .exec(`SELECT "seq", "tableName", "op", "rowId", "oldRow", "newRow", "actorId", "adminOverride", "createdAt"
1551
+ FROM "_changes"
1552
+ WHERE "tableName" = ? AND "seq" > ?
1553
+ ORDER BY "seq" ASC
1554
+ LIMIT ?`, subscription.table, sinceSeq, config.limits.maxReplayChanges + 1)
1555
+ .toArray();
1556
+ if (rows.length > config.limits.maxReplayChanges) {
1557
+ this.send(ws, { v: 1, type: "resync", subscriptionId: subscription.id, reason: "gap", sinceSeq, latestSeq: maxSeq });
1558
+ return;
1559
+ }
1560
+ for (const row of rows) {
1561
+ await this.deliverChange(ws, attachment, subscription, this.parseChangeRow(row));
1562
+ }
1563
+ }
1564
+ presenceMember(attachment, room) {
1565
+ const entry = attachment.presenceRooms[room];
1566
+ return {
1567
+ connectionId: attachment.connectionId,
1568
+ user: {
1569
+ id: attachment.user.id,
1570
+ name: attachment.user.name,
1571
+ email: attachment.user.email,
1572
+ appRoles: attachment.user.appRoles?.map(String) ?? ["user"]
1573
+ },
1574
+ state: entry?.state ?? {},
1575
+ joinedAt: entry?.joinedAt ?? new Date().toISOString(),
1576
+ updatedAt: entry?.updatedAt ?? new Date().toISOString()
1577
+ };
1578
+ }
1579
+ roomMembers(room) {
1580
+ return this.ctx
1581
+ .getWebSockets()
1582
+ .map((ws) => this.attachment(ws))
1583
+ .filter((attachment) => Boolean(attachment?.presenceRooms[room]))
1584
+ .map((attachment) => this.presenceMember(attachment, room));
1585
+ }
1586
+ broadcastPresence(room, message) {
1587
+ for (const ws of this.ctx.getWebSockets()) {
1588
+ const attachment = this.attachment(ws);
1589
+ if (!attachment?.presenceRooms[room])
1590
+ continue;
1591
+ this.send(ws, { v: 1, room, ...message });
1592
+ }
1593
+ }
1594
+ async fanout(change) {
1595
+ await this.ensureRealtimeState();
1596
+ for (const ws of this.ctx.getWebSockets()) {
1597
+ const attachment = this.attachment(ws);
1598
+ if (!attachment) {
1599
+ ws.close(1011, "Missing realtime attachment");
1600
+ continue;
1601
+ }
1602
+ const user = await this.revalidate(attachment);
1603
+ if (!user) {
1604
+ ws.close(4001, "Session is no longer valid");
1605
+ continue;
1606
+ }
1607
+ attachment.user = user;
1608
+ const subscriptions = Object.values(attachment.subscriptions).filter((subscription) => subscription.table === change.tableName);
1609
+ for (const subscription of subscriptions) {
1610
+ try {
1611
+ await this.deliverChange(ws, attachment, subscription, change);
1612
+ }
1613
+ catch (error) {
1614
+ if (!(error instanceof RaybaseDataReadDeniedError))
1615
+ throw error;
1616
+ this.sendError(ws, undefined, error.code, error.message);
1617
+ delete attachment.subscriptions[subscription.id];
1618
+ this.saveAttachment(ws, attachment);
1619
+ }
1620
+ }
1621
+ }
1622
+ }
1623
+ async fanoutNotification(input) {
1624
+ await this.ensureRealtimeState();
1625
+ for (const ws of this.ctx.getWebSockets()) {
1626
+ const attachment = this.attachment(ws);
1627
+ if (!attachment?.notificationSubscription)
1628
+ continue;
1629
+ const user = await this.revalidate(attachment);
1630
+ if (!user) {
1631
+ ws.close(4001, "Session is no longer valid");
1632
+ continue;
1633
+ }
1634
+ attachment.user = user;
1635
+ if (attachment.notificationSubscription.subjectId !== input.subjectId)
1636
+ continue;
1637
+ if (input.seq <= attachment.notificationSubscription.lastSeq)
1638
+ continue;
1639
+ if (this.send(ws, { v: 1, type: "notification", seq: input.seq, event: input.event, item: input.item }) &&
1640
+ this.send(ws, { v: 1, type: "notifications.count", unread: input.unread, seq: input.seq })) {
1641
+ attachment.notificationSubscription.lastSeq = input.seq;
1642
+ this.saveAttachment(ws, attachment);
1643
+ }
1644
+ }
1645
+ }
1646
+ async pruneChanges(config) {
1647
+ const cutoff = new Date(Date.now() - config.retention.maxAgeSeconds * 1000).toISOString();
1648
+ this.mutateSync(() => {
1649
+ this.ctx.storage.sql.exec(`DELETE FROM "_changes" WHERE "createdAt" < ?`, cutoff);
1650
+ this.ctx.storage.sql.exec(`DELETE FROM "_changes"
1651
+ WHERE "seq" NOT IN (
1652
+ SELECT "seq" FROM "_changes" ORDER BY "seq" DESC LIMIT ?
1653
+ )`, config.retention.maxChanges);
1654
+ });
1655
+ await this.ctx.storage.setAlarm(Date.now() + realtimeSweepMs);
1656
+ }
1657
+ async ensureSchema(bundle) {
1658
+ const startedAt = Date.now();
1659
+ const cacheKey = JSON.stringify({
1660
+ migrations: bundle.migrations.map(({ name, checksum }) => ({ name, checksum })),
1661
+ systemTables: (bundle.systemTables ?? []).map((table) => ({
1662
+ name: table.name,
1663
+ columns: table.columns,
1664
+ indexes: table.indexes
1665
+ }))
1666
+ });
1667
+ if (this.ensuredSchemaBundles.has(cacheKey)) {
1668
+ return { applied: [], cached: true, durationMs: Date.now() - startedAt };
1669
+ }
1670
+ const applied = [];
1671
+ // A throw inside blockConcurrencyWhile resets the whole object. A bad
1672
+ // bundle (checksum mismatch, broken SQL) must fail the caller loudly, but
1673
+ // it is a deploy problem, not storage corruption — transactionSync already
1674
+ // rolled back — so the failure is carried out and thrown after the gate.
1675
+ let failure = null;
1676
+ await this.ctx.blockConcurrencyWhile(async () => {
1677
+ try {
1678
+ this.ctx.storage.transactionSync(() => {
1679
+ this.ensureBackupProtocolSchemaSync();
1680
+ const before = this.backupMutationMarkerSync();
1681
+ this.ensureJournal();
1682
+ if (this.runtimeOptions().schemaMode === "application") {
1683
+ this.ensureApplicationSchema(bundle.systemTables);
1684
+ }
1685
+ else {
1686
+ this.ensureControlSchema();
1687
+ this.ensureApplicationSchema(bundle.systemTables);
1688
+ }
1689
+ for (const migration of bundle.migrations) {
1690
+ const existing = this.ctx.storage.sql.exec(`SELECT "checksum" FROM "_do_migrations" WHERE "name" = ?`, migration.name).toArray()[0];
1691
+ if (existing) {
1692
+ if (existing.checksum !== migration.checksum) {
1693
+ throw new Error(`Durable Object migration checksum mismatch for ${migration.name}`);
1694
+ }
1695
+ continue;
1696
+ }
1697
+ for (const statement of splitSqlStatements(migration.sql)) {
1698
+ this.ctx.storage.sql.exec(statement);
1699
+ }
1700
+ this.ctx.storage.sql.exec(`INSERT INTO "_do_migrations" ("name", "checksum", "appliedAt") VALUES (?, ?, ?)`, migration.name, migration.checksum, new Date().toISOString());
1701
+ applied.push(migration.name);
1702
+ }
1703
+ const after = this.backupMutationMarkerSync();
1704
+ if (this.backupMutationMarkerChanged(before, after)) {
1705
+ this.assertMutationAllowedSync();
1706
+ this.ctx.storage.sql.exec(`UPDATE ${sqlIdentifier(backupStateTable)}
1707
+ SET "generation" = "generation" + 1 WHERE "id" = 1`);
1708
+ }
1709
+ });
1710
+ if (!this.initialized) {
1711
+ await this.ctx.storage.put({
1712
+ createdAt: (await this.ctx.storage.get("createdAt")) ?? new Date().toISOString()
1713
+ });
1714
+ this.initialized = true;
1715
+ }
1716
+ this.ensuredSchemaBundles.add(cacheKey);
1717
+ }
1718
+ catch (error) {
1719
+ failure = error;
1720
+ }
1721
+ });
1722
+ if (failure)
1723
+ throw failure instanceof Error ? failure : new Error(String(failure));
1724
+ return { applied, cached: false, durationMs: Date.now() - startedAt };
1725
+ }
1726
+ ensureJobsSchemaSync() {
1727
+ for (const statement of splitSqlStatements(jobSchemaSql)) {
1728
+ this.ctx.storage.sql.exec(statement);
1729
+ }
1730
+ const addColumn = (table, column, ddl) => {
1731
+ if (!this.columnExists(table, column))
1732
+ this.ctx.storage.sql.exec(`ALTER TABLE ${sqlIdentifier(table)} ADD COLUMN ${ddl}`);
1733
+ };
1734
+ addColumn("_job_runs", "scheduleKey", '"scheduleKey" TEXT');
1735
+ addColumn("_job_runs", "scheduleGeneration", '"scheduleGeneration" INTEGER');
1736
+ addColumn("_job_runs", "scheduleBookedAt", '"scheduleBookedAt" TEXT');
1737
+ addColumn("_job_runs", "batchId", '"batchId" TEXT');
1738
+ addColumn("_job_runs", "batchSeq", '"batchSeq" INTEGER');
1739
+ addColumn("_job_runs", "continuationOfBatchId", '"continuationOfBatchId" TEXT');
1740
+ addColumn("_job_runs", "concurrencyKey", '"concurrencyKey" TEXT');
1741
+ addColumn("_job_runs", "rateLimitKey", '"rateLimitKey" TEXT');
1742
+ addColumn("_job_runs", "deferrals", '"deferrals" INTEGER NOT NULL DEFAULT 0');
1743
+ addColumn("_job_runs", "deferredAt", '"deferredAt" TEXT');
1744
+ addColumn("_job_runs", "deferredReason", '"deferredReason" TEXT');
1745
+ addColumn("_job_runs", "wokenAt", '"wokenAt" TEXT');
1746
+ addColumn("_job_schedules", "key", '"key" TEXT');
1747
+ addColumn("_job_schedules", "kind", '"kind" TEXT');
1748
+ addColumn("_job_schedules", "status", '"status" TEXT');
1749
+ addColumn("_job_schedules", "bookedAt", '"bookedAt" TEXT');
1750
+ addColumn("_job_schedules", "generation", '"generation" INTEGER NOT NULL DEFAULT 1');
1751
+ addColumn("_job_schedules", "dispatchAttempts", '"dispatchAttempts" INTEGER NOT NULL DEFAULT 0');
1752
+ addColumn("_job_schedules", "dispatchLeaseExpiresAt", '"dispatchLeaseExpiresAt" TEXT');
1753
+ addColumn("_job_schedules", "lastDispatchAt", '"lastDispatchAt" TEXT');
1754
+ addColumn("_job_schedules", "lastDispatchError", '"lastDispatchError" TEXT');
1755
+ addColumn("_job_schedules", "suspendedAt", '"suspendedAt" TEXT');
1756
+ addColumn("_job_schedules", "cancelledAt", '"cancelledAt" TEXT');
1757
+ addColumn("_job_schedules", "cancellationReason", '"cancellationReason" TEXT');
1758
+ addColumn("_job_schedules", "actorKind", '"actorKind" TEXT');
1759
+ addColumn("_job_schedules", "actorId", '"actorId" TEXT');
1760
+ this.ctx.storage.sql.exec(`UPDATE "_job_schedules"
1761
+ SET "key" = COALESCE("key", 'raybase:declared:' || "jobName"),
1762
+ "kind" = COALESCE("kind", CASE WHEN "cron" IS NULL THEN 'once' ELSE 'cron' END),
1763
+ "status" = COALESCE("status", CASE WHEN COALESCE("enabled", 1) = 1 THEN 'pending' ELSE 'suspended' END),
1764
+ "bookedAt" = COALESCE("bookedAt", "nextRunAt"),
1765
+ "generation" = COALESCE("generation", 1),
1766
+ "dispatchAttempts" = COALESCE("dispatchAttempts", 0),
1767
+ "actorKind" = COALESCE("actorKind", CASE WHEN "createdBy" IS NULL THEN 'service' ELSE 'user' END),
1768
+ "actorId" = COALESCE("actorId", "createdBy", "service")`);
1769
+ this.ctx.storage.sql.exec(`CREATE UNIQUE INDEX IF NOT EXISTS "_job_schedules_key_idx" ON "_job_schedules" ("key")`);
1770
+ this.ctx.storage.sql.exec(`CREATE INDEX IF NOT EXISTS "_job_schedules_status_booked_idx" ON "_job_schedules" ("status", "bookedAt")`);
1771
+ this.ctx.storage.sql.exec(`CREATE INDEX IF NOT EXISTS "_job_schedules_job_idx" ON "_job_schedules" ("jobName")`);
1772
+ this.ctx.storage.sql.exec(`CREATE INDEX IF NOT EXISTS "_job_schedules_lease_idx" ON "_job_schedules" ("dispatchLeaseExpiresAt")`);
1773
+ this.ctx.storage.sql.exec(`CREATE UNIQUE INDEX IF NOT EXISTS "_job_runs_schedule_generation_idx"
1774
+ ON "_job_runs" ("scheduleKey", "scheduleGeneration")
1775
+ WHERE "scheduleKey" IS NOT NULL AND "scheduleGeneration" IS NOT NULL`);
1776
+ this.ctx.storage.sql.exec(`CREATE UNIQUE INDEX IF NOT EXISTS "_job_runs_batch_seq_idx"
1777
+ ON "_job_runs" ("batchId", "batchSeq")
1778
+ WHERE "batchId" IS NOT NULL`);
1779
+ this.ctx.storage.sql.exec(`CREATE INDEX IF NOT EXISTS "_job_runs_continuation_idx"
1780
+ ON "_job_runs" ("continuationOfBatchId")
1781
+ WHERE "continuationOfBatchId" IS NOT NULL`);
1782
+ this.ctx.storage.sql.exec(`CREATE INDEX IF NOT EXISTS "_job_runs_concurrency_key_idx"
1783
+ ON "_job_runs" ("concurrencyKey", "status")
1784
+ WHERE "concurrencyKey" IS NOT NULL`);
1785
+ this.ctx.storage.sql.exec(`CREATE INDEX IF NOT EXISTS "_job_runs_job_status_idx" ON "_job_runs" ("jobName", "status")`);
1786
+ }
1787
+ ensureAiBudgetSchemaSync() {
1788
+ for (const statement of splitSqlStatements(aiBudgetSchemaSql)) {
1789
+ this.ctx.storage.sql.exec(statement);
1790
+ }
1791
+ }
1792
+ async claimAiBudget(input) {
1793
+ await this.ensureSchema({ migrations: [] });
1794
+ const now = input.now || new Date().toISOString();
1795
+ const windowStart = `${now.slice(0, 10)}T00:00:00.000Z`;
1796
+ const windowEnd = new Date(new Date(windowStart).getTime() + 24 * 60 * 60 * 1000).toISOString();
1797
+ const requestCount = Math.max(0, Math.trunc(input.requestCount || 0));
1798
+ const outputTokens = Math.max(0, Math.trunc(input.estimatedOutputTokens || 0));
1799
+ const neurons = input.estimatedNeurons == null ? null : Math.max(0, Math.trunc(input.estimatedNeurons));
1800
+ const principalKind = input.principalKind;
1801
+ const checks = [
1802
+ { scope: "app", scopeId: input.appScopeId || "app", requestLimit: input.perAppDailyRequests, outputLimit: null }
1803
+ ];
1804
+ if (principalKind === "public") {
1805
+ checks.push({
1806
+ scope: "public",
1807
+ scopeId: input.publicScopeId || input.userId || "public",
1808
+ requestLimit: input.perPublicDailyRequests,
1809
+ outputLimit: input.perPublicDailyOutputTokens
1810
+ });
1811
+ }
1812
+ else if (principalKind === "user") {
1813
+ const userId = input.userId;
1814
+ if (!userId)
1815
+ throw new Error("AI budget claim requires userId for user principals");
1816
+ checks.push({ scope: "user", scopeId: userId, requestLimit: input.perUserDailyRequests, outputLimit: input.perUserDailyOutputTokens });
1817
+ }
1818
+ let result = { ok: true, windowStart, windowEnd };
1819
+ this.mutateSync(() => {
1820
+ this.ensureAiBudgetSchemaSync();
1821
+ const rows = new Map();
1822
+ for (const check of checks) {
1823
+ const row = this.ctx.storage.sql
1824
+ .exec(`SELECT * FROM "_ai_budget_windows" WHERE "scope" = ? AND "scopeId" = ? AND "windowStart" = ? LIMIT 1`, check.scope, check.scopeId, windowStart)
1825
+ .toArray()[0] ?? null;
1826
+ rows.set(`${check.scope}:${check.scopeId}`, row);
1827
+ const currentRequests = Number(row?.requestCount ?? 0);
1828
+ const currentOutputTokens = Number(row?.estimatedOutputTokens ?? 0);
1829
+ if (check.requestLimit != null && check.requestLimit >= 0 && currentRequests + requestCount > check.requestLimit) {
1830
+ result = { ok: false, scope: check.scope, reason: "daily request limit reached", limit: check.requestLimit, current: currentRequests };
1831
+ return;
1832
+ }
1833
+ if (check.outputLimit != null && check.outputLimit >= 0 && currentOutputTokens + outputTokens > check.outputLimit) {
1834
+ result = { ok: false, scope: check.scope, reason: "daily output token limit reached", limit: check.outputLimit, current: currentOutputTokens };
1835
+ return;
1836
+ }
1837
+ }
1838
+ for (const check of checks) {
1839
+ const row = rows.get(`${check.scope}:${check.scopeId}`) ?? null;
1840
+ if (row) {
1841
+ this.ctx.storage.sql.exec(`UPDATE "_ai_budget_windows"
1842
+ SET "requestCount" = "requestCount" + ?,
1843
+ "estimatedOutputTokens" = "estimatedOutputTokens" + ?,
1844
+ "estimatedNeurons" = COALESCE("estimatedNeurons", 0) + COALESCE(?, 0),
1845
+ "updatedAt" = ?
1846
+ WHERE "id" = ?`, requestCount, outputTokens, neurons, now, row.id);
1847
+ }
1848
+ else {
1849
+ this.ctx.storage.sql.exec(`INSERT INTO "_ai_budget_windows"
1850
+ ("id", "scope", "scopeId", "windowStart", "windowEnd", "requestCount", "estimatedOutputTokens", "estimatedNeurons", "updatedAt")
1851
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, `ai_budget_${crypto.randomUUID().replace(/-/g, "")}`, check.scope, check.scopeId, windowStart, windowEnd, requestCount, outputTokens, neurons, now);
1852
+ }
1853
+ }
1854
+ });
1855
+ return result;
1856
+ }
1857
+ async ensureJobsSchema() {
1858
+ await this.ensureSchema({ migrations: [] });
1859
+ await this.ctx.blockConcurrencyWhile(async () => {
1860
+ this.mutateSync(() => {
1861
+ this.ensureJobsSchemaSync();
1862
+ });
1863
+ });
1864
+ return { ok: true };
1865
+ }
1866
+ getJobRun(runId) {
1867
+ return rowToJobRun(this.ctx.storage.sql.exec(`SELECT * FROM "_job_runs" WHERE "id" = ?`, runId).toArray()[0]);
1868
+ }
1869
+ async hydrateClock(force = false) {
1870
+ await hydrateAppClock({
1871
+ local: this.env.RAYBASE_ENV === "local",
1872
+ force,
1873
+ readState: async () => {
1874
+ const row = this.ctx.storage.sql
1875
+ .exec(`SELECT "value" FROM "_state" WHERE "key" = ?`, devTimeTravelStateKey)
1876
+ .toArray()[0];
1877
+ if (!row?.value)
1878
+ return null;
1879
+ try {
1880
+ return JSON.parse(row.value);
1881
+ }
1882
+ catch {
1883
+ return null;
1884
+ }
1885
+ }
1886
+ });
1887
+ }
1888
+ async scheduleRealAlarm(realMs = wallNowMs() + defaultCompactionDelayMs) {
1889
+ if (!Number.isFinite(realMs))
1890
+ return;
1891
+ const storage = this.ctx.storage;
1892
+ if (!storage.setAlarm)
1893
+ return;
1894
+ const current = storage.getAlarm ? await storage.getAlarm() : null;
1895
+ if (current === null || current > realMs)
1896
+ await storage.setAlarm(new Date(Math.max(wallNowMs() + 1000, realMs)));
1897
+ }
1898
+ async scheduleVirtualAlarm(targetIso) {
1899
+ if (!targetIso) {
1900
+ await this.scheduleRealAlarm();
1901
+ return;
1902
+ }
1903
+ await this.hydrateClock();
1904
+ const virtualMs = Date.parse(targetIso);
1905
+ if (!Number.isFinite(virtualMs))
1906
+ return;
1907
+ await this.scheduleRealAlarm(toRealMs(virtualMs));
1908
+ }
1909
+ async armAgentAlarm(targetIso, claims) {
1910
+ if (claims && Object.keys(claims).length > 0) {
1911
+ await this.ctx.storage.put(agentAlarmClaimsKey, claims);
1912
+ }
1913
+ await this.hydrateClock(true);
1914
+ await this.scheduleVirtualAlarm(targetIso);
1915
+ return { ok: true };
1916
+ }
1917
+ agentFollowUpExecutor() {
1918
+ return {
1919
+ all: async (sql, params = []) => {
1920
+ this.assertReadableSync();
1921
+ return this.trustedReadRowsSync(sql, params);
1922
+ },
1923
+ first: async (sql, params = []) => {
1924
+ this.assertReadableSync();
1925
+ return this.trustedReadRowsSync(sql, params)[0] ?? null;
1926
+ },
1927
+ run: async (sql, params = []) => {
1928
+ this.mutateSync(() => {
1929
+ this.ctx.storage.sql.exec(sql, ...params);
1930
+ });
1931
+ },
1932
+ runTransaction: async (statements) => {
1933
+ this.mutateSync(() => {
1934
+ for (const statement of statements) {
1935
+ this.ctx.storage.sql.exec(statement.sql, ...(statement.params ?? []));
1936
+ }
1937
+ });
1938
+ }
1939
+ };
1940
+ }
1941
+ async runAgentFollowUpAlarm(appNow, wallNow) {
1942
+ if (!this.tableExists("_agent_follow_ups"))
1943
+ return;
1944
+ const control = this.agentFollowUpExecutor();
1945
+ const queue = this.env.RAYBASE_JOBS;
1946
+ const claims = (await this.ctx.storage.get(agentAlarmClaimsKey))
1947
+ ?? {};
1948
+ const newlyLapsed = new Set();
1949
+ let dispatchFailed = false;
1950
+ if (queue) {
1951
+ try {
1952
+ const outcomes = await dispatchDueAgentFollowUps({
1953
+ control,
1954
+ send: async (message) => {
1955
+ await queue.send(mergeRaybaseRuntimeClaims(message, claims));
1956
+ },
1957
+ now: appNow,
1958
+ wallNow,
1959
+ limit: 100,
1960
+ claimedBy: "alarm"
1961
+ });
1962
+ for (const outcome of outcomes) {
1963
+ if (outcome.type === "lapsed")
1964
+ newlyLapsed.add(outcome.row.id);
1965
+ }
1966
+ for (const row of await listUnhandledLapsedAgentFollowUps(control, { limit: 100 })) {
1967
+ if (newlyLapsed.has(row.id))
1968
+ continue;
1969
+ await queue.send(mergeRaybaseRuntimeClaims({
1970
+ kind: "raybase.agent.follow_up.lapse",
1971
+ version: 1,
1972
+ followUpId: row.id,
1973
+ agentName: row.agentName,
1974
+ claimedBy: "alarm",
1975
+ lapsedAt: row.settledAt ?? row.updatedAt
1976
+ }, claims));
1977
+ }
1978
+ }
1979
+ catch (error) {
1980
+ dispatchFailed = true;
1981
+ console.warn("Raybase Agent follow-up alarm dispatch failed.", error);
1982
+ }
1983
+ }
1984
+ try {
1985
+ const work = await agentFollowUpAlarmWork(control);
1986
+ if (!work.nextDueAt && !work.hasUnhandledLapse && !work.hasOutstandingDispatch)
1987
+ return;
1988
+ const wallNowMsValue = Date.parse(wallNow);
1989
+ const appNowMsValue = Date.parse(appNow);
1990
+ const nextDueMs = work.nextDueAt ? Date.parse(work.nextDueAt) : Number.POSITIVE_INFINITY;
1991
+ const retryPastDue = (!queue || dispatchFailed) && nextDueMs < appNowMsValue;
1992
+ const targets = [
1993
+ retryPastDue ? wallNowMsValue + followUpOutstandingRetryMs : toRealMs(nextDueMs),
1994
+ work.hasUnhandledLapse ? wallNowMsValue + followUpLapseRetryMs : Number.POSITIVE_INFINITY,
1995
+ work.hasOutstandingDispatch ? wallNowMsValue + followUpOutstandingRetryMs : Number.POSITIVE_INFINITY,
1996
+ wallNowMsValue + followUpSafetySweepMs
1997
+ ].filter(Number.isFinite);
1998
+ await this.scheduleRealAlarm(Math.min(...targets));
1999
+ }
2000
+ catch (error) {
2001
+ console.warn("Raybase Agent follow-up alarm re-arm failed.", error);
2002
+ }
2003
+ }
2004
+ async runJobScheduleAlarm(appNow, wallNow) {
2005
+ if (!this.tableExists("_job_schedules"))
2006
+ return;
2007
+ const queue = this.env.RAYBASE_JOBS;
2008
+ const claims = (await this.ctx.storage.get(agentAlarmClaimsKey)) ?? {};
2009
+ const leaseExpiresAt = new Date(Date.parse(wallNow) + 2 * 60_000).toISOString();
2010
+ const claimed = await this.claimDueJobSchedules({ now: appNow, wallNow, leaseExpiresAt, limit: 100 });
2011
+ for (const schedule of claimed.schedules) {
2012
+ if (!schedule.bookedAt)
2013
+ continue;
2014
+ if (!queue) {
2015
+ const retryAt = new Date(Date.parse(wallNow) + 10_000).toISOString();
2016
+ this.mutateSync(() => {
2017
+ this.ctx.storage.sql.exec(`UPDATE "_job_schedules" SET "dispatchLeaseExpiresAt" = ?, "lastDispatchError" = ?, "updatedAt" = ? WHERE "id" = ? AND "generation" = ?`, retryAt, "RAYBASE_JOBS queue binding is missing", appNow, schedule.id, schedule.generation);
2018
+ });
2019
+ await this.scheduleRealAlarm(Date.parse(retryAt));
2020
+ continue;
2021
+ }
2022
+ try {
2023
+ await queue.send(mergeRaybaseRuntimeClaims({
2024
+ kind: "raybase.job.schedule",
2025
+ version: 1,
2026
+ scheduleId: schedule.id,
2027
+ key: schedule.key,
2028
+ jobName: schedule.jobName,
2029
+ generation: schedule.generation,
2030
+ bookedAt: schedule.bookedAt,
2031
+ inputJson: schedule.inputJson,
2032
+ service: schedule.service ?? "scheduler",
2033
+ actorKind: schedule.actorKind,
2034
+ actorId: schedule.actorId
2035
+ }, claims));
2036
+ }
2037
+ catch (error) {
2038
+ const retryAt = new Date(Date.parse(wallNow) + 10_000).toISOString();
2039
+ this.mutateSync(() => {
2040
+ this.ctx.storage.sql.exec(`UPDATE "_job_schedules" SET "dispatchLeaseExpiresAt" = ?, "lastDispatchError" = ?, "updatedAt" = ? WHERE "id" = ? AND "generation" = ?`, retryAt, error instanceof Error ? error.message : String(error), appNow, schedule.id, schedule.generation);
2041
+ });
2042
+ await this.scheduleRealAlarm(Date.parse(retryAt));
2043
+ }
2044
+ }
2045
+ const state = await this.schedulerState();
2046
+ if (state.nextJobScheduleAt)
2047
+ await this.scheduleVirtualAlarm(state.nextJobScheduleAt);
2048
+ }
2049
+ async runAgentTimerAlarm(appNow, wallNow) {
2050
+ const hasWakes = this.tableExists("_agent_wakes");
2051
+ const hasSchedules = this.tableExists("_agent_schedules");
2052
+ if (!hasWakes && !hasSchedules)
2053
+ return;
2054
+ const addOptionalColumn = (table, column, ddl) => {
2055
+ if (this.tableExists(table) && !this.columnExists(table, column)) {
2056
+ this.ctx.storage.sql.exec(`ALTER TABLE ${sqlIdentifier(table)} ADD COLUMN ${ddl}`);
2057
+ }
2058
+ };
2059
+ for (const table of ["_agent_wakes", "_agent_schedules"]) {
2060
+ addOptionalColumn(table, "dispatchGeneration", '"dispatchGeneration" INTEGER');
2061
+ addOptionalColumn(table, "dispatchAttempts", '"dispatchAttempts" INTEGER');
2062
+ addOptionalColumn(table, "dispatchLeaseExpiresAt", '"dispatchLeaseExpiresAt" TEXT');
2063
+ addOptionalColumn(table, "lastDispatchAt", '"lastDispatchAt" TEXT');
2064
+ addOptionalColumn(table, "lastDispatchError", '"lastDispatchError" TEXT');
2065
+ }
2066
+ const control = this.agentFollowUpExecutor();
2067
+ const queue = this.env.RAYBASE_JOBS;
2068
+ const claims = (await this.ctx.storage.get(agentAlarmClaimsKey)) ?? {};
2069
+ const leaseExpiresAt = new Date(Date.parse(wallNow) + 60_000).toISOString();
2070
+ const wakes = hasWakes ? await claimDueAgentWakes(control, { now: appNow, wallNow, leaseExpiresAt, limit: 100 }) : [];
2071
+ const schedules = hasSchedules ? await claimDueAgentSchedules(control, { now: appNow, wallNow, leaseExpiresAt, limit: 100 }) : [];
2072
+ let dispatchFailed = false;
2073
+ for (const wake of wakes) {
2074
+ try {
2075
+ if (!queue)
2076
+ throw new Error("RAYBASE_JOBS queue binding is missing");
2077
+ await queue.send(mergeRaybaseRuntimeClaims({
2078
+ kind: "raybase.agent.wake",
2079
+ version: 1,
2080
+ wakeId: wake.id,
2081
+ agentName: wake.agentName,
2082
+ generation: wake.generation,
2083
+ runAt: wake.runAt,
2084
+ claimedBy: "alarm",
2085
+ claimedAt: wallNow
2086
+ }, claims));
2087
+ }
2088
+ catch (error) {
2089
+ dispatchFailed = true;
2090
+ this.ctx.storage.sql.exec(`UPDATE "_agent_wakes" SET "dispatchLeaseExpiresAt" = NULL, "lastDispatchError" = ?, "updatedAt" = ?
2091
+ WHERE "id" = ? AND COALESCE("dispatchGeneration", 1) = ?`, error instanceof Error ? error.message : String(error), appNow, wake.id, wake.generation);
2092
+ }
2093
+ }
2094
+ for (const schedule of schedules) {
2095
+ try {
2096
+ if (!queue)
2097
+ throw new Error("RAYBASE_JOBS queue binding is missing");
2098
+ await queue.send(mergeRaybaseRuntimeClaims({
2099
+ kind: "raybase.agent.schedule",
2100
+ version: 1,
2101
+ scheduleId: schedule.id,
2102
+ agentName: schedule.agentName,
2103
+ generation: schedule.generation,
2104
+ bookedAt: schedule.bookedAt,
2105
+ claimedBy: "alarm",
2106
+ claimedAt: wallNow
2107
+ }, claims));
2108
+ }
2109
+ catch (error) {
2110
+ dispatchFailed = true;
2111
+ this.ctx.storage.sql.exec(`UPDATE "_agent_schedules" SET "dispatchLeaseExpiresAt" = NULL, "lastDispatchError" = ?, "updatedAt" = ?
2112
+ WHERE "id" = ? AND "nextRunAt" = ? AND COALESCE("dispatchGeneration", 1) = ?`, error instanceof Error ? error.message : String(error), appNow, schedule.id, schedule.bookedAt, schedule.generation);
2113
+ }
2114
+ }
2115
+ const virtualTargets = [];
2116
+ const realTargets = [];
2117
+ if (hasWakes) {
2118
+ const row = this.ctx.storage.sql.exec(`SELECT MIN(CASE WHEN "dispatchLeaseExpiresAt" IS NULL THEN "runAt" END) AS "virtualTarget",
2119
+ MIN("dispatchLeaseExpiresAt") AS "realTarget" FROM "_agent_wakes" WHERE "status" = 'pending'`).toArray()[0];
2120
+ if (row?.virtualTarget)
2121
+ virtualTargets.push(row.virtualTarget);
2122
+ if (row?.realTarget)
2123
+ realTargets.push(row.realTarget);
2124
+ }
2125
+ if (hasSchedules) {
2126
+ const row = this.ctx.storage.sql.exec(`SELECT MIN(CASE WHEN "dispatchLeaseExpiresAt" IS NULL THEN "nextRunAt" END) AS "virtualTarget",
2127
+ MIN("dispatchLeaseExpiresAt") AS "realTarget" FROM "_agent_schedules"
2128
+ WHERE (("enabledOverride" IS NULL AND "enabled" = 1) OR "enabledOverride" = 1) AND "nextRunAt" IS NOT NULL`).toArray()[0];
2129
+ if (row?.virtualTarget)
2130
+ virtualTargets.push(row.virtualTarget);
2131
+ if (row?.realTarget)
2132
+ realTargets.push(row.realTarget);
2133
+ }
2134
+ if (virtualTargets.length)
2135
+ await this.scheduleVirtualAlarm(virtualTargets.sort()[0]);
2136
+ if (realTargets.length)
2137
+ await this.scheduleRealAlarm(Date.parse(realTargets.sort()[0]));
2138
+ if (dispatchFailed)
2139
+ await this.scheduleRealAlarm(Date.parse(wallNow) + 10_000);
2140
+ }
2141
+ async runAgentSkillSyncAlarm(now) {
2142
+ if (!this.tableExists("_agent_skill_sources"))
2143
+ return;
2144
+ try {
2145
+ const result = await syncDueAgentSkillSources({ control: this.agentFollowUpExecutor(), fetcher: fetch, env: this.env, now, limit: 5 });
2146
+ if (result.nextDueAt)
2147
+ await this.scheduleRealAlarm(Date.parse(result.nextDueAt));
2148
+ if (result.failed.length)
2149
+ console.warn("Raybase Agent Skill sync completed with failures.", result.failed.map(({ sourceId }) => sourceId));
2150
+ }
2151
+ catch (error) {
2152
+ console.warn("Raybase Agent Skill alarm sync failed.", error);
2153
+ await this.scheduleRealAlarm(wallNowMs() + 5 * 60 * 1000);
2154
+ }
2155
+ }
2156
+ findIdempotentJobRunSync(jobName, idempotencyKey) {
2157
+ return rowToJobRun(this.ctx.storage.sql
2158
+ .exec(`SELECT * FROM "_job_runs" WHERE "jobName" = ? AND "idempotencyKey" = ?`, jobName, idempotencyKey)
2159
+ .toArray()[0]);
2160
+ }
2161
+ insertJobRunSync(run) {
2162
+ this.ctx.storage.sql.exec(`INSERT INTO "_job_runs"
2163
+ ("id", "jobName", "status", "inputJson", "inputHash", "idempotencyKey", "actorKind", "actorId", "runAsKind", "runAsId",
2164
+ "attempt", "maxAttempts", "queueMessageId", "workflowInstanceId", "leaseOwner", "leaseExpiresAt", "createdAt", "availableAt",
2165
+ "startedAt", "endedAt", "expiresAt", "progressJson", "resultJson", "errorJson", "artifactJson", "createdBy", "retryOfRunId",
2166
+ "scheduleKey", "scheduleGeneration", "scheduleBookedAt", "batchId", "batchSeq", "continuationOfBatchId",
2167
+ "concurrencyKey", "rateLimitKey")
2168
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, run.id, run.jobName, run.status, run.inputJson, run.inputHash ?? null, run.idempotencyKey ?? null, run.actorKind, run.actorId ?? null, run.runAsKind, run.runAsId ?? null, run.attempt ?? 0, run.maxAttempts, run.queueMessageId ?? null, run.workflowInstanceId ?? null, run.leaseOwner ?? null, run.leaseExpiresAt ?? null, run.createdAt, run.availableAt, run.startedAt ?? null, run.endedAt ?? null, run.expiresAt ?? null, run.progressJson ?? null, run.resultJson ?? null, run.errorJson ?? null, run.artifactJson ?? null, run.createdBy ?? null, run.retryOfRunId ?? null, run.scheduleKey ?? null, run.scheduleGeneration ?? null, run.scheduleBookedAt ?? null, run.batchId ?? null, run.batchSeq ?? null, run.continuationOfBatchId ?? null, run.concurrencyKey ?? null, run.rateLimitKey ?? null);
2169
+ const inserted = this.getJobRun(run.id);
2170
+ if (!inserted)
2171
+ throw new Error("Failed to create job run");
2172
+ return inserted;
2173
+ }
2174
+ async createJobRun(input) {
2175
+ await this.ensureJobsSchema();
2176
+ let result = undefined;
2177
+ this.mutateSync(() => {
2178
+ const run = input.run;
2179
+ if (run.idempotencyKey) {
2180
+ const existing = this.findIdempotentJobRunSync(run.jobName, run.idempotencyKey);
2181
+ if (existing) {
2182
+ result = { created: false, run: existing };
2183
+ return;
2184
+ }
2185
+ }
2186
+ result = { created: true, run: this.insertJobRunSync(run) };
2187
+ });
2188
+ await this.scheduleRealAlarm();
2189
+ return result;
2190
+ }
2191
+ getJobBatchRowSync(batchId) {
2192
+ return this.ctx.storage.sql.exec(`SELECT * FROM "_job_batches" WHERE "id" = ?`, batchId).toArray()[0];
2193
+ }
2194
+ requireJobBatchSync(batchId) {
2195
+ const row = this.getJobBatchRowSync(batchId);
2196
+ if (!row)
2197
+ throw new Error(`Job batch not found: ${batchId}`);
2198
+ return rowToJobBatch(row);
2199
+ }
2200
+ /**
2201
+ * Records that `run` just became terminal. Callers invoke this inside the
2202
+ * same transaction as the status UPDATE, and only when that UPDATE changed
2203
+ * the row, so each child is counted exactly once however many workers,
2204
+ * redeliveries, or lease sweeps race on it. Returns true when the batch
2205
+ * completed and its continuation run was created.
2206
+ */
2207
+ settleJobRunSync(run, status, now) {
2208
+ let completed = false;
2209
+ if (run.batchId) {
2210
+ const column = batchCountColumns[status];
2211
+ if (!column)
2212
+ throw new Error(`Job run status ${status} is not terminal`);
2213
+ this.ctx.storage.sql.exec(`UPDATE "_job_batches"
2214
+ SET "pending" = "pending" - 1, ${sqlIdentifier(column)} = ${sqlIdentifier(column)} + 1, "updatedAt" = ?
2215
+ WHERE "id" = ? AND "pending" > 0`, now, run.batchId);
2216
+ if (sqliteChanges(this.ctx.storage) > 0)
2217
+ completed = this.completeJobBatchIfDoneSync(run.batchId, now);
2218
+ }
2219
+ if (run.continuationOfBatchId) {
2220
+ this.ctx.storage.sql.exec(`UPDATE "_job_batches" SET "finishedAt" = ?, "expiresAt" = ?, "updatedAt" = ? WHERE "id" = ? AND "finishedAt" IS NULL`, now, run.expiresAt, now, run.continuationOfBatchId);
2221
+ }
2222
+ return completed;
2223
+ }
2224
+ /**
2225
+ * Fires the batch's one continuation when it is sealed and has no pending
2226
+ * children. The status guard (`sealed` → `completed`) and the continuation
2227
+ * idempotency key make a second call a no-op.
2228
+ */
2229
+ completeJobBatchIfDoneSync(batchId, now) {
2230
+ const row = this.getJobBatchRowSync(batchId);
2231
+ if (!row || row.status !== "sealed" || Number(row.pending) > 0)
2232
+ return false;
2233
+ const batch = rowToJobBatch(row);
2234
+ const { counts } = batch;
2235
+ const unsuccessful = Boolean(batch.cancelledAt) || counts.failed + counts.cancelled + counts.denied + counts.expired > 0;
2236
+ const template = JSON.parse(String(unsuccessful && row.onFailureJson ? row.onFailureJson : row.onCompleteJson));
2237
+ const idempotencyKey = batchContinuationIdempotencyKey(batch.id);
2238
+ const continuation = this.findIdempotentJobRunSync(template.jobName, idempotencyKey) ?? this.insertJobRunSync({
2239
+ id: randomId("jobrun"),
2240
+ jobName: template.jobName,
2241
+ status: "queued",
2242
+ inputJson: template.inputJson,
2243
+ inputHash: template.inputHash,
2244
+ idempotencyKey,
2245
+ actorKind: batch.actorKind,
2246
+ actorId: batch.actorId,
2247
+ runAsKind: template.runAsKind,
2248
+ runAsId: template.runAsId,
2249
+ maxAttempts: template.maxAttempts,
2250
+ createdAt: now,
2251
+ availableAt: wallNowIso(),
2252
+ expiresAt: null,
2253
+ progressJson: null,
2254
+ artifactJson: null,
2255
+ createdBy: batch.createdBy,
2256
+ retryOfRunId: null,
2257
+ scheduleKey: null,
2258
+ scheduleGeneration: null,
2259
+ scheduleBookedAt: null,
2260
+ continuationOfBatchId: batch.id,
2261
+ concurrencyKey: template.concurrencyKey ?? null,
2262
+ rateLimitKey: template.rateLimitKey ?? null
2263
+ });
2264
+ this.ctx.storage.sql.exec(`UPDATE "_job_batches"
2265
+ SET "status" = 'completed', "completedAt" = ?, "continuationRunId" = ?, "continuationJob" = ?,
2266
+ "continuationDispatchedAt" = NULL, "updatedAt" = ?
2267
+ WHERE "id" = ? AND "status" = 'sealed'`, now, continuation.id, continuation.jobName, now, batch.id);
2268
+ this.ctx.storage.sql.exec(`INSERT INTO "_job_events" ("id", "runId", "type", "level", "message", "dataJson", "createdAt")
2269
+ VALUES (?, ?, 'batch', 'info', ?, ?, ?)`, `jobevt_${crypto.randomUUID().replace(/-/g, "")}`, continuation.id, `Batch ${batch.name} completed`, JSON.stringify({ batchId: batch.id, key: batch.key, counts, cancelled: Boolean(batch.cancelledAt) }), now);
2270
+ return true;
2271
+ }
2272
+ /**
2273
+ * Sends queue messages for continuations that are not yet dispatched. The
2274
+ * continuation row is committed before this runs, so a crash between commit
2275
+ * and send leaves an undispatched batch that the alarm retries; a second
2276
+ * send is harmless because claiming a run is idempotent.
2277
+ */
2278
+ async dispatchJobBatchContinuations() {
2279
+ if (!this.tableExists("_job_batches"))
2280
+ return;
2281
+ const pending = this.ctx.storage.sql.exec(`SELECT "id", "continuationRunId", "continuationJob" FROM "_job_batches"
2282
+ WHERE "status" = 'completed' AND "continuationDispatchedAt" IS NULL AND "continuationRunId" IS NOT NULL
2283
+ ORDER BY "completedAt" ASC LIMIT 50`).toArray();
2284
+ if (!pending.length)
2285
+ return;
2286
+ await this.scheduleRealAlarm(wallNowMs() + 30_000);
2287
+ const queue = this.env.RAYBASE_JOBS;
2288
+ const claims = (await this.ctx.storage.get(agentAlarmClaimsKey)) ?? {};
2289
+ let failed = false;
2290
+ for (const batch of pending) {
2291
+ try {
2292
+ if (!queue)
2293
+ throw new Error("RAYBASE_JOBS queue binding is missing");
2294
+ await queue.send(mergeRaybaseRuntimeClaims({
2295
+ kind: "raybase.job.run",
2296
+ version: 1,
2297
+ runId: batch.continuationRunId,
2298
+ jobName: batch.continuationJob
2299
+ }, claims));
2300
+ this.mutateSync(() => {
2301
+ this.ctx.storage.sql.exec(`UPDATE "_job_batches" SET "continuationDispatchedAt" = ?, "lastDispatchError" = NULL WHERE "id" = ? AND "continuationDispatchedAt" IS NULL`, wallNowIso(), batch.id);
2302
+ });
2303
+ }
2304
+ catch (error) {
2305
+ failed = true;
2306
+ this.mutateSync(() => {
2307
+ this.ctx.storage.sql.exec(`UPDATE "_job_batches" SET "lastDispatchError" = ? WHERE "id" = ?`, error instanceof Error ? error.message : String(error), batch.id);
2308
+ });
2309
+ }
2310
+ }
2311
+ if (failed)
2312
+ await this.scheduleRealAlarm(wallNowMs() + 10_000);
2313
+ }
2314
+ async createJobBatch(input) {
2315
+ await this.ensureJobsSchema();
2316
+ let result = undefined;
2317
+ const onCompleteJson = JSON.stringify(input.onComplete);
2318
+ const onFailureJson = input.onFailure ? JSON.stringify(input.onFailure) : null;
2319
+ this.mutateSync(() => {
2320
+ const existing = this.ctx.storage.sql.exec(`SELECT * FROM "_job_batches" WHERE "key" = ?`, input.key).toArray()[0];
2321
+ if (existing) {
2322
+ const sameDefinition = existing.name === input.name
2323
+ && sameContinuation(existing.onCompleteJson, onCompleteJson)
2324
+ && sameContinuation(existing.onFailureJson, onFailureJson);
2325
+ if (!sameDefinition)
2326
+ throw new Error(`Job batch key already exists with a different definition: ${input.key}`);
2327
+ result = { created: false, batch: rowToJobBatch(existing) };
2328
+ return;
2329
+ }
2330
+ this.ctx.storage.sql.exec(`INSERT INTO "_job_batches"
2331
+ ("id", "name", "key", "status", "onCompleteJson", "onFailureJson", "createdAt", "updatedAt", "createdBy", "actorKind", "actorId")
2332
+ VALUES (?, ?, ?, 'open', ?, ?, ?, ?, ?, ?, ?)`, input.id, input.name, input.key, onCompleteJson, onFailureJson, input.now, input.now, input.createdBy, input.actorKind, input.actorId);
2333
+ result = { created: true, batch: this.requireJobBatchSync(input.id) };
2334
+ });
2335
+ if (input.claims && Object.keys(input.claims).length)
2336
+ await this.ctx.storage.put(agentAlarmClaimsKey, input.claims);
2337
+ return result;
2338
+ }
2339
+ async getJobBatch(batchId) {
2340
+ await this.ensureJobsSchema();
2341
+ const row = this.getJobBatchRowSync(batchId);
2342
+ return row ? rowToJobBatch(row) : null;
2343
+ }
2344
+ async listJobBatches(input = {}) {
2345
+ await this.ensureJobsSchema();
2346
+ const clauses = [];
2347
+ const bindings = [];
2348
+ if (input.status) {
2349
+ clauses.push(`"status" = ?`);
2350
+ bindings.push(input.status);
2351
+ }
2352
+ if (input.name) {
2353
+ clauses.push(`"name" = ?`);
2354
+ bindings.push(input.name);
2355
+ }
2356
+ if (input.key) {
2357
+ clauses.push(`"key" = ?`);
2358
+ bindings.push(input.key);
2359
+ }
2360
+ const limit = Math.min(Math.max(input.limit ?? 100, 1), 500);
2361
+ const batches = this.ctx.storage.sql.exec(`SELECT * FROM "_job_batches" ${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""} ORDER BY "createdAt" DESC, "id" DESC LIMIT ?`, ...bindings, limit).toArray().map(rowToJobBatch);
2362
+ const counts = this.ctx.storage.sql.exec(`SELECT "status", COUNT(*) AS "count" FROM "_job_batches" GROUP BY "status" ORDER BY "status"`).toArray().map((row) => ({ status: row.status, count: Number(row.count) }));
2363
+ return { batches, counts };
2364
+ }
2365
+ /**
2366
+ * Adds up to 100 children in one transaction: each run, plus the enqueue
2367
+ * event and audit row of each one created. Children are numbered in input
2368
+ * order; a repeated idempotency key, in this chunk or an earlier call,
2369
+ * returns the existing child.
2370
+ */
2371
+ async createJobBatchChildren(input) {
2372
+ await this.ensureJobsSchema();
2373
+ if (input.children.length > 100)
2374
+ throw new Error("A job batch chunk holds at most 100 children");
2375
+ let result = undefined;
2376
+ this.mutateSync(() => {
2377
+ const batch = this.requireJobBatchSync(input.batchId);
2378
+ let mayAdd = batch.status === "open" ? true : null;
2379
+ const children = [];
2380
+ let created = 0;
2381
+ for (const child of input.children) {
2382
+ const run = child.run;
2383
+ if (run.idempotencyKey) {
2384
+ const existing = this.findIdempotentJobRunSync(run.jobName, run.idempotencyKey);
2385
+ if (existing) {
2386
+ if (existing.batchId !== batch.id) {
2387
+ throw new Error(`Idempotency key ${run.idempotencyKey} already belongs to job run ${existing.id} outside batch ${batch.key}`);
2388
+ }
2389
+ children.push({ created: false, run: existing });
2390
+ continue;
2391
+ }
2392
+ }
2393
+ if (mayAdd === null) {
2394
+ const caller = input.fromRunId ? this.getJobRun(input.fromRunId) : null;
2395
+ mayAdd = batch.status !== "completed" && caller?.batchId === batch.id && !terminalJobStatuses.has(caller.status);
2396
+ }
2397
+ if (!mayAdd) {
2398
+ throw new Error(`Job batch ${batch.key} is ${batch.status}; only its own running children can add children after seal`);
2399
+ }
2400
+ created += 1;
2401
+ const inserted = this.insertJobRunSync({ ...run, batchId: batch.id, batchSeq: batch.counts.total + created, continuationOfBatchId: null });
2402
+ this.ctx.storage.sql.exec(`INSERT INTO "_job_events" ("id", "runId", "type", "level", "message", "dataJson", "createdAt")
2403
+ VALUES (?, ?, 'enqueue', 'info', ?, ?, ?)`, `jobevt_${crypto.randomUUID().replace(/-/g, "")}`, inserted.id, `Queued ${inserted.jobName} in batch ${batch.name}`, child.eventDataJson, input.now);
2404
+ this.ctx.storage.sql.exec(`INSERT INTO "_audit" ("id", "actorKind", "actorId", "runAsKind", "runAsId", "action", "targetType", "targetId", "metadata")
2405
+ VALUES (?, ?, ?, ?, ?, ?, '_job_runs', ?, ?)`, randomId("audit"), child.audit.actorKind, child.audit.actorId, child.audit.runAsKind, child.audit.runAsId, child.audit.action, inserted.id, child.audit.metadata);
2406
+ children.push({ created: true, run: inserted });
2407
+ }
2408
+ if (created) {
2409
+ this.ctx.storage.sql.exec(`UPDATE "_job_batches" SET "total" = "total" + ?, "pending" = "pending" + ?, "updatedAt" = ? WHERE "id" = ?`, created, created, input.now, batch.id);
2410
+ }
2411
+ result = { children, batch: this.requireJobBatchSync(batch.id) };
2412
+ });
2413
+ await this.scheduleRealAlarm();
2414
+ return result;
2415
+ }
2416
+ async sealJobBatch(input) {
2417
+ await this.ensureJobsSchema();
2418
+ let sealed = false;
2419
+ let completed = false;
2420
+ let batch = undefined;
2421
+ this.mutateSync(() => {
2422
+ this.requireJobBatchSync(input.batchId);
2423
+ this.ctx.storage.sql.exec(`UPDATE "_job_batches" SET "status" = 'sealed', "sealedAt" = ?, "updatedAt" = ? WHERE "id" = ? AND "status" = 'open'`, input.now, input.now, input.batchId);
2424
+ sealed = sqliteChanges(this.ctx.storage) > 0;
2425
+ completed = this.completeJobBatchIfDoneSync(input.batchId, input.now);
2426
+ batch = this.requireJobBatchSync(input.batchId);
2427
+ });
2428
+ if (completed)
2429
+ await this.dispatchJobBatchContinuations();
2430
+ return { sealed, batch: completed ? (await this.getJobBatch(input.batchId)) : batch };
2431
+ }
2432
+ async cancelJobBatch(input) {
2433
+ await this.ensureJobsSchema();
2434
+ let cancelledRuns = 0;
2435
+ let completed = false;
2436
+ let wakes = [];
2437
+ this.mutateSync(() => {
2438
+ const batch = this.requireJobBatchSync(input.batchId);
2439
+ if (batch.status === "completed")
2440
+ return;
2441
+ this.ctx.storage.sql.exec(`UPDATE "_job_batches"
2442
+ SET "status" = 'sealed', "sealedAt" = COALESCE("sealedAt", ?), "cancelledAt" = ?, "cancellationReason" = ?, "updatedAt" = ?
2443
+ WHERE "id" = ?`, input.now, input.now, input.reason ?? null, input.now, batch.id);
2444
+ const errorJson = JSON.stringify({
2445
+ cancelled: true,
2446
+ reason: input.reason ?? null,
2447
+ batchId: batch.id,
2448
+ actorKind: input.actorKind ?? null,
2449
+ actorId: input.actorId ?? null
2450
+ });
2451
+ const pools = this.ctx.storage.sql.exec(`SELECT DISTINCT "jobName", "concurrencyKey" FROM "_job_runs"
2452
+ WHERE "batchId" = ? AND "status" NOT IN ('succeeded', 'failed', 'denied', 'cancelled', 'expired')`, batch.id).toArray();
2453
+ for (const jobName of new Set(pools.map((pool) => pool.jobName))) {
2454
+ this.ctx.storage.sql.exec(`UPDATE "_job_runs"
2455
+ SET "status" = 'cancelled', "endedAt" = ?, "expiresAt" = ?, "errorJson" = ?, "leaseOwner" = NULL, "leaseExpiresAt" = NULL
2456
+ WHERE "batchId" = ? AND "jobName" = ? AND "status" NOT IN ('succeeded', 'failed', 'denied', 'cancelled', 'expired')`, input.now, input.expiresAtByJob?.[jobName] ?? input.expiresAt ?? null, errorJson, batch.id, jobName);
2457
+ cancelledRuns += sqliteChanges(this.ctx.storage);
2458
+ }
2459
+ this.ctx.storage.sql.exec(`UPDATE "_job_batches" SET "pending" = "pending" - ?, "cancelled" = "cancelled" + ? WHERE "id" = ?`, cancelledRuns, cancelledRuns, batch.id);
2460
+ completed = this.completeJobBatchIfDoneSync(batch.id, input.now);
2461
+ // Running children give their slots up, so runs waiting on them in other
2462
+ // batches, or outside any batch, can start.
2463
+ wakes = this.wakeJobRunsSync(jobSlotPools(pools), wallNowIso());
2464
+ });
2465
+ await this.sendJobWakes(wakes);
2466
+ if (completed)
2467
+ await this.dispatchJobBatchContinuations();
2468
+ return { cancelledRuns, batch: (await this.getJobBatch(input.batchId)) };
2469
+ }
2470
+ async listJobBatchChildren(input) {
2471
+ await this.ensureJobsSchema();
2472
+ const limit = Math.min(Math.max(Math.trunc(input.limit ?? 50), 1), 100);
2473
+ const afterSeq = input.cursor ? Number(input.cursor) : 0;
2474
+ if (!Number.isInteger(afterSeq) || afterSeq < 0)
2475
+ throw new Error("Invalid job batch children cursor");
2476
+ const rows = this.ctx.storage.sql.exec(`SELECT * FROM "_job_runs"
2477
+ WHERE "batchId" = ? AND "batchSeq" > ?${input.status ? ` AND "status" = ?` : ""}
2478
+ ORDER BY "batchSeq" ASC LIMIT ?`, ...(input.status ? [input.batchId, afterSeq, input.status, limit + 1] : [input.batchId, afterSeq, limit + 1])).toArray();
2479
+ const page = rows.slice(0, limit).map((row) => rowToJobBatchChild(row, input.batchId));
2480
+ return { children: page, cursor: rows.length > limit ? String(page[page.length - 1].seq) : null };
2481
+ }
2482
+ async claimJobRun(input) {
2483
+ await this.ensureJobsSchema();
2484
+ const wallNow = input.wallNow ?? input.now;
2485
+ let result = { status: "missing" };
2486
+ let wakes = [];
2487
+ this.mutateSync(() => {
2488
+ this.syncJobClaimLimitsSync(input.jobName, input.limits, wallNow);
2489
+ // Counting slots in use and taking one happen in this one synchronous
2490
+ // block, so concurrent claims cannot both see the last free slot.
2491
+ const gate = input.limits ? this.jobClaimGateSync(input, input.limits, wallNow) : null;
2492
+ if (gate?.deferred) {
2493
+ result = gate.deferred.result;
2494
+ wakes = gate.deferred.wakes;
2495
+ return;
2496
+ }
2497
+ this.ctx.storage.sql.exec(`UPDATE "_job_runs"
2498
+ SET "status" = 'running',
2499
+ "leaseOwner" = ?,
2500
+ "leaseExpiresAt" = ?,
2501
+ "startedAt" = COALESCE("startedAt", ?),
2502
+ "attempt" = "attempt" + 1,
2503
+ "deferredReason" = NULL,
2504
+ "wokenAt" = NULL
2505
+ WHERE "id" = ?
2506
+ AND "jobName" = ?
2507
+ AND "status" IN ('queued', 'leased')
2508
+ AND "availableAt" <= ?
2509
+ AND ("leaseExpiresAt" IS NULL OR "leaseExpiresAt" <= ?)`, input.leaseOwner, input.leaseExpiresAt, input.now, input.runId, input.jobName, wallNow, wallNow);
2510
+ if (sqliteChanges(this.ctx.storage) > 0) {
2511
+ if (gate?.rateBucket)
2512
+ this.takeJobRateTokenSync(gate.rateBucket, wallNow);
2513
+ const claimed = this.getJobRun(input.runId);
2514
+ if (!claimed)
2515
+ throw new Error("Claimed job run disappeared");
2516
+ result = { status: "claimed", run: claimed };
2517
+ return;
2518
+ }
2519
+ const current = this.getJobRun(input.runId);
2520
+ if (!current) {
2521
+ result = { status: "missing" };
2522
+ }
2523
+ else if (terminalJobStatuses.has(current.status)) {
2524
+ result = { status: "terminal", run: current };
2525
+ }
2526
+ else if (current.status === "queued" && current.availableAt > wallNow) {
2527
+ result = { status: "not-due", run: current };
2528
+ }
2529
+ else if (current.status === "cancelled") {
2530
+ result = { status: "cancelled", run: current };
2531
+ }
2532
+ else {
2533
+ result = { status: "leased", run: current };
2534
+ }
2535
+ });
2536
+ const finalized = result;
2537
+ if (finalized.status !== "deferred") {
2538
+ await this.scheduleRealAlarm(Date.parse(input.leaseExpiresAt));
2539
+ }
2540
+ else if (finalized.deferral.parked) {
2541
+ await this.storeAlarmClaims(input.claims);
2542
+ await this.scheduleRealAlarm(wallNowMs() + jobWakeSweepMs);
2543
+ }
2544
+ await this.sendJobWakes(wakes);
2545
+ return finalized;
2546
+ }
2547
+ /**
2548
+ * Keeps the limits each job last claimed with, so that when a slot frees the
2549
+ * Durable Object can tell which parked runs a claim would now admit.
2550
+ */
2551
+ syncJobClaimLimitsSync(jobName, limits, wallNow) {
2552
+ const max = limits?.max ?? null;
2553
+ const perKeyMax = limits?.perKeyMax ?? null;
2554
+ const stored = this.ctx.storage.sql
2555
+ .exec(`SELECT "max", "perKeyMax" FROM "_job_claim_limits" WHERE "jobName" = ?`, jobName)
2556
+ .toArray()[0];
2557
+ if (max === null && perKeyMax === null) {
2558
+ if (stored)
2559
+ this.ctx.storage.sql.exec(`DELETE FROM "_job_claim_limits" WHERE "jobName" = ?`, jobName);
2560
+ return;
2561
+ }
2562
+ if (stored && stored.max === max && stored.perKeyMax === perKeyMax)
2563
+ return;
2564
+ this.ctx.storage.sql.exec(`INSERT INTO "_job_claim_limits" ("jobName", "max", "perKeyMax", "updatedAt") VALUES (?, ?, ?, ?)
2565
+ ON CONFLICT("jobName") DO UPDATE SET "max" = excluded."max", "perKeyMax" = excluded."perKeyMax", "updatedAt" = excluded."updatedAt"`, jobName, max, perKeyMax, wallNow);
2566
+ }
2567
+ /**
2568
+ * Slots of one pool in use: runs holding a live lease, plus parked runs
2569
+ * woken in the last 30 seconds, which hold the slot that woke them while
2570
+ * their message is in flight. A run whose worker died stops counting when
2571
+ * its lease expires, before stale-lease compaction fails it.
2572
+ */
2573
+ jobSlotsInUseSync(column, value, wallNow, exceptRunId = null) {
2574
+ const reservedSince = new Date(Date.parse(wallNow) - jobWakeReservationMs).toISOString();
2575
+ return Number(this.ctx.storage.sql.exec(`SELECT COUNT(*) AS "count" FROM "_job_runs"
2576
+ WHERE ${sqlIdentifier(column)} = ?
2577
+ AND (("status" IN ('running', 'leased') AND "leaseExpiresAt" > ?)
2578
+ OR ("status" = 'queued' AND "wokenAt" > ? AND "id" != ?))`, value, wallNow, reservedSince, exceptRunId ?? "").toArray()[0]?.count ?? 0);
2579
+ }
2580
+ /** Decides whether a claim that would otherwise succeed must wait on a full limit. */
2581
+ jobClaimGateSync(input, limits, wallNow) {
2582
+ const run = this.getJobRun(input.runId);
2583
+ if (!run || run.jobName !== input.jobName || (run.status !== "queued" && run.status !== "leased"))
2584
+ return null;
2585
+ if (run.availableAt > wallNow || (run.leaseExpiresAt !== null && run.leaseExpiresAt > wallNow))
2586
+ return null;
2587
+ let deferral = null;
2588
+ if (limits.max !== undefined) {
2589
+ const count = this.jobSlotsInUseSync("jobName", run.jobName, wallNow, run.id);
2590
+ if (count >= limits.max)
2591
+ deferral = { reason: "concurrency", limit: limits.max, inFlight: count, key: null };
2592
+ }
2593
+ if (!deferral && limits.perKeyMax !== undefined && run.concurrencyKey !== null) {
2594
+ const count = this.jobSlotsInUseSync("concurrencyKey", run.concurrencyKey, wallNow, run.id);
2595
+ if (count >= limits.perKeyMax)
2596
+ deferral = { reason: "concurrency-key", limit: limits.perKeyMax, inFlight: count, key: run.concurrencyKey };
2597
+ }
2598
+ let rateBucket;
2599
+ if (!deferral && limits.rateLimit) {
2600
+ const { perSecond, burst, keyed } = limits.rateLimit;
2601
+ const key = keyed ? run.rateLimitKey : null;
2602
+ const id = key === null ? `job:${run.jobName}` : `key:${key}`;
2603
+ const tokens = this.jobRateTokensSync(id, perSecond, burst, wallNow);
2604
+ if (tokens < 1)
2605
+ deferral = { reason: "rate-limit", limit: burst, key, retryAfterMs: Math.ceil(((1 - tokens) / perSecond) * 1000) };
2606
+ else
2607
+ rateBucket = { id, tokens, perSecond, burst };
2608
+ }
2609
+ if (!deferral)
2610
+ return { rateBucket };
2611
+ return { deferred: this.deferJobRunSync(run, deferral, input.now, wallNow) };
2612
+ }
2613
+ /**
2614
+ * A run deferred on a concurrency slot is parked: the Durable Object wakes
2615
+ * it when a slot in its pools frees, so the consumer drops its message
2616
+ * instead of polling. A rate-limit wait is a known delay, so that message
2617
+ * is redelivered after it. Without a queue binding to wake from, every
2618
+ * deferral falls back to redelivery with backoff.
2619
+ */
2620
+ deferJobRunSync(run, deferral, now, wallNow) {
2621
+ const deferrals = run.deferrals + 1;
2622
+ const parked = deferral.reason !== "rate-limit" && Boolean(this.jobsQueue());
2623
+ this.ctx.storage.sql.exec(`UPDATE "_job_runs" SET "deferrals" = ?, "deferredAt" = ?, "deferredReason" = ?, "wokenAt" = NULL WHERE "id" = ?`, deferrals, now, deferral.reason, run.id);
2624
+ // A backlog can defer one run many times; record the wait once, when it
2625
+ // starts or changes reason, rather than once per redelivery or wake.
2626
+ if (run.deferredReason !== deferral.reason) {
2627
+ this.ctx.storage.sql.exec(`INSERT INTO "_job_events" ("id", "runId", "type", "level", "message", "dataJson", "createdAt")
2628
+ VALUES (?, ?, 'deferred', 'info', ?, ?, ?)`, `jobevt_${crypto.randomUUID().replace(/-/g, "")}`, run.id, jobDeferralMessage(run.jobName, deferral), JSON.stringify({ ...deferral, deferrals }), now);
2629
+ }
2630
+ // A woken run that waits again gives back the slot it was holding.
2631
+ const wakes = run.wokenAt ? this.wakeJobRunsSync(jobSlotPools([run]), wallNow) : [];
2632
+ const updated = this.getJobRun(run.id);
2633
+ if (!updated)
2634
+ throw new Error("Deferred job run disappeared");
2635
+ return { result: { status: "deferred", run: updated, deferral: { ...deferral, deferrals, parked } }, wakes };
2636
+ }
2637
+ /**
2638
+ * Picks the parked runs a claim would now admit, oldest first, and marks
2639
+ * them woken so they hold their slots until their message arrives. Scoped
2640
+ * to the pools a released run held, or every parked run for the alarm's
2641
+ * sweep. Each candidate is checked against its own job's last claimed
2642
+ * limits; the claim gate still has the final say.
2643
+ */
2644
+ wakeJobRunsSync(requested, wallNow, max = 100) {
2645
+ // Without a queue binding nothing is parked: waiting runs redeliver themselves.
2646
+ if (!this.jobsQueue())
2647
+ return [];
2648
+ if (requested && !requested.jobNames.length && !requested.keys.length)
2649
+ return [];
2650
+ // A statement binds at most 100 values; many pools at once (a batch
2651
+ // cancelled across hundreds of sites) take the unscoped sweep instead.
2652
+ const scope = requested && requested.jobNames.length + requested.keys.length <= 90 ? requested : null;
2653
+ const reservedSince = new Date(Date.parse(wallNow) - jobWakeReservationMs).toISOString();
2654
+ const bindings = [wallNow, reservedSince];
2655
+ let poolSql = "";
2656
+ if (scope) {
2657
+ const clauses = [];
2658
+ if (scope.jobNames.length) {
2659
+ clauses.push(`"jobName" IN (${scope.jobNames.map(() => "?").join(", ")})`);
2660
+ bindings.push(...scope.jobNames);
2661
+ }
2662
+ if (scope.keys.length) {
2663
+ clauses.push(`"concurrencyKey" IN (${scope.keys.map(() => "?").join(", ")})`);
2664
+ bindings.push(...scope.keys);
2665
+ }
2666
+ poolSql = ` AND (${clauses.join(" OR ")})`;
2667
+ }
2668
+ const candidates = this.ctx.storage.sql.exec(`SELECT "id", "jobName", "concurrencyKey" FROM "_job_runs"
2669
+ WHERE "status" = 'queued' AND "deferredReason" IN ${parkedDeferralReasons}
2670
+ AND "availableAt" <= ? AND ("wokenAt" IS NULL OR "wokenAt" <= ?)${poolSql}
2671
+ ORDER BY "createdAt" ASC, rowid ASC
2672
+ LIMIT ?`, ...bindings, scope ? 200 : 1000).toArray();
2673
+ if (!candidates.length)
2674
+ return [];
2675
+ const limitsByJob = new Map();
2676
+ const inUse = new Map();
2677
+ const poolKey = (column, value) => `${column}:${value}`;
2678
+ const slotsInUse = (column, value) => {
2679
+ const key = poolKey(column, value);
2680
+ if (!inUse.has(key))
2681
+ inUse.set(key, this.jobSlotsInUseSync(column, value, wallNow));
2682
+ return inUse.get(key);
2683
+ };
2684
+ const woken = [];
2685
+ for (const candidate of candidates) {
2686
+ if (!limitsByJob.has(candidate.jobName)) {
2687
+ limitsByJob.set(candidate.jobName, this.ctx.storage.sql
2688
+ .exec(`SELECT "max", "perKeyMax" FROM "_job_claim_limits" WHERE "jobName" = ?`, candidate.jobName)
2689
+ .toArray()[0] ?? null);
2690
+ }
2691
+ const limits = limitsByJob.get(candidate.jobName);
2692
+ if (limits?.max != null && slotsInUse("jobName", candidate.jobName) >= Number(limits.max))
2693
+ continue;
2694
+ if (limits?.perKeyMax != null && candidate.concurrencyKey !== null && slotsInUse("concurrencyKey", candidate.concurrencyKey) >= Number(limits.perKeyMax))
2695
+ continue;
2696
+ this.ctx.storage.sql.exec(`UPDATE "_job_runs" SET "wokenAt" = ? WHERE "id" = ?`, wallNow, candidate.id);
2697
+ // The woken run now holds a slot in each pool it belongs to, whichever
2698
+ // job's limit counts that pool.
2699
+ for (const key of [poolKey("jobName", candidate.jobName), candidate.concurrencyKey === null ? null : poolKey("concurrencyKey", candidate.concurrencyKey)]) {
2700
+ if (key && inUse.has(key))
2701
+ inUse.set(key, inUse.get(key) + 1);
2702
+ }
2703
+ woken.push({ id: candidate.id, jobName: candidate.jobName });
2704
+ if (woken.length >= max)
2705
+ break;
2706
+ }
2707
+ return woken;
2708
+ }
2709
+ /**
2710
+ * Sends the wake messages chosen inside a committed transaction. A failed
2711
+ * send gives the unsent runs' slots back and arms the alarm, whose sweep
2712
+ * wakes them again; a duplicate wake is harmless because claiming is
2713
+ * idempotent.
2714
+ */
2715
+ async sendJobWakes(wakes) {
2716
+ if (!wakes.length)
2717
+ return;
2718
+ const queue = this.jobsQueue();
2719
+ const claims = (await this.ctx.storage.get(agentAlarmClaimsKey)) ?? {};
2720
+ let sent = 0;
2721
+ try {
2722
+ if (!queue)
2723
+ throw new Error("RAYBASE_JOBS queue binding is missing");
2724
+ for (; sent < wakes.length; sent += 100) {
2725
+ await queue.sendBatch(wakes.slice(sent, sent + 100).map((wake) => ({
2726
+ body: mergeRaybaseRuntimeClaims({ kind: "raybase.job.run", version: 1, runId: wake.id, jobName: wake.jobName }, claims)
2727
+ })));
2728
+ }
2729
+ }
2730
+ catch (error) {
2731
+ console.warn("Raybase could not send job wake messages; the alarm will wake those runs again.", error);
2732
+ const unsent = wakes.slice(sent).map((wake) => wake.id);
2733
+ this.mutateSync(() => {
2734
+ for (let offset = 0; offset < unsent.length; offset += 90) {
2735
+ const ids = unsent.slice(offset, offset + 90);
2736
+ this.ctx.storage.sql.exec(`UPDATE "_job_runs" SET "wokenAt" = NULL WHERE "status" = 'queued' AND "id" IN (${ids.map(() => "?").join(", ")})`, ...ids);
2737
+ }
2738
+ });
2739
+ await this.scheduleRealAlarm(wallNowMs() + jobWakeRetryMs);
2740
+ }
2741
+ }
2742
+ /** The alarm's backstop: wakes parked runs that a lost, failed, or never-sent wake left waiting. */
2743
+ async sweepParkedJobRuns() {
2744
+ if (!this.jobsQueue() || !this.tableExists("_job_runs") || !this.columnExists("_job_runs", "wokenAt"))
2745
+ return;
2746
+ let wakes = [];
2747
+ this.mutateSync(() => {
2748
+ wakes = this.wakeJobRunsSync(null, wallNowIso());
2749
+ });
2750
+ await this.sendJobWakes(wakes);
2751
+ const parked = this.ctx.storage.sql.exec(`SELECT 1 FROM "_job_runs" WHERE "status" = 'queued' AND "deferredReason" IN ${parkedDeferralReasons} LIMIT 1`).toArray();
2752
+ if (parked.length)
2753
+ await this.scheduleRealAlarm(wallNowMs() + jobWakeSweepMs);
2754
+ }
2755
+ /**
2756
+ * A parked run of a job this deployment no longer registers stops waiting
2757
+ * for a wake, so it cannot keep taking the slots of jobs that share its key.
2758
+ */
2759
+ async unparkJobRun(input) {
2760
+ await this.ensureJobsSchema();
2761
+ let unparked = false;
2762
+ let wakes = [];
2763
+ this.mutateSync(() => {
2764
+ const run = this.getJobRun(input.runId);
2765
+ if (!run || run.status !== "queued" || (run.deferredReason !== "concurrency" && run.deferredReason !== "concurrency-key"))
2766
+ return;
2767
+ this.ctx.storage.sql.exec(`UPDATE "_job_runs" SET "deferredReason" = NULL, "wokenAt" = NULL WHERE "id" = ?`, run.id);
2768
+ this.ctx.storage.sql.exec(`INSERT INTO "_job_events" ("id", "runId", "type", "level", "message", "dataJson", "createdAt")
2769
+ VALUES (?, ?, 'unparked', 'warn', ?, NULL, ?)`, `jobevt_${crypto.randomUUID().replace(/-/g, "")}`, run.id, `Job ${run.jobName} is not registered, so this run no longer waits for a concurrency slot`, input.now);
2770
+ unparked = true;
2771
+ if (run.wokenAt)
2772
+ wakes = this.wakeJobRunsSync(jobSlotPools([run]), wallNowIso());
2773
+ });
2774
+ await this.sendJobWakes(wakes);
2775
+ return { unparked };
2776
+ }
2777
+ jobsQueue() {
2778
+ return this.env.RAYBASE_JOBS;
2779
+ }
2780
+ async storeAlarmClaims(claims) {
2781
+ if (!claims || !Object.keys(claims).length)
2782
+ return;
2783
+ const stored = await this.ctx.storage.get(agentAlarmClaimsKey);
2784
+ if (JSON.stringify(stored ?? null) !== JSON.stringify(claims))
2785
+ await this.ctx.storage.put(agentAlarmClaimsKey, claims);
2786
+ }
2787
+ jobRateTokensSync(id, perSecond, burst, wallNow) {
2788
+ const row = this.ctx.storage.sql
2789
+ .exec(`SELECT "tokens", "refilledAt" FROM "_job_rate_buckets" WHERE "key" = ?`, id)
2790
+ .toArray()[0];
2791
+ if (!row)
2792
+ return burst;
2793
+ const elapsedSeconds = Math.max(0, (Date.parse(wallNow) - Date.parse(String(row.refilledAt))) / 1000);
2794
+ return Math.min(burst, Number(row.tokens) + elapsedSeconds * perSecond);
2795
+ }
2796
+ takeJobRateTokenSync(bucket, wallNow) {
2797
+ const tokens = bucket.tokens - 1;
2798
+ // A bucket past fullAt reads the same as no row, so compaction may drop it.
2799
+ const fullAt = new Date(Date.parse(wallNow) + ((bucket.burst - tokens) / bucket.perSecond) * 1000).toISOString();
2800
+ this.ctx.storage.sql.exec(`INSERT INTO "_job_rate_buckets" ("key", "tokens", "refilledAt", "fullAt") VALUES (?, ?, ?, ?)
2801
+ ON CONFLICT("key") DO UPDATE SET "tokens" = excluded."tokens", "refilledAt" = excluded."refilledAt", "fullAt" = excluded."fullAt"`, bucket.id, tokens, wallNow, fullAt);
2802
+ }
2803
+ async completeJobRun(input) {
2804
+ await this.ensureJobsSchema();
2805
+ let batchCompleted = false;
2806
+ let wakes = [];
2807
+ this.mutateSync(() => {
2808
+ this.ctx.storage.sql.exec(`UPDATE "_job_runs"
2809
+ SET "status" = 'succeeded',
2810
+ "endedAt" = ?,
2811
+ "expiresAt" = ?,
2812
+ "resultJson" = ?,
2813
+ "artifactJson" = COALESCE(?, "artifactJson"),
2814
+ "leaseOwner" = NULL,
2815
+ "leaseExpiresAt" = NULL
2816
+ WHERE "id" = ? AND "status" = 'running' AND "leaseOwner" = ?`, input.now, input.expiresAt, input.resultJson, input.artifactJson ?? null, input.runId, input.leaseOwner);
2817
+ if (sqliteChanges(this.ctx.storage) === 0)
2818
+ throw new Error("Job run was not completed because its lease is stale or it is no longer running");
2819
+ const run = this.getJobRun(input.runId);
2820
+ batchCompleted = this.settleJobRunSync(run, "succeeded", input.now);
2821
+ wakes = this.wakeJobRunsSync(jobSlotPools([run]), wallNowIso());
2822
+ });
2823
+ await this.sendJobWakes(wakes);
2824
+ if (batchCompleted)
2825
+ await this.dispatchJobBatchContinuations();
2826
+ await this.scheduleVirtualAlarm(input.expiresAt);
2827
+ return { ok: true };
2828
+ }
2829
+ async failJobRun(input) {
2830
+ await this.ensureJobsSchema();
2831
+ let batchCompleted = false;
2832
+ let wakes = [];
2833
+ this.mutateSync(() => {
2834
+ if (input.retry) {
2835
+ this.ctx.storage.sql.exec(`UPDATE "_job_runs"
2836
+ SET "status" = 'queued',
2837
+ "availableAt" = ?,
2838
+ "errorJson" = ?,
2839
+ "leaseOwner" = NULL,
2840
+ "leaseExpiresAt" = NULL
2841
+ WHERE "id" = ? AND "status" = 'running' AND "leaseOwner" = ?`, input.availableAt ?? input.now, input.errorJson, input.runId, input.leaseOwner);
2842
+ }
2843
+ else {
2844
+ this.ctx.storage.sql.exec(`UPDATE "_job_runs"
2845
+ SET "status" = 'failed',
2846
+ "endedAt" = ?,
2847
+ "expiresAt" = ?,
2848
+ "errorJson" = ?,
2849
+ "leaseOwner" = NULL,
2850
+ "leaseExpiresAt" = NULL
2851
+ WHERE "id" = ? AND "status" = 'running' AND "leaseOwner" = ?`, input.now, input.expiresAt ?? null, input.errorJson, input.runId, input.leaseOwner);
2852
+ }
2853
+ if (sqliteChanges(this.ctx.storage) === 0)
2854
+ throw new Error("Job run was not failed because its lease is stale or it is no longer running");
2855
+ const run = this.getJobRun(input.runId);
2856
+ if (!input.retry)
2857
+ batchCompleted = this.settleJobRunSync(run, "failed", input.now);
2858
+ // A run waiting out its retry delay holds no slot.
2859
+ wakes = this.wakeJobRunsSync(jobSlotPools([run]), wallNowIso());
2860
+ });
2861
+ await this.sendJobWakes(wakes);
2862
+ if (batchCompleted)
2863
+ await this.dispatchJobBatchContinuations();
2864
+ if (input.retry)
2865
+ await this.scheduleRealAlarm(Date.parse(input.availableAt ?? input.now));
2866
+ else
2867
+ await this.scheduleVirtualAlarm(input.expiresAt);
2868
+ return { ok: true };
2869
+ }
2870
+ async denyJobRun(input) {
2871
+ await this.ensureJobsSchema();
2872
+ let batchCompleted = false;
2873
+ let wakes = [];
2874
+ this.mutateSync(() => {
2875
+ const leaseSql = input.leaseOwner ? ` AND "leaseOwner" = ?` : "";
2876
+ this.ctx.storage.sql.exec(`UPDATE "_job_runs"
2877
+ SET "status" = 'denied',
2878
+ "endedAt" = ?,
2879
+ "expiresAt" = ?,
2880
+ "errorJson" = ?,
2881
+ "leaseOwner" = NULL,
2882
+ "leaseExpiresAt" = NULL
2883
+ WHERE "id" = ? AND "status" IN ('queued', 'leased', 'running')${leaseSql}`, ...(input.leaseOwner
2884
+ ? [input.now, input.expiresAt, input.errorJson, input.runId, input.leaseOwner]
2885
+ : [input.now, input.expiresAt, input.errorJson, input.runId]));
2886
+ if (sqliteChanges(this.ctx.storage) > 0) {
2887
+ const run = this.getJobRun(input.runId);
2888
+ batchCompleted = this.settleJobRunSync(run, "denied", input.now);
2889
+ wakes = this.wakeJobRunsSync(jobSlotPools([run]), wallNowIso());
2890
+ }
2891
+ });
2892
+ await this.sendJobWakes(wakes);
2893
+ if (batchCompleted)
2894
+ await this.dispatchJobBatchContinuations();
2895
+ await this.scheduleVirtualAlarm(input.expiresAt);
2896
+ return { ok: true };
2897
+ }
2898
+ async cancelJobRun(input) {
2899
+ await this.ensureJobsSchema();
2900
+ let result = { status: "missing" };
2901
+ let batchCompleted = false;
2902
+ let wakes = [];
2903
+ this.mutateSync(() => {
2904
+ const current = this.getJobRun(input.runId);
2905
+ if (!current) {
2906
+ result = { status: "missing" };
2907
+ return;
2908
+ }
2909
+ if (terminalJobStatuses.has(current.status)) {
2910
+ result = { status: "already-terminal", run: current };
2911
+ return;
2912
+ }
2913
+ const errorJson = JSON.stringify({
2914
+ cancelled: true,
2915
+ reason: input.reason ?? null,
2916
+ actorKind: input.actorKind ?? null,
2917
+ actorId: input.actorId ?? null
2918
+ });
2919
+ this.ctx.storage.sql.exec(`UPDATE "_job_runs"
2920
+ SET "status" = 'cancelled',
2921
+ "endedAt" = ?,
2922
+ "expiresAt" = ?,
2923
+ "errorJson" = ?,
2924
+ "leaseOwner" = NULL,
2925
+ "leaseExpiresAt" = NULL
2926
+ WHERE "id" = ? AND "status" NOT IN ('succeeded', 'failed', 'denied', 'cancelled', 'expired')`, input.now, input.expiresAt ?? null, errorJson, input.runId);
2927
+ const changed = sqliteChanges(this.ctx.storage) > 0;
2928
+ const run = this.getJobRun(input.runId);
2929
+ if (!run)
2930
+ throw new Error("Cancelled job run disappeared");
2931
+ if (changed) {
2932
+ batchCompleted = this.settleJobRunSync(run, "cancelled", input.now);
2933
+ wakes = this.wakeJobRunsSync(jobSlotPools([run]), wallNowIso());
2934
+ }
2935
+ result = { status: "cancelled", run };
2936
+ });
2937
+ await this.sendJobWakes(wakes);
2938
+ if (batchCompleted)
2939
+ await this.dispatchJobBatchContinuations();
2940
+ await this.scheduleVirtualAlarm(input.expiresAt);
2941
+ return result;
2942
+ }
2943
+ async appendJobEvent(input) {
2944
+ await this.ensureJobsSchema();
2945
+ this.mutateSync(() => {
2946
+ this.ctx.storage.sql.exec(`INSERT INTO "_job_events" ("id", "runId", "type", "level", "message", "dataJson", "createdAt")
2947
+ VALUES (?, ?, ?, ?, ?, ?, ?)`, input.id ?? `jobevt_${crypto.randomUUID().replace(/-/g, "")}`, input.runId, input.type, input.level, input.message ?? null, input.dataJson ?? null, input.createdAt ?? new Date().toISOString());
2948
+ });
2949
+ return { ok: true };
2950
+ }
2951
+ async listDueSchedules(input) {
2952
+ await this.ensureJobsSchema();
2953
+ const limit = Math.min(Math.max(input.limit ?? 100, 1), 500);
2954
+ const schedules = this.ctx.storage.sql
2955
+ .exec(`SELECT * FROM "_job_schedules"
2956
+ WHERE "status" = 'pending' AND "bookedAt" IS NOT NULL AND "bookedAt" <= ?
2957
+ ORDER BY "bookedAt" ASC
2958
+ LIMIT ?`, input.now, limit)
2959
+ .toArray()
2960
+ .map(rowToJobSchedule);
2961
+ return { schedules };
2962
+ }
2963
+ async upsertJobSchedule(input) {
2964
+ await this.ensureJobsSchema();
2965
+ this.mutateSync(() => {
2966
+ const key = `raybase:declared:${input.jobName}`;
2967
+ const existingRow = this.ctx.storage.sql.exec(`SELECT * FROM "_job_schedules" WHERE "key" = ?`, key).toArray()[0];
2968
+ if (!existingRow) {
2969
+ this.ctx.storage.sql.exec(`INSERT INTO "_job_schedules"
2970
+ ("id", "jobName", "key", "kind", "status", "inputJson", "cron", "timezone", "enabled", "lastRunAt", "nextRunAt", "bookedAt", "generation", "createdAt", "updatedAt", "createdBy", "service", "actorKind", "actorId")
2971
+ VALUES (?, ?, ?, 'cron', ?, ?, ?, ?, ?, NULL, ?, ?, 1, ?, ?, ?, ?, 'service', ?)`, input.id, input.jobName, key, input.enabled === false ? "cancelled" : "pending", input.inputJson, input.cron, input.timezone ?? null, input.enabled === false ? 0 : 1, input.nextRunAt ?? null, input.nextRunAt ?? null, input.now, input.now, input.createdBy ?? null, input.service ?? null, input.service ?? "scheduler");
2972
+ return;
2973
+ }
2974
+ const existing = rowToJobSchedule(existingRow);
2975
+ const desiredEnabled = input.enabled !== false;
2976
+ const definitionChanged = existing.jobName !== input.jobName
2977
+ || existing.inputJson !== input.inputJson
2978
+ || existing.cron !== input.cron
2979
+ || existing.timezone !== (input.timezone ?? null)
2980
+ || (existing.status === "cancelled") !== !desiredEnabled;
2981
+ if (definitionChanged) {
2982
+ const nextStatus = desiredEnabled
2983
+ ? existing.status === "suspended" ? "suspended" : "pending"
2984
+ : "cancelled";
2985
+ this.ctx.storage.sql.exec(`UPDATE "_job_schedules" SET
2986
+ "jobName" = ?, "kind" = 'cron', "status" = ?, "inputJson" = ?, "cron" = ?, "timezone" = ?,
2987
+ "enabled" = ?, "bookedAt" = ?, "nextRunAt" = ?, "generation" = "generation" + 1,
2988
+ "dispatchAttempts" = 0, "dispatchLeaseExpiresAt" = NULL, "lastDispatchAt" = NULL,
2989
+ "lastDispatchError" = NULL, "cancelledAt" = ?, "cancellationReason" = ?, "updatedAt" = ?,
2990
+ "service" = ?, "actorKind" = 'service', "actorId" = ?
2991
+ WHERE "key" = ?`, input.jobName, nextStatus, input.inputJson, input.cron, input.timezone ?? null, desiredEnabled ? 1 : 0, input.nextRunAt ?? null, input.nextRunAt ?? null, desiredEnabled ? null : input.now, desiredEnabled ? null : "Declared schedule is disabled", input.now, input.service ?? null, input.service ?? "scheduler", key);
2992
+ }
2993
+ else if (existing.service !== (input.service ?? null)
2994
+ || existing.actorKind !== "service"
2995
+ || existing.actorId !== (input.service ?? "scheduler")) {
2996
+ this.ctx.storage.sql.exec(`UPDATE "_job_schedules" SET "service" = ?, "actorKind" = 'service', "actorId" = ? WHERE "key" = ?`, input.service ?? null, input.service ?? "scheduler", key);
2997
+ }
2998
+ });
2999
+ await this.scheduleVirtualAlarm(input.nextRunAt);
3000
+ return { ok: true };
3001
+ }
3002
+ async advanceSchedule(input) {
3003
+ await this.ensureJobsSchema();
3004
+ this.mutateSync(() => {
3005
+ this.ctx.storage.sql.exec(`UPDATE "_job_schedules"
3006
+ SET "lastRunAt" = ?, "bookedAt" = ?, "nextRunAt" = ?, "generation" = "generation" + 1, "updatedAt" = ?
3007
+ WHERE "id" = ?`, input.lastRunAt, input.nextRunAt, input.nextRunAt, input.now, input.id);
3008
+ });
3009
+ await this.scheduleVirtualAlarm(input.nextRunAt);
3010
+ return { ok: true };
3011
+ }
3012
+ async createJobSchedule(input) {
3013
+ await this.ensureJobsSchema();
3014
+ if (!input.key || input.key.length > 512)
3015
+ throw new Error("Job schedule key must be between 1 and 512 characters");
3016
+ let created = null;
3017
+ this.mutateSync(() => {
3018
+ if (this.ctx.storage.sql.exec(`SELECT 1 FROM "_job_schedules" WHERE "key" = ?`, input.key).toArray()[0]) {
3019
+ throw new Error(`Job schedule key already exists: ${input.key}`);
3020
+ }
3021
+ this.ctx.storage.sql.exec(`INSERT INTO "_job_schedules"
3022
+ ("id", "jobName", "key", "kind", "status", "inputJson", "cron", "timezone", "enabled", "nextRunAt", "bookedAt",
3023
+ "generation", "dispatchAttempts", "createdAt", "updatedAt", "createdBy", "service", "actorKind", "actorId")
3024
+ VALUES (?, ?, ?, ?, 'pending', ?, ?, ?, 1, ?, ?, 1, 0, ?, ?, ?, ?, ?, ?)`, input.id, input.jobName, input.key, input.kind, input.inputJson, input.cron ?? null, input.timezone ?? null, input.bookedAt, input.bookedAt, input.now, input.now, input.createdBy ?? null, input.service ?? "scheduler", input.actorKind ?? (input.createdBy ? "user" : "service"), input.actorId ?? input.createdBy ?? input.service ?? "scheduler");
3025
+ created = rowToJobSchedule(this.ctx.storage.sql.exec(`SELECT * FROM "_job_schedules" WHERE "key" = ?`, input.key).toArray()[0]);
3026
+ });
3027
+ if (input.claims && Object.keys(input.claims).length)
3028
+ await this.ctx.storage.put(agentAlarmClaimsKey, input.claims);
3029
+ await this.scheduleVirtualAlarm(input.bookedAt);
3030
+ return created;
3031
+ }
3032
+ async getJobSchedule(key) {
3033
+ await this.ensureJobsSchema();
3034
+ const row = this.ctx.storage.sql.exec(`SELECT * FROM "_job_schedules" WHERE "key" = ?`, key).toArray()[0];
3035
+ return row ? rowToJobSchedule(row) : null;
3036
+ }
3037
+ async listJobSchedules(input = {}) {
3038
+ await this.ensureJobsSchema();
3039
+ const clauses = [];
3040
+ const bindings = [];
3041
+ if (input.name) {
3042
+ clauses.push(`"jobName" = ?`);
3043
+ bindings.push(input.name);
3044
+ }
3045
+ if (input.prefix) {
3046
+ clauses.push(`"key" LIKE ? ESCAPE '\\'`);
3047
+ bindings.push(`${input.prefix.replace(/[\\%_]/g, "\\$&")}%`);
3048
+ }
3049
+ if (input.status) {
3050
+ clauses.push(`"status" = ?`);
3051
+ bindings.push(input.status);
3052
+ }
3053
+ const limit = Math.min(Math.max(input.limit ?? 100, 1), 500);
3054
+ const rows = this.ctx.storage.sql.exec(`SELECT * FROM "_job_schedules" ${clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""} ORDER BY "key" LIMIT ?`, ...bindings, limit).toArray();
3055
+ return { schedules: rows.map(rowToJobSchedule) };
3056
+ }
3057
+ async replaceJobSchedule(input) {
3058
+ await this.ensureJobsSchema();
3059
+ let schedule = null;
3060
+ this.mutateSync(() => {
3061
+ const existing = this.ctx.storage.sql.exec(`SELECT * FROM "_job_schedules" WHERE "key" = ?`, input.key).toArray()[0];
3062
+ if (!existing)
3063
+ throw new Error(`Job schedule does not exist: ${input.key}`);
3064
+ const expectedClause = input.expected
3065
+ ? ` AND "id" = ? AND "generation" = ? AND "bookedAt" IS ?`
3066
+ : "";
3067
+ this.ctx.storage.sql.exec(`UPDATE "_job_schedules" SET "jobName" = ?, "kind" = ?, "status" = 'pending', "inputJson" = ?, "cron" = ?, "timezone" = ?,
3068
+ "enabled" = 1, "bookedAt" = ?, "nextRunAt" = ?, "generation" = "generation" + 1, "dispatchAttempts" = 0,
3069
+ "dispatchLeaseExpiresAt" = NULL, "lastDispatchAt" = NULL, "lastDispatchError" = NULL, "suspendedAt" = NULL,
3070
+ "cancelledAt" = NULL, "cancellationReason" = NULL, "updatedAt" = ?, "service" = ?, "actorKind" = ?, "actorId" = ? WHERE "key" = ?${expectedClause}`, input.jobName, input.kind, input.inputJson, input.cron ?? null, input.timezone ?? null, input.bookedAt, input.bookedAt, input.now, input.service ?? "scheduler", input.actorKind ?? "service", input.actorId ?? input.service ?? "scheduler", input.key, ...(input.expected
3071
+ ? [input.expected.scheduleId, input.expected.generation, input.expected.bookedAt]
3072
+ : []));
3073
+ if (input.expected && sqliteChanges(this.ctx.storage) === 0) {
3074
+ throw new Error(`Job schedule changed while authorizing replacement: ${input.key}`);
3075
+ }
3076
+ schedule = rowToJobSchedule(this.ctx.storage.sql.exec(`SELECT * FROM "_job_schedules" WHERE "key" = ?`, input.key).toArray()[0]);
3077
+ });
3078
+ if (input.claims && Object.keys(input.claims).length)
3079
+ await this.ctx.storage.put(agentAlarmClaimsKey, input.claims);
3080
+ await this.scheduleVirtualAlarm(input.bookedAt);
3081
+ return schedule;
3082
+ }
3083
+ async cancelJobSchedule(input) {
3084
+ await this.ensureJobsSchema();
3085
+ let changed = false;
3086
+ this.mutateSync(() => {
3087
+ const expectedClause = input.expected
3088
+ ? ` AND "id" = ? AND "generation" = ? AND "bookedAt" IS ?`
3089
+ : "";
3090
+ this.ctx.storage.sql.exec(`UPDATE "_job_schedules" SET "status" = 'cancelled', "enabled" = 0, "cancelledAt" = ?, "cancellationReason" = ?,
3091
+ "dispatchLeaseExpiresAt" = NULL, "updatedAt" = ? WHERE "key" = ? AND "status" != 'cancelled'${expectedClause}`, input.now, input.reason ?? null, input.now, input.key, ...(input.expected
3092
+ ? [input.expected.scheduleId, input.expected.generation, input.expected.bookedAt]
3093
+ : []));
3094
+ changed = sqliteChanges(this.ctx.storage) > 0;
3095
+ });
3096
+ if (input.expected && !changed)
3097
+ return null;
3098
+ return this.getJobSchedule(input.key);
3099
+ }
3100
+ scheduleSelection(input) {
3101
+ if ("keys" in input && input.keys) {
3102
+ const keys = [...new Set(input.keys.filter(Boolean))].slice(0, 500);
3103
+ if (!keys.length)
3104
+ return { where: "0", bindings: [] };
3105
+ return { where: `"key" IN (${keys.map(() => "?").join(", ")})`, bindings: keys };
3106
+ }
3107
+ const prefix = "prefix" in input ? input.prefix : "";
3108
+ if (!prefix)
3109
+ throw new Error("Schedule selection requires a non-empty prefix or keys");
3110
+ return { where: `"key" LIKE ? ESCAPE '\\'`, bindings: [`${prefix.replace(/[\\%_]/g, "\\$&")}%`] };
3111
+ }
3112
+ async suspendJobSchedules(input) {
3113
+ await this.ensureJobsSchema();
3114
+ const selected = this.scheduleSelection(input);
3115
+ let schedules = [];
3116
+ this.mutateSync(() => {
3117
+ let rows = this.ctx.storage.sql.exec(`SELECT * FROM "_job_schedules" WHERE ${selected.where} AND "status" = 'pending' ORDER BY "key" LIMIT 500`, ...selected.bindings).toArray();
3118
+ if (input.expected) {
3119
+ const expected = new Map(input.expected.map((coordinate) => [coordinate.key, coordinate]));
3120
+ rows = rows.filter((row) => {
3121
+ const coordinate = expected.get(String(row.key));
3122
+ return coordinate
3123
+ && coordinate.scheduleId === String(row.id)
3124
+ && coordinate.generation === Number(row.generation)
3125
+ && coordinate.bookedAt === (row.bookedAt == null ? null : String(row.bookedAt));
3126
+ });
3127
+ }
3128
+ if (!rows.length)
3129
+ return;
3130
+ const ids = rows.map((row) => String(row.id));
3131
+ this.ctx.storage.sql.exec(`UPDATE "_job_schedules" SET "status" = 'suspended', "enabled" = 0, "suspendedAt" = ?, "dispatchLeaseExpiresAt" = NULL, "updatedAt" = ?
3132
+ WHERE "id" IN (${ids.map(() => "?").join(", ")}) AND "status" = 'pending'`, input.now, input.now, ...ids);
3133
+ schedules = this.ctx.storage.sql.exec(`SELECT * FROM "_job_schedules" WHERE "id" IN (${ids.map(() => "?").join(", ")}) ORDER BY "key"`, ...ids).toArray().map(rowToJobSchedule);
3134
+ });
3135
+ return { count: schedules.length, schedules };
3136
+ }
3137
+ async resumeJobSchedules(input) {
3138
+ await this.ensureJobsSchema();
3139
+ const selected = this.scheduleSelection(input);
3140
+ let earliest = null;
3141
+ let scheduleIds = [];
3142
+ this.mutateSync(() => {
3143
+ let rows = this.ctx.storage.sql.exec(`SELECT * FROM "_job_schedules" WHERE ${selected.where} AND "status" = 'suspended' ORDER BY "key" LIMIT 500`, ...selected.bindings).toArray();
3144
+ if (input.expected) {
3145
+ const expected = new Map(input.expected.map((coordinate) => [coordinate.key, coordinate]));
3146
+ rows = rows.filter((row) => {
3147
+ const coordinate = expected.get(String(row.key));
3148
+ return coordinate
3149
+ && coordinate.scheduleId === String(row.id)
3150
+ && coordinate.generation === Number(row.generation)
3151
+ && coordinate.bookedAt === (row.bookedAt == null ? null : String(row.bookedAt));
3152
+ });
3153
+ }
3154
+ scheduleIds = rows.map((row) => String(row.id));
3155
+ for (const row of rows) {
3156
+ const current = rowToJobSchedule(row);
3157
+ // Resume is the inverse of suspend, which preserved both. `bookedAt` is
3158
+ // the signed moment a handler stamps its ledger with, so it survives the
3159
+ // pause even when it has passed: the row comes back late, not rebooked.
3160
+ // Only a cron whose instant elapsed while suspended moves, because that
3161
+ // is a genuinely new occurrence — and a new occurrence is a new
3162
+ // generation. Anything still in the future resumes untouched, so a
3163
+ // repeated resume cannot walk the schedule forward.
3164
+ const elapsedCron = current.kind === "cron"
3165
+ && current.cron
3166
+ && Date.parse(current.bookedAt ?? input.now) <= Date.parse(input.now);
3167
+ const bookedAt = elapsedCron
3168
+ ? nextCronRun(current.cron, input.now, current.timezone)
3169
+ : current.bookedAt;
3170
+ if (!bookedAt)
3171
+ continue;
3172
+ const rebooked = bookedAt !== current.bookedAt;
3173
+ this.ctx.storage.sql.exec(`UPDATE "_job_schedules" SET "status" = 'pending', "enabled" = 1, "bookedAt" = ?, "nextRunAt" = ?,
3174
+ "generation" = "generation" + ?, "suspendedAt" = NULL, "updatedAt" = ? WHERE "id" = ?`, bookedAt, bookedAt, rebooked ? 1 : 0, input.now, current.id);
3175
+ // The alarm arms at the later of the two; a past instant fires at once.
3176
+ const armAt = new Date(Math.max(Date.parse(bookedAt), Date.parse(input.now))).toISOString();
3177
+ if (!earliest || armAt < earliest)
3178
+ earliest = armAt;
3179
+ }
3180
+ });
3181
+ if (earliest)
3182
+ await this.scheduleVirtualAlarm(earliest);
3183
+ if (!scheduleIds.length)
3184
+ return { count: 0, schedules: [] };
3185
+ const schedules = this.ctx.storage.sql.exec(`SELECT * FROM "_job_schedules" WHERE "id" IN (${scheduleIds.map(() => "?").join(", ")}) ORDER BY "key"`, ...scheduleIds).toArray().map(rowToJobSchedule);
3186
+ return { count: schedules.length, schedules };
3187
+ }
3188
+ async claimDueJobSchedules(input) {
3189
+ await this.ensureJobsSchema();
3190
+ const limit = Math.min(Math.max(input.limit ?? 100, 1), 500);
3191
+ let schedules = [];
3192
+ this.mutateSync(() => {
3193
+ this.ctx.storage.sql.exec(`UPDATE "_job_schedules" SET "status" = 'cancelled', "enabled" = 0, "cancelledAt" = ?,
3194
+ "cancellationReason" = 'dispatch-attempts-exhausted', "dispatchLeaseExpiresAt" = NULL,
3195
+ "lastDispatchError" = 'dispatch-attempts-exhausted', "updatedAt" = ?
3196
+ WHERE "status" = 'pending' AND "bookedAt" <= ? AND "dispatchAttempts" >= ?
3197
+ AND ("dispatchLeaseExpiresAt" IS NULL OR "dispatchLeaseExpiresAt" <= ?)`, input.now, input.now, input.now, maxJobScheduleDispatchAttempts, input.wallNow);
3198
+ const due = this.ctx.storage.sql.exec(`SELECT * FROM "_job_schedules" WHERE "status" = 'pending' AND "bookedAt" <= ?
3199
+ AND ("dispatchLeaseExpiresAt" IS NULL OR "dispatchLeaseExpiresAt" <= ?)
3200
+ AND "dispatchAttempts" < ?
3201
+ ORDER BY "bookedAt" LIMIT ?`, input.now, input.wallNow, maxJobScheduleDispatchAttempts, limit).toArray();
3202
+ for (const row of due) {
3203
+ this.ctx.storage.sql.exec(`UPDATE "_job_schedules" SET "dispatchAttempts" = "dispatchAttempts" + 1, "dispatchLeaseExpiresAt" = ?,
3204
+ "lastDispatchAt" = ?, "lastDispatchError" = NULL, "updatedAt" = ? WHERE "id" = ? AND "generation" = ?`, input.leaseExpiresAt, input.wallNow, input.now, row.id, row.generation);
3205
+ }
3206
+ schedules = due.map((row) => rowToJobSchedule({ ...row, dispatchAttempts: Number(row.dispatchAttempts ?? 0) + 1, dispatchLeaseExpiresAt: input.leaseExpiresAt }));
3207
+ });
3208
+ if (schedules.length)
3209
+ await this.scheduleRealAlarm(Date.parse(input.leaseExpiresAt));
3210
+ return { schedules };
3211
+ }
3212
+ async materializeScheduledJobRun(input) {
3213
+ await this.ensureJobsSchema();
3214
+ let result = { status: "missing" };
3215
+ this.mutateSync(() => {
3216
+ const row = this.ctx.storage.sql.exec(`SELECT * FROM "_job_schedules" WHERE "id" = ? AND "key" = ?`, input.scheduleId, input.key).toArray()[0];
3217
+ if (!row)
3218
+ return;
3219
+ const schedule = rowToJobSchedule(row);
3220
+ const existing = rowToJobRun(this.ctx.storage.sql.exec(`SELECT * FROM "_job_runs" WHERE "scheduleKey" = ? AND "scheduleGeneration" = ?`, input.key, input.generation).toArray()[0]);
3221
+ if (existing) {
3222
+ result = { status: "existing", run: existing, schedule };
3223
+ return;
3224
+ }
3225
+ if (schedule.status !== "pending" || schedule.generation !== input.generation || schedule.bookedAt !== input.bookedAt) {
3226
+ result = { status: "stale" };
3227
+ return;
3228
+ }
3229
+ const run = { ...input.run, scheduleKey: input.key, scheduleGeneration: input.generation, scheduleBookedAt: input.bookedAt };
3230
+ this.insertJobRunSync(run);
3231
+ const recurring = schedule.kind === "cron" && input.nextBookedAt !== null;
3232
+ const nextStatus = recurring ? "pending" : "fired";
3233
+ const nextGeneration = recurring ? input.generation + 1 : input.generation;
3234
+ const nextBookedAt = recurring ? input.nextBookedAt : input.bookedAt;
3235
+ this.ctx.storage.sql.exec(`UPDATE "_job_schedules" SET "status" = ?, "enabled" = ?, "lastRunAt" = ?, "bookedAt" = ?, "nextRunAt" = ?,
3236
+ "generation" = ?, "dispatchLeaseExpiresAt" = NULL, "lastDispatchError" = NULL, "updatedAt" = ? WHERE "id" = ? AND "generation" = ?`, nextStatus, nextStatus === "pending" ? 1 : 0, input.materializedAt, nextBookedAt, nextBookedAt, nextGeneration, input.materializedAt, input.scheduleId, input.generation);
3237
+ const inserted = this.getJobRun(run.id);
3238
+ const updated = rowToJobSchedule(this.ctx.storage.sql.exec(`SELECT * FROM "_job_schedules" WHERE "id" = ?`, input.scheduleId).toArray()[0]);
3239
+ result = { status: "materialized", run: inserted, schedule: updated };
3240
+ });
3241
+ const finalized = result;
3242
+ if (finalized.status === "materialized" && finalized.schedule.status === "pending" && finalized.schedule.bookedAt) {
3243
+ await this.scheduleVirtualAlarm(finalized.schedule.bookedAt);
3244
+ }
3245
+ return finalized;
3246
+ }
3247
+ async findMaterializedScheduledJobRun(input) {
3248
+ await this.ensureJobsSchema();
3249
+ return rowToJobRun(this.ctx.storage.sql.exec(`SELECT * FROM "_job_runs" WHERE "idempotencyKey" = ? AND "scheduleKey" = ? AND "scheduleGeneration" = ? AND "scheduleBookedAt" = ? LIMIT 1`, `job_schedule:${input.scheduleId}:${input.generation}`, input.key, input.generation, input.bookedAt).toArray()[0]);
3250
+ }
3251
+ async schedulerState() {
3252
+ await this.ensureJobsSchema();
3253
+ const row = this.ctx.storage.sql.exec(`SELECT MIN(CASE WHEN "status" = 'pending' AND "dispatchLeaseExpiresAt" IS NULL THEN "bookedAt" END) AS "nextAt",
3254
+ SUM(CASE WHEN "status" = 'pending' THEN 1 ELSE 0 END) AS "pending",
3255
+ SUM(CASE WHEN "dispatchLeaseExpiresAt" IS NOT NULL THEN 1 ELSE 0 END) AS "leased" FROM "_job_schedules"`).toArray()[0];
3256
+ return { nextJobScheduleAt: row?.nextAt ?? null, pendingJobSchedules: Number(row?.pending ?? 0), leasedJobSchedules: Number(row?.leased ?? 0) };
3257
+ }
3258
+ async compactJobs(input) {
3259
+ await this.ensureJobsSchema();
3260
+ const start = wallNowMs();
3261
+ const batchSize = Math.min(Math.max(input.batchSize ?? defaultCompactionBatchSize, 1), 500);
3262
+ const budgetMs = Math.min(Math.max(input.wallClockBudgetMs ?? 20, 5), 100);
3263
+ const now = input.now;
3264
+ const wallNow = input.wallNow ?? input.now;
3265
+ const failedExpiresAt = new Date(new Date(now).getTime() + defaultFailedRetentionMs).toISOString();
3266
+ const result = { expiredLeases: 0, rolledUpRuns: 0, deletedEvents: 0, deletedRuns: 0, more: false };
3267
+ let batchCompleted = false;
3268
+ let wakes = [];
3269
+ this.mutateSync(() => {
3270
+ const stale = this.ctx.storage.sql.exec(`SELECT "id" FROM "_job_runs"
3271
+ WHERE "status" IN ('running', 'leased')
3272
+ AND "leaseExpiresAt" IS NOT NULL
3273
+ AND "leaseExpiresAt" <= ?
3274
+ ORDER BY "leaseExpiresAt" ASC
3275
+ LIMIT ?`, input.staleLeaseBefore ?? wallNow, batchSize).toArray();
3276
+ const released = [];
3277
+ for (const { id } of stale) {
3278
+ this.ctx.storage.sql.exec(`UPDATE "_job_runs"
3279
+ SET "status" = 'failed',
3280
+ "endedAt" = ?,
3281
+ "expiresAt" = COALESCE("expiresAt", ?),
3282
+ "errorJson" = COALESCE("errorJson", '{"error":"stale job lease expired"}'),
3283
+ "leaseOwner" = NULL,
3284
+ "leaseExpiresAt" = NULL
3285
+ WHERE "id" = ? AND "status" IN ('running', 'leased')`, now, failedExpiresAt, id);
3286
+ if (sqliteChanges(this.ctx.storage) === 0)
3287
+ continue;
3288
+ result.expiredLeases += 1;
3289
+ const run = this.getJobRun(id);
3290
+ released.push(run);
3291
+ if (this.settleJobRunSync(run, "failed", now))
3292
+ batchCompleted = true;
3293
+ }
3294
+ wakes = this.wakeJobRunsSync(jobSlotPools(released), wallNowIso());
3295
+ });
3296
+ await this.sendJobWakes(wakes);
3297
+ if (batchCompleted)
3298
+ await this.dispatchJobBatchContinuations();
3299
+ if (wallNowMs() - start > budgetMs) {
3300
+ result.more = true;
3301
+ return result;
3302
+ }
3303
+ // Children of a batch whose continuation has not finished stay readable
3304
+ // through `ctx.batch.children()` whatever their own retention says.
3305
+ const expired = this.ctx.storage.sql
3306
+ .exec(`SELECT "id", "jobName", "status", "startedAt", "endedAt"
3307
+ FROM "_job_runs"
3308
+ WHERE "status" IN ('succeeded', 'failed', 'denied', 'cancelled', 'expired')
3309
+ AND "expiresAt" IS NOT NULL
3310
+ AND "expiresAt" <= ?
3311
+ AND ("batchId" IS NULL OR "batchId" NOT IN (SELECT "id" FROM "_job_batches" WHERE "finishedAt" IS NULL))
3312
+ ORDER BY "expiresAt" ASC
3313
+ LIMIT ?`, now, batchSize)
3314
+ .toArray();
3315
+ this.mutateSync(() => {
3316
+ for (const run of expired) {
3317
+ const day = String(run.endedAt ?? now).slice(0, 10);
3318
+ const duration = run.startedAt && run.endedAt ? Math.max(0, new Date(run.endedAt).getTime() - new Date(run.startedAt).getTime()) : 0;
3319
+ this.ctx.storage.sql.exec(`INSERT INTO "_job_stats_daily" ("day", "jobName", "status", "count", "totalDurationMs")
3320
+ VALUES (?, ?, ?, 1, ?)
3321
+ ON CONFLICT("day", "jobName", "status") DO UPDATE SET
3322
+ "count" = "count" + 1,
3323
+ "totalDurationMs" = COALESCE("totalDurationMs", 0) + excluded."totalDurationMs"`, day, run.jobName, run.status, duration);
3324
+ result.rolledUpRuns += 1;
3325
+ }
3326
+ if (expired.length) {
3327
+ const ids = expired.map((run) => run.id);
3328
+ const placeholders = ids.map(() => "?").join(", ");
3329
+ this.ctx.storage.sql.exec(`DELETE FROM "_job_events" WHERE "runId" IN (${placeholders})`, ...ids);
3330
+ result.deletedEvents = sqliteChanges(this.ctx.storage);
3331
+ this.ctx.storage.sql.exec(`DELETE FROM "_job_runs" WHERE "id" IN (${placeholders})`, ...ids);
3332
+ result.deletedRuns = sqliteChanges(this.ctx.storage);
3333
+ }
3334
+ this.ctx.storage.sql.exec(`DELETE FROM "_job_events"
3335
+ WHERE "id" IN (
3336
+ SELECT "id" FROM "_job_events"
3337
+ WHERE "createdAt" <= ?
3338
+ AND "runId" NOT IN (SELECT "id" FROM "_job_runs")
3339
+ ORDER BY "createdAt" ASC
3340
+ LIMIT ?
3341
+ )`, now, batchSize);
3342
+ result.deletedEvents += sqliteChanges(this.ctx.storage);
3343
+ this.ctx.storage.sql.exec(`DELETE FROM "_job_batches"
3344
+ WHERE "id" IN (
3345
+ SELECT "id" FROM "_job_batches"
3346
+ WHERE "expiresAt" IS NOT NULL AND "expiresAt" <= ?
3347
+ ORDER BY "expiresAt" ASC
3348
+ LIMIT ?
3349
+ )`, now, batchSize);
3350
+ this.ctx.storage.sql.exec(`DELETE FROM "_job_rate_buckets"
3351
+ WHERE "key" IN (SELECT "key" FROM "_job_rate_buckets" WHERE "fullAt" <= ? LIMIT ?)`, wallNow, batchSize);
3352
+ });
3353
+ result.more = result.expiredLeases === batchSize || expired.length === batchSize;
3354
+ return result;
3355
+ }
3356
+ async all(sql, params = []) {
3357
+ await this.ensureSchema({ migrations: [] });
3358
+ this.assertReadableSync();
3359
+ this.assertPublicSqlDoesNotTargetBackupState(sql);
3360
+ return this.publicRowsSync(sql, params);
3361
+ }
3362
+ async first(sql, params = []) {
3363
+ await this.ensureSchema({ migrations: [] });
3364
+ this.assertReadableSync();
3365
+ this.assertPublicSqlDoesNotTargetBackupState(sql);
3366
+ return this.publicRowsSync(sql, params)[0] ?? null;
3367
+ }
3368
+ async run(sql, params = []) {
3369
+ await this.ensureSchema({ migrations: [] });
3370
+ this.assertPublicSqlDoesNotTargetBackupState(sql);
3371
+ this.mutateSync(() => {
3372
+ this.ctx.storage.sql.exec(sql, ...params);
3373
+ });
3374
+ }
3375
+ async runTransaction(input) {
3376
+ const statements = Array.isArray(input) ? input : input.statements;
3377
+ await this.ensureSchema(Array.isArray(input) ? { migrations: [] } : input.bundle);
3378
+ for (const statement of statements)
3379
+ this.assertPublicSqlDoesNotTargetBackupState(statement.sql);
3380
+ this.mutateSync(() => {
3381
+ for (const statement of statements) {
3382
+ this.ctx.storage.sql.exec(statement.sql, ...(statement.params ?? []));
3383
+ }
3384
+ });
3385
+ }
3386
+ async configureRealtime(input) {
3387
+ await this.ensureSchema({ migrations: [] });
3388
+ this.realtimeConfig = input.config;
3389
+ this.realtimeSchema = input.schema;
3390
+ this.realtimeAuth = input.auth;
3391
+ await this.ctx.storage.put({
3392
+ [realtimeConfigKey]: input.config,
3393
+ [realtimeSchemaKey]: input.schema,
3394
+ [realtimeAuthKey]: input.auth
3395
+ });
3396
+ await this.ctx.storage.setAlarm(Date.now() + realtimeSweepMs);
3397
+ return { ok: true };
3398
+ }
3399
+ async runWithChange(input) {
3400
+ await this.ensureSchema({ migrations: [] });
3401
+ this.assertPublicSqlDoesNotTargetBackupState(input.sql);
3402
+ let stored = null;
3403
+ this.mutateSync(() => {
3404
+ this.ctx.storage.sql.exec(input.sql, ...(input.params ?? []));
3405
+ const createdAt = new Date().toISOString();
3406
+ this.ctx.storage.sql.exec(`INSERT INTO "_changes" ("tableName", "op", "rowId", "oldRow", "newRow", "actorId", "adminOverride", "createdAt")
3407
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, input.change.tableName, input.change.op, input.change.rowId, input.change.oldRow ? JSON.stringify(input.change.oldRow) : null, input.change.newRow ? JSON.stringify(input.change.newRow) : null, input.change.actorId ?? null, input.change.adminOverride ? 1 : 0, createdAt);
3408
+ const seq = this.ctx.storage.sql.exec(`SELECT last_insert_rowid() AS "seq"`).toArray()[0]?.seq;
3409
+ if (!seq)
3410
+ throw new Error("Could not assign realtime change sequence");
3411
+ stored = { ...input.change, seq: Number(seq), createdAt };
3412
+ });
3413
+ await this.pruneChanges(input.realtime);
3414
+ if (!stored)
3415
+ throw new Error("Realtime change was not stored");
3416
+ return stored;
3417
+ }
3418
+ async notifyDataChange(input) {
3419
+ this.realtimeConfig = input.config;
3420
+ this.realtimeSchema = input.schema;
3421
+ await this.ctx.storage.put({
3422
+ [realtimeConfigKey]: input.config,
3423
+ [realtimeSchemaKey]: input.schema
3424
+ });
3425
+ await this.fanout(input.change);
3426
+ return { ok: true };
3427
+ }
3428
+ async notifyNotificationFeed(input) {
3429
+ await this.fanoutNotification(input);
3430
+ return { ok: true };
3431
+ }
3432
+ async broadcastRealtimeResync(input = {}) {
3433
+ await this.ensureRealtimeState();
3434
+ for (const ws of this.ctx.getWebSockets()) {
3435
+ const attachment = this.attachment(ws);
3436
+ for (const subscription of Object.values(attachment?.subscriptions ?? {})) {
3437
+ this.send(ws, { v: 1, type: "resync", subscriptionId: subscription.id, reason: input.reason ?? "gap" });
3438
+ }
3439
+ }
3440
+ return { ok: true };
3441
+ }
3442
+ async fetch(request) {
3443
+ await this.ensureSchema({ migrations: [] });
3444
+ try {
3445
+ this.assertReadableSync();
3446
+ }
3447
+ catch (error) {
3448
+ if (error instanceof RaybaseBackupError)
3449
+ return new Response(error.message, { status: 503 });
3450
+ throw error;
3451
+ }
3452
+ await this.ensureRealtimeState();
3453
+ if (request.headers.get("Upgrade")?.toLowerCase() !== "websocket") {
3454
+ return new Response("Expected WebSocket upgrade", { status: 400 });
3455
+ }
3456
+ const config = this.realtimeConfig;
3457
+ if (!config?.enabled)
3458
+ return new Response("Realtime is disabled", { status: 404 });
3459
+ if (this.ctx.getWebSockets().length >= config.limits.maxSockets) {
3460
+ return new Response("Realtime socket limit reached", { status: 429 });
3461
+ }
3462
+ const rawSession = request.headers.get("x-raybase-realtime-session");
3463
+ if (!rawSession)
3464
+ return new Response("Missing realtime session", { status: 401 });
3465
+ const session = JSON.parse(rawSession);
3466
+ const pair = new WebSocketPair();
3467
+ const [client, server] = Object.values(pair);
3468
+ const attachment = {
3469
+ v: 1,
3470
+ connectionId: randomId("rt"),
3471
+ sessionToken: session.sessionToken,
3472
+ sessionId: session.sessionId,
3473
+ user: session.user,
3474
+ subscriptions: {},
3475
+ notificationSubscription: undefined,
3476
+ presenceRooms: {},
3477
+ lastSeq: 0
3478
+ };
3479
+ this.saveAttachment(server, attachment);
3480
+ this.ctx.acceptWebSocket(server);
3481
+ return new Response(null, { status: 101, webSocket: client });
3482
+ }
3483
+ async webSocketMessage(ws, message) {
3484
+ await this.ensureSchema({ migrations: [] });
3485
+ try {
3486
+ this.assertReadableSync();
3487
+ }
3488
+ catch (error) {
3489
+ ws.close(1013, error instanceof Error ? error.message : "Database maintenance is active");
3490
+ return;
3491
+ }
3492
+ await this.ensureRealtimeState();
3493
+ const attachment = this.attachment(ws);
3494
+ if (!attachment) {
3495
+ ws.close(1011, "Missing realtime attachment");
3496
+ return;
3497
+ }
3498
+ const user = await this.revalidate(attachment);
3499
+ if (!user) {
3500
+ ws.close(4001, "Session is no longer valid");
3501
+ return;
3502
+ }
3503
+ attachment.user = user;
3504
+ let parsed;
3505
+ try {
3506
+ const text = typeof message === "string" ? message : new TextDecoder().decode(message);
3507
+ parsed = JSON.parse(text);
3508
+ }
3509
+ catch {
3510
+ this.sendError(ws, undefined, "invalid_json", "Realtime messages must be valid JSON");
3511
+ return;
3512
+ }
3513
+ if (parsed.v !== 1) {
3514
+ this.sendError(ws, parsed.ref, "unsupported_version", "Unsupported realtime protocol version");
3515
+ return;
3516
+ }
3517
+ try {
3518
+ if (parsed.type === "hello") {
3519
+ attachment.lastSeq = Math.max(attachment.lastSeq, parsed.lastSeq ?? 0);
3520
+ this.saveAttachment(ws, attachment);
3521
+ this.send(ws, { v: 1, ref: parsed.ref, type: "status", status: "subscribed" });
3522
+ return;
3523
+ }
3524
+ if (parsed.type === "subscribe") {
3525
+ const config = this.realtimeConfig;
3526
+ if (!config)
3527
+ throw new Error("Realtime config is not loaded");
3528
+ if (Object.keys(attachment.subscriptions).length >= config.limits.maxSubscriptionsPerSocket) {
3529
+ throw new Error("Too many realtime subscriptions on this socket");
3530
+ }
3531
+ const table = this.tableFor(parsed.table);
3532
+ if (!table)
3533
+ throw new Error(`Unknown realtime table: ${parsed.table}`);
3534
+ if (classifyReadPolicy(table.policies?.read, { user: attachment.user, adminOverride: false }) === "denied") {
3535
+ throw new RaybaseDataReadDeniedError({ table: table.name, context: { user: attachment.user } });
3536
+ }
3537
+ assertRealtimeListOptions(table, parsed.options);
3538
+ const subscription = {
3539
+ id: parsed.id,
3540
+ table: table.name,
3541
+ events: parsed.events,
3542
+ options: parsed.options,
3543
+ lastSeq: parsed.lastSeq ?? attachment.lastSeq
3544
+ };
3545
+ attachment.subscriptions[subscription.id] = subscription;
3546
+ this.saveAttachment(ws, attachment);
3547
+ this.send(ws, { v: 1, ref: parsed.ref, type: "status", status: "subscribed", subscriptionId: subscription.id });
3548
+ await this.replay(ws, attachment, subscription, subscription.lastSeq ?? 0);
3549
+ return;
3550
+ }
3551
+ if (parsed.type === "unsubscribe") {
3552
+ delete attachment.subscriptions[parsed.id];
3553
+ this.saveAttachment(ws, attachment);
3554
+ this.send(ws, { v: 1, ref: parsed.ref, type: "status", status: "closed", subscriptionId: parsed.id });
3555
+ return;
3556
+ }
3557
+ if (parsed.type === "notifications.subscribe") {
3558
+ attachment.notificationSubscription = {
3559
+ subjectId: user.id,
3560
+ lastSeq: parsed.lastSeq ?? 0
3561
+ };
3562
+ this.saveAttachment(ws, attachment);
3563
+ this.send(ws, { v: 1, ref: parsed.ref, type: "status", status: "subscribed" });
3564
+ if (parsed.lastSeq) {
3565
+ this.send(ws, { v: 1, type: "notifications.resync", reason: "gap" });
3566
+ }
3567
+ return;
3568
+ }
3569
+ if (parsed.type === "notifications.unsubscribe") {
3570
+ delete attachment.notificationSubscription;
3571
+ this.saveAttachment(ws, attachment);
3572
+ this.send(ws, { v: 1, ref: parsed.ref, type: "status", status: "closed" });
3573
+ return;
3574
+ }
3575
+ if (parsed.type === "presence.join") {
3576
+ const config = this.realtimeConfig;
3577
+ if (!config)
3578
+ throw new Error("Realtime config is not loaded");
3579
+ if (!attachment.presenceRooms[parsed.room] && Object.keys(attachment.presenceRooms).length >= config.limits.maxPresenceRoomsPerSocket) {
3580
+ throw new Error("Too many realtime presence rooms on this socket");
3581
+ }
3582
+ const now = new Date().toISOString();
3583
+ attachment.presenceRooms[parsed.room] = {
3584
+ state: this.parseState(parsed.state, config.limits.maxPresenceStateBytes),
3585
+ joinedAt: attachment.presenceRooms[parsed.room]?.joinedAt ?? now,
3586
+ updatedAt: now
3587
+ };
3588
+ this.saveAttachment(ws, attachment);
3589
+ this.send(ws, { v: 1, type: "presence.sync", room: parsed.room, members: this.roomMembers(parsed.room) });
3590
+ this.broadcastPresence(parsed.room, { type: "presence.join", members: [this.presenceMember(attachment, parsed.room)] });
3591
+ return;
3592
+ }
3593
+ if (parsed.type === "presence.update") {
3594
+ const config = this.realtimeConfig;
3595
+ if (!config)
3596
+ throw new Error("Realtime config is not loaded");
3597
+ const room = attachment.presenceRooms[parsed.room];
3598
+ if (!room)
3599
+ throw new Error("Presence room has not been joined");
3600
+ room.state = this.parseState(parsed.state, config.limits.maxPresenceStateBytes);
3601
+ room.updatedAt = new Date().toISOString();
3602
+ this.saveAttachment(ws, attachment);
3603
+ this.broadcastPresence(parsed.room, { type: "presence.sync", members: this.roomMembers(parsed.room) });
3604
+ return;
3605
+ }
3606
+ if (parsed.type === "presence.leave") {
3607
+ const member = attachment.presenceRooms[parsed.room] ? this.presenceMember(attachment, parsed.room) : null;
3608
+ delete attachment.presenceRooms[parsed.room];
3609
+ this.saveAttachment(ws, attachment);
3610
+ if (member)
3611
+ this.broadcastPresence(parsed.room, { type: "presence.leave", members: [member] });
3612
+ return;
3613
+ }
3614
+ const unsupported = parsed;
3615
+ this.sendError(ws, unsupported.ref, "unsupported_message", `Unsupported realtime message type: ${String(unsupported.type)}`);
3616
+ }
3617
+ catch (error) {
3618
+ this.sendError(ws, parsed.ref, error instanceof RaybaseDataReadDeniedError ? error.code : "realtime_error", error instanceof Error ? error.message : String(error));
3619
+ }
3620
+ }
3621
+ async webSocketClose(ws) {
3622
+ const attachment = this.attachment(ws);
3623
+ if (!attachment)
3624
+ return;
3625
+ for (const room of Object.keys(attachment.presenceRooms)) {
3626
+ const member = this.presenceMember(attachment, room);
3627
+ delete attachment.presenceRooms[room];
3628
+ this.broadcastPresence(room, { type: "presence.leave", members: [member] });
3629
+ }
3630
+ }
3631
+ async webSocketError(ws) {
3632
+ await this.webSocketClose(ws);
3633
+ }
3634
+ async alarm() {
3635
+ await this.ensureSchema({ migrations: [] });
3636
+ await this.hydrateClock(true);
3637
+ const backupState = this.backupMutationStateSync();
3638
+ if (backupState.gateKind) {
3639
+ await this.ctx.storage.setAlarm(wallNowMs() + 30_000);
3640
+ if (backupState.gateKind === "restore") {
3641
+ for (const ws of this.ctx.getWebSockets())
3642
+ ws.close(1013, "Database restore maintenance is active");
3643
+ }
3644
+ return;
3645
+ }
3646
+ await this.ensureRealtimeState();
3647
+ const config = this.realtimeConfig;
3648
+ if (config)
3649
+ await this.pruneChanges(config);
3650
+ const wallNow = wallNowIso();
3651
+ const appNow = appNowIso();
3652
+ const result = await this.compactJobs({ now: appNow, wallNow, batchSize: defaultCompactionBatchSize, wallClockBudgetMs: 40 });
3653
+ if (result.more)
3654
+ await this.scheduleRealAlarm(wallNowMs() + 30_000);
3655
+ await this.runJobScheduleAlarm(appNow, wallNow);
3656
+ await this.dispatchJobBatchContinuations();
3657
+ await this.sweepParkedJobRuns();
3658
+ await this.runAgentTimerAlarm(appNow, wallNow);
3659
+ await this.runAgentFollowUpAlarm(appNow, wallNow);
3660
+ await this.runAgentSkillSyncAlarm(wallNow);
3661
+ for (const ws of this.ctx.getWebSockets()) {
3662
+ const attachment = this.attachment(ws);
3663
+ if (!attachment) {
3664
+ ws.close(1011, "Missing realtime attachment");
3665
+ continue;
3666
+ }
3667
+ const user = await this.revalidate(attachment);
3668
+ if (!user)
3669
+ ws.close(4001, "Session is no longer valid");
3670
+ else {
3671
+ attachment.user = user;
3672
+ this.saveAttachment(ws, attachment);
3673
+ }
3674
+ }
3675
+ }
3676
+ async schemaInfo() {
3677
+ await this.ensureSchema({ migrations: [] });
3678
+ this.assertReadableSync();
3679
+ const tables = this.ctx.storage.sql
3680
+ .exec(`SELECT "name" FROM "sqlite_master"
3681
+ WHERE "type" = 'table' AND "name" NOT LIKE 'sqlite_%'
3682
+ AND "name" != '_changes'
3683
+ ORDER BY "name"`)
3684
+ .toArray()
3685
+ .filter((table) => !backupOperationalTables.has(table.name) &&
3686
+ !isRaybaseSearchInternalTable(table.name));
3687
+ return {
3688
+ tables: tables.map((table) => {
3689
+ try {
3690
+ const row = this.ctx.storage.sql.exec(`SELECT COUNT(*) as "count" FROM "${table.name.replace(/"/g, '""')}"`).toArray()[0];
3691
+ return { name: table.name, rowCount: Number(row?.count ?? 0) };
3692
+ }
3693
+ catch {
3694
+ return { name: table.name, rowCount: null };
3695
+ }
3696
+ })
3697
+ };
3698
+ }
3699
+ async databaseSize() {
3700
+ await this.ensureSchema({ migrations: [] });
3701
+ this.assertReadableSync();
3702
+ return this.ctx.storage.sql.databaseSize;
3703
+ }
3704
+ async ping() {
3705
+ await this.ensureSchema({ migrations: [] });
3706
+ return { ok: true };
3707
+ }
3708
+ async pingDiagnostics() {
3709
+ const ensured = await this.ensureSchema({ migrations: [] });
3710
+ return { ok: true, schema: { cached: ensured.cached, durationMs: ensured.durationMs } };
3711
+ }
3712
+ async purge() {
3713
+ this.assertMutationAllowedSync();
3714
+ await this.ctx.storage.deleteAll();
3715
+ this.initialized = false;
3716
+ this.ensuredSchemaBundles.clear();
3717
+ this.ctx.storage.transactionSync(() => {
3718
+ this.ensureBackupProtocolSchemaSync();
3719
+ this.ctx.storage.sql.exec(`UPDATE ${sqlIdentifier(backupStateTable)}
3720
+ SET "epoch" = ?, "generation" = 1, "gateKind" = NULL, "gateOperationId" = NULL
3721
+ WHERE "id" = 1`, crypto.randomUUID());
3722
+ });
3723
+ return { ok: true };
3724
+ }
3725
+ async pitrCurrentBookmark() {
3726
+ await this.ensureSchema({ migrations: [] });
3727
+ return this.ctx.storage.getCurrentBookmark();
3728
+ }
3729
+ async pitrBookmarkForTime(epochMs) {
3730
+ await this.ensureSchema({ migrations: [] });
3731
+ return this.ctx.storage.getBookmarkForTime(new Date(epochMs));
3732
+ }
3733
+ async pitrRestore(bookmark) {
3734
+ return this.ctx.storage.onNextSessionRestoreBookmark(bookmark);
3735
+ }
3736
+ async pitrRestartToApply() {
3737
+ this.ctx.abort("Raybase point-in-time restore");
3738
+ throw new Error("Durable Object restart requested");
3739
+ }
3740
+ async backupMutationState() {
3741
+ await this.ensureSchema({ migrations: [] });
3742
+ const state = this.backupMutationStateSync();
3743
+ return { epoch: state.epoch, generation: state.generation, gate: state.gateKind };
3744
+ }
3745
+ async beginBackupSnapshot(input) {
3746
+ await this.ensureSchema({ migrations: [] });
3747
+ const operationId = validateBackupOperationId(input.operationId);
3748
+ const mode = input.mode ?? "optimistic";
3749
+ if (!["small", "optimistic", "write-gated"].includes(mode)) {
3750
+ backupError("BACKUP_LIMIT_EXCEEDED", "Backup snapshot mode is unsupported");
3751
+ }
3752
+ if (mode === "write-gated") {
3753
+ this.ctx.storage.transactionSync(() => {
3754
+ const state = this.backupMutationStateSync();
3755
+ if (state.gateKind &&
3756
+ !(state.gateKind === "snapshot" && state.gateOperationId === operationId)) {
3757
+ backupError("BACKUP_RESTORE_MAINTENANCE_REQUIRED", "Another database maintenance operation is active");
3758
+ }
3759
+ this.ctx.storage.sql.exec(`UPDATE ${sqlIdentifier(backupStateTable)}
3760
+ SET "gateKind" = 'snapshot', "gateOperationId" = ?
3761
+ WHERE "id" = 1`, operationId);
3762
+ });
3763
+ }
3764
+ try {
3765
+ const plan = await this.backupSnapshotPlan();
3766
+ return {
3767
+ operationId,
3768
+ mode,
3769
+ epoch: plan.epoch,
3770
+ generation: plan.generation,
3771
+ databaseBytes: plan.databaseBytes,
3772
+ schemaFingerprint: plan.schemaFingerprint,
3773
+ applicationId: plan.applicationId,
3774
+ userVersion: plan.userVersion,
3775
+ tables: plan.tables.map(({ paginationSqlColumns: _paginationSqlColumns, pageAlias: _pageAlias, references: _references, ...table }) => table),
3776
+ secondaryDdlCount: plan.secondaryDdl.length,
3777
+ cursor: { phase: "metadata", recordOffset: 0 },
3778
+ recommendedMode: plan.databaseBytes <= backupDatabaseProtocolLimits.smallSnapshotDatabaseBytes
3779
+ ? "small"
3780
+ : "optimistic"
3781
+ };
3782
+ }
3783
+ catch (error) {
3784
+ if (mode === "write-gated") {
3785
+ await this.cancelBackupSnapshot({ operationId }).catch(() => undefined);
3786
+ }
3787
+ throw error;
3788
+ }
3789
+ }
3790
+ async readBackupSnapshotPage(input) {
3791
+ await this.ensureSchema({ migrations: [] });
3792
+ const plan = await this.backupSnapshotPlan();
3793
+ this.assertSnapshotMatches(plan, input);
3794
+ const maxBytes = Math.min(Math.max(Math.trunc(input.maxBytes ?? backupDatabaseProtocolLimits.defaultPageBytes), 1), backupDatabaseProtocolLimits.maximumPageBytes);
3795
+ const maxRows = Math.min(Math.max(Math.trunc(input.maxRows ?? backupDatabaseProtocolLimits.defaultPageRows), 1), backupDatabaseProtocolLimits.maximumPageRows);
3796
+ const chunks = [];
3797
+ let nextCursor = input.cursor;
3798
+ let rowCount = 0;
3799
+ let encodedBytes = 0;
3800
+ while (encodedBytes < maxBytes && nextCursor.phase !== "done") {
3801
+ const cursor = nextCursor;
3802
+ let record;
3803
+ let afterRecord;
3804
+ let rowRecord = false;
3805
+ if (cursor.phase === "metadata") {
3806
+ record = this.snapshotMetadataRecord(plan);
3807
+ afterRecord = plan.tables.length
3808
+ ? { phase: "table-schema", tableIndex: 0, recordOffset: 0 }
3809
+ : plan.secondaryDdl.length
3810
+ ? { phase: "secondary-ddl", ddlIndex: 0, recordOffset: 0 }
3811
+ : { phase: "done" };
3812
+ }
3813
+ else if (cursor.phase === "table-schema") {
3814
+ const table = plan.tables[cursor.tableIndex];
3815
+ if (!table)
3816
+ backupError("BACKUP_ARCHIVE_CORRUPT", "Snapshot table cursor is out of range");
3817
+ record = this.snapshotTableSchemaRecord(table);
3818
+ afterRecord = { phase: "table-ddl", tableIndex: cursor.tableIndex, recordOffset: 0 };
3819
+ }
3820
+ else if (cursor.phase === "table-ddl") {
3821
+ const table = plan.tables[cursor.tableIndex];
3822
+ if (!table)
3823
+ backupError("BACKUP_ARCHIVE_CORRUPT", "Snapshot table DDL cursor is out of range");
3824
+ record = { kind: "ddl", sql: table.createSql };
3825
+ afterRecord = { phase: "table-start", tableIndex: cursor.tableIndex, recordOffset: 0 };
3826
+ }
3827
+ else if (cursor.phase === "table-start") {
3828
+ const table = plan.tables[cursor.tableIndex];
3829
+ if (!table)
3830
+ backupError("BACKUP_ARCHIVE_CORRUPT", "Snapshot table-start cursor is out of range");
3831
+ record = { kind: "table-start", tableId: table.tableId };
3832
+ afterRecord = {
3833
+ phase: "table-rows",
3834
+ tableIndex: cursor.tableIndex,
3835
+ after: null,
3836
+ recordOffset: 0
3837
+ };
3838
+ }
3839
+ else if (cursor.phase === "table-rows") {
3840
+ const table = plan.tables[cursor.tableIndex];
3841
+ if (!table)
3842
+ backupError("BACKUP_ARCHIVE_CORRUPT", "Snapshot row cursor is out of range");
3843
+ // Querying one row at a time is deliberate. SQLite cannot constrain a
3844
+ // result by encoded bytes, so LIMIT maxRows could materialize
3845
+ // maxRows × 16 MiB before Core had a chance to enforce the page bound.
3846
+ const row = this.snapshotRowsSync(table, cursor.after, 1)[0];
3847
+ if (!row) {
3848
+ nextCursor = { phase: "table-end", tableIndex: cursor.tableIndex, recordOffset: 0 };
3849
+ continue;
3850
+ }
3851
+ record = row.record;
3852
+ afterRecord = {
3853
+ phase: "table-rows",
3854
+ tableIndex: cursor.tableIndex,
3855
+ after: row.key,
3856
+ recordOffset: 0
3857
+ };
3858
+ rowRecord = true;
3859
+ }
3860
+ else if (cursor.phase === "table-end") {
3861
+ const table = plan.tables[cursor.tableIndex];
3862
+ if (!table)
3863
+ backupError("BACKUP_ARCHIVE_CORRUPT", "Snapshot table-end cursor is out of range");
3864
+ record = { kind: "table-end", tableId: table.tableId };
3865
+ afterRecord = cursor.tableIndex + 1 < plan.tables.length
3866
+ ? { phase: "table-schema", tableIndex: cursor.tableIndex + 1, recordOffset: 0 }
3867
+ : plan.secondaryDdl.length
3868
+ ? { phase: "secondary-ddl", ddlIndex: 0, recordOffset: 0 }
3869
+ : { phase: "done" };
3870
+ }
3871
+ else if (cursor.phase === "secondary-ddl") {
3872
+ const sql = plan.secondaryDdl[cursor.ddlIndex];
3873
+ if (!sql)
3874
+ backupError("BACKUP_ARCHIVE_CORRUPT", "Snapshot DDL cursor is out of range");
3875
+ record = { kind: "ddl", sql };
3876
+ afterRecord = cursor.ddlIndex + 1 < plan.secondaryDdl.length
3877
+ ? { phase: "secondary-ddl", ddlIndex: cursor.ddlIndex + 1, recordOffset: 0 }
3878
+ : { phase: "done" };
3879
+ }
3880
+ else {
3881
+ backupError("BACKUP_ARCHIVE_CORRUPT", "Snapshot cursor phase is unsupported");
3882
+ }
3883
+ const encoded = encodeBackupLogicalRecord(record);
3884
+ if (!Number.isSafeInteger(cursor.recordOffset) ||
3885
+ cursor.recordOffset < 0 ||
3886
+ cursor.recordOffset >= encoded.byteLength) {
3887
+ backupError("BACKUP_ARCHIVE_CORRUPT", "Snapshot record offset is out of range");
3888
+ }
3889
+ const available = maxBytes - encodedBytes;
3890
+ const end = Math.min(cursor.recordOffset + available, encoded.byteLength);
3891
+ chunks.push(encoded.slice(cursor.recordOffset, end));
3892
+ encodedBytes += end - cursor.recordOffset;
3893
+ if (end < encoded.byteLength) {
3894
+ nextCursor = { ...cursor, recordOffset: end };
3895
+ break;
3896
+ }
3897
+ nextCursor = afterRecord;
3898
+ if (rowRecord) {
3899
+ rowCount += 1;
3900
+ if (rowCount >= maxRows)
3901
+ break;
3902
+ }
3903
+ }
3904
+ return {
3905
+ epoch: plan.epoch,
3906
+ generation: plan.generation,
3907
+ schemaFingerprint: plan.schemaFingerprint,
3908
+ chunks,
3909
+ encodedBytes,
3910
+ rowCount,
3911
+ nextCursor,
3912
+ done: nextCursor.phase === "done"
3913
+ };
3914
+ }
3915
+ async readBackupSnapshotSmall(input) {
3916
+ await this.ensureSchema({ migrations: [] });
3917
+ const plan = await this.backupSnapshotPlan();
3918
+ this.assertSnapshotMatches(plan, input);
3919
+ if (plan.databaseBytes > backupDatabaseProtocolLimits.smallSnapshotDatabaseBytes) {
3920
+ backupError("BACKUP_LIMIT_EXCEEDED", "Database exceeds the bounded small-snapshot size");
3921
+ }
3922
+ const maxBytes = Math.min(Math.max(Math.trunc(input.maxBytes ?? backupDatabaseProtocolLimits.smallSnapshotLogicalBytes), 1), backupDatabaseProtocolLimits.smallSnapshotLogicalBytes);
3923
+ const records = [];
3924
+ let encodedBytes = 0;
3925
+ let recordCount = 0;
3926
+ let rowCount = 0;
3927
+ let tableCount = 0;
3928
+ const append = (record) => {
3929
+ const encoded = encodeBackupLogicalRecord(record);
3930
+ if (encodedBytes + encoded.byteLength > maxBytes) {
3931
+ backupError("BACKUP_LIMIT_EXCEEDED", "Logical snapshot exceeds the bounded small-snapshot size");
3932
+ }
3933
+ records.push(encoded);
3934
+ encodedBytes += encoded.byteLength;
3935
+ recordCount += 1;
3936
+ };
3937
+ append(this.snapshotMetadataRecord(plan));
3938
+ for (const table of plan.tables) {
3939
+ append(this.snapshotTableSchemaRecord(table));
3940
+ append({ kind: "ddl", sql: table.createSql });
3941
+ append({ kind: "table-start", tableId: table.tableId });
3942
+ let after = null;
3943
+ while (true) {
3944
+ const rows = this.snapshotRowsSync(table, after, backupDatabaseProtocolLimits.maximumPageRows);
3945
+ if (!rows.length)
3946
+ break;
3947
+ for (const row of rows) {
3948
+ append(row.record);
3949
+ after = row.key;
3950
+ rowCount += 1;
3951
+ }
3952
+ }
3953
+ append({ kind: "table-end", tableId: table.tableId });
3954
+ tableCount += 1;
3955
+ }
3956
+ for (const sql of plan.secondaryDdl)
3957
+ append({ kind: "ddl", sql });
3958
+ const plaintextSha256 = await sha256Hex(concatBackupBytes(records));
3959
+ const refreshedPlan = await this.backupSnapshotPlan();
3960
+ this.assertSnapshotMatches(refreshedPlan, input);
3961
+ const endRecord = encodeBackupLogicalRecord({
3962
+ kind: "logical-end",
3963
+ recordCount,
3964
+ rowCount,
3965
+ tableCount,
3966
+ plaintextSha256
3967
+ });
3968
+ if (encodedBytes + endRecord.byteLength > maxBytes) {
3969
+ backupError("BACKUP_LIMIT_EXCEEDED", "Logical snapshot end record exceeds the bounded small-snapshot size");
3970
+ }
3971
+ records.push(endRecord);
3972
+ encodedBytes += endRecord.byteLength;
3973
+ return {
3974
+ epoch: plan.epoch,
3975
+ generation: plan.generation,
3976
+ schemaFingerprint: plan.schemaFingerprint,
3977
+ records,
3978
+ encodedBytes,
3979
+ recordCount,
3980
+ rowCount,
3981
+ tableCount,
3982
+ plaintextSha256
3983
+ };
3984
+ }
3985
+ async finishBackupSnapshot(input) {
3986
+ await this.ensureSchema({ migrations: [] });
3987
+ const plan = await this.backupSnapshotPlan();
3988
+ this.assertSnapshotMatches(plan, input);
3989
+ const record = encodeBackupLogicalRecord({
3990
+ kind: "logical-end",
3991
+ recordCount: backupNonNegativeInteger(input.recordCount, "Snapshot record count"),
3992
+ rowCount: backupNonNegativeInteger(input.rowCount, "Snapshot row count"),
3993
+ tableCount: backupNonNegativeInteger(input.tableCount, "Snapshot table count"),
3994
+ plaintextSha256: validateBackupDigest(input.plaintextSha256, "Snapshot plaintext digest")
3995
+ });
3996
+ const operationId = validateBackupOperationId(input.operationId);
3997
+ this.ctx.storage.transactionSync(() => {
3998
+ const state = this.backupMutationStateSync();
3999
+ if (state.gateKind === "snapshot" && state.gateOperationId === operationId) {
4000
+ this.ctx.storage.sql.exec(`UPDATE ${sqlIdentifier(backupStateTable)}
4001
+ SET "gateKind" = NULL, "gateOperationId" = NULL
4002
+ WHERE "id" = 1`);
4003
+ }
4004
+ });
4005
+ return { record };
4006
+ }
4007
+ async cancelBackupSnapshot(input) {
4008
+ await this.ensureSchema({ migrations: [] });
4009
+ const operationId = validateBackupOperationId(input.operationId);
4010
+ this.ctx.storage.transactionSync(() => {
4011
+ const state = this.backupMutationStateSync();
4012
+ if (state.gateKind === "snapshot" && state.gateOperationId === operationId) {
4013
+ this.ctx.storage.sql.exec(`UPDATE ${sqlIdentifier(backupStateTable)}
4014
+ SET "gateKind" = NULL, "gateOperationId" = NULL
4015
+ WHERE "id" = 1`);
4016
+ }
4017
+ });
4018
+ return { ok: true };
4019
+ }
4020
+ async backupRestoreStatus() {
4021
+ this.ensureBackupProtocolSchemaSync();
4022
+ return restoreStatusFromRow(this.backupRestoreJournalSync());
4023
+ }
4024
+ async backupRestoreAuthenticateSession(input) {
4025
+ this.ensureBackupProtocolSchemaSync();
4026
+ const status = restoreStatusFromRow(this.backupRestoreJournalSync());
4027
+ const state = this.backupMutationStateSync();
4028
+ if (!status.active || state.gateKind !== "restore")
4029
+ return null;
4030
+ if (typeof input.sessionToken !== "string" ||
4031
+ input.sessionToken.length < 1 ||
4032
+ input.sessionToken.length > 4_096) {
4033
+ return null;
4034
+ }
4035
+ const row = this.ctx.storage.sql
4036
+ .exec(`SELECT "sessionId", "sessionToken", "expiresAt", "userId", "email", "name",
4037
+ "emailVerified", "operatorRole", "appRoles", "banned"
4038
+ FROM ${sqlIdentifier(backupRestoreSessionsTable)}
4039
+ WHERE "operationId" = ? AND "sessionToken" = ?
4040
+ LIMIT 1`, status.operationId, input.sessionToken)
4041
+ .toArray()[0];
4042
+ if (!row || this.expiresAtMs(row.expiresAt) <= Date.now() || Boolean(row.banned))
4043
+ return null;
4044
+ return {
4045
+ user: withHostUserRoleCeiling({
4046
+ id: row.userId,
4047
+ email: row.email,
4048
+ name: row.name,
4049
+ operatorRole: normalizeStoredAdminRole(row.operatorRole),
4050
+ appRoles: this.parseRoles(row.appRoles),
4051
+ emailVerified: row.emailVerified === 1 || row.emailVerified === "1",
4052
+ disabled: false
4053
+ }),
4054
+ session: {
4055
+ id: row.sessionId,
4056
+ token: row.sessionToken,
4057
+ userId: row.userId,
4058
+ expiresAt: row.expiresAt
4059
+ }
4060
+ };
4061
+ }
4062
+ async beginBackupRestore(input) {
4063
+ await this.ensureSchema({ migrations: [] });
4064
+ const operationId = validateBackupOperationId(input.operationId, "Restore operation id");
4065
+ validateBackupOperationId(input.archiveId, "Restore archive id");
4066
+ validateBackupOperationId(input.targetId, "Restore target id");
4067
+ validateBackupDigest(input.sourceSha256, "Restore source digest");
4068
+ validateBackupDigest(input.plaintextSha256, "Restore plaintext digest");
4069
+ const expectedRecordCount = backupNonNegativeInteger(input.expectedRecordCount, "Expected restore record count");
4070
+ const expectedRowCount = backupNonNegativeInteger(input.expectedRowCount, "Expected restore row count");
4071
+ const expectedTableCount = backupNonNegativeInteger(input.expectedTableCount, "Expected restore table count");
4072
+ let status = null;
4073
+ let failure = null;
4074
+ await this.ctx.blockConcurrencyWhile(async () => {
4075
+ try {
4076
+ const undoBookmark = await this.ctx.storage.getCurrentBookmark();
4077
+ this.ctx.storage.transactionSync(() => {
4078
+ this.ensureBackupProtocolSchemaSync();
4079
+ const existing = this.backupRestoreJournalSync(operationId);
4080
+ if (existing) {
4081
+ if (existing.sourceSha256 !== input.sourceSha256 ||
4082
+ existing.archiveId !== input.archiveId ||
4083
+ existing.targetId !== input.targetId ||
4084
+ existing.expectedRecordCount !== expectedRecordCount ||
4085
+ existing.expectedRowCount !== expectedRowCount ||
4086
+ existing.expectedTableCount !== expectedTableCount ||
4087
+ existing.plaintextSha256 !== input.plaintextSha256) {
4088
+ backupError("BACKUP_ARCHIVE_IDENTITY_MISMATCH", "Restore operation id was reused for a different archive");
4089
+ }
4090
+ status = restoreStatusFromRow(existing);
4091
+ return;
4092
+ }
4093
+ const state = this.backupMutationStateSync();
4094
+ const active = this.backupRestoreJournalSync();
4095
+ if (active ||
4096
+ (state.gateKind && !(state.gateKind === "restore" && state.gateOperationId === operationId))) {
4097
+ backupError("BACKUP_RESTORE_MAINTENANCE_REQUIRED", "Another database maintenance operation is active");
4098
+ }
4099
+ const now = input.createdAt ?? new Date().toISOString();
4100
+ this.ctx.storage.sql.exec(`INSERT INTO ${sqlIdentifier(backupRestoreJournalTable)}
4101
+ ("operationId", "sourceSha256", "archiveId", "targetId", "undoBookmark", "phase",
4102
+ "expectedRecordCount", "expectedRowCount", "expectedTableCount", "plaintextSha256",
4103
+ "nextBatchIndex", "lastBatchSha256", "recordCount", "rowCount", "tableCount",
4104
+ "currentTableId", "schemaReplaced", "metadataSeen", "logicalEndSeen", "error", "createdAt", "updatedAt")
4105
+ VALUES (?, ?, ?, ?, ?, 'applying', ?, ?, ?, ?, 0, NULL, 0, 0, 0, NULL, 0, 0, 0, NULL, ?, ?)`, operationId, input.sourceSha256, input.archiveId, input.targetId, undoBookmark, expectedRecordCount, expectedRowCount, expectedTableCount, input.plaintextSha256, now, now);
4106
+ this.ctx.storage.sql.exec(`DELETE FROM ${sqlIdentifier(backupRestoreSessionsTable)}
4107
+ WHERE "operationId" = ?`, operationId);
4108
+ if (this.tableExists("_auth_sessions") && this.tableExists("_auth_users")) {
4109
+ this.ctx.storage.sql.exec(`INSERT INTO ${sqlIdentifier(backupRestoreSessionsTable)}
4110
+ ("operationId", "sessionId", "sessionToken", "expiresAt", "userId", "email", "name",
4111
+ "emailVerified", "operatorRole", "appRoles", "banned")
4112
+ SELECT ?, s."id", s."token", s."expiresAt", u."id", u."email", u."name",
4113
+ u."emailVerified", u."operatorRole", u."appRoles", u."banned"
4114
+ FROM "_auth_sessions" s INNER JOIN "_auth_users" u ON u."id" = s."userId"
4115
+ WHERE u."operatorRole" IN ('admin', 'owner')`, operationId);
4116
+ }
4117
+ this.ctx.storage.sql.exec(`UPDATE ${sqlIdentifier(backupStateTable)}
4118
+ SET "gateKind" = 'restore', "gateOperationId" = ?
4119
+ WHERE "id" = 1`, operationId);
4120
+ status = restoreStatusFromRow(this.backupRestoreJournalSync(operationId));
4121
+ });
4122
+ }
4123
+ catch (error) {
4124
+ failure = error;
4125
+ }
4126
+ });
4127
+ if (failure)
4128
+ throw failure;
4129
+ if (!status)
4130
+ throw new Error("Restore journal was not created");
4131
+ return status;
4132
+ }
4133
+ async applyBackupRestoreBatch(input) {
4134
+ this.ensureBackupProtocolSchemaSync();
4135
+ const operationId = validateBackupOperationId(input.operationId, "Restore operation id");
4136
+ const batchIndex = backupNonNegativeInteger(input.batchIndex, "Restore batch index");
4137
+ validateBackupDigest(input.batchSha256, "Restore batch digest");
4138
+ if (!Array.isArray(input.records) ||
4139
+ input.records.length < 1 ||
4140
+ input.records.length > backupDatabaseProtocolLimits.maximumRestoreBatchRecords) {
4141
+ backupError("BACKUP_LIMIT_EXCEEDED", "Restore batch record count is outside its bound");
4142
+ }
4143
+ const encoded = input.records.map(encodeBackupLogicalRecord);
4144
+ const encodedBytes = encoded.reduce((total, record) => total + record.byteLength, 0);
4145
+ if (encodedBytes > backupDatabaseProtocolLimits.maximumRestoreBatchBytes &&
4146
+ !(encoded.length === 1 && encodedBytes <= backupFormatLimits.logicalRecordBytes + 6)) {
4147
+ backupError("BACKUP_LIMIT_EXCEEDED", "Restore batch exceeds its encoded byte bound");
4148
+ }
4149
+ const actualBatchSha256 = await sha256Hex(concatBackupBytes(encoded));
4150
+ if (actualBatchSha256 !== input.batchSha256) {
4151
+ backupError("BACKUP_ARCHIVE_CORRUPT", "Restore batch digest does not match its records");
4152
+ }
4153
+ let result = null;
4154
+ this.ctx.storage.sql.exec("PRAGMA foreign_keys=OFF");
4155
+ try {
4156
+ this.mutateSync(() => {
4157
+ const journal = this.backupRestoreJournalSync(operationId);
4158
+ if (!journal) {
4159
+ backupError("BACKUP_RESTORE_MAINTENANCE_REQUIRED", "Restore journal is missing");
4160
+ }
4161
+ if (journal.phase !== "applying") {
4162
+ backupError("BACKUP_RESTORE_MAINTENANCE_REQUIRED", `Restore is in ${journal.phase} phase`);
4163
+ }
4164
+ if (batchIndex === journal.nextBatchIndex - 1) {
4165
+ if (journal.lastBatchSha256 !== input.batchSha256) {
4166
+ backupError("BACKUP_ARCHIVE_CORRUPT", "Repeated restore batch has a different digest");
4167
+ }
4168
+ result = {
4169
+ operationId,
4170
+ nextBatchIndex: journal.nextBatchIndex,
4171
+ duplicate: true,
4172
+ phase: journal.phase,
4173
+ recordCount: Number(journal.recordCount),
4174
+ rowCount: Number(journal.rowCount),
4175
+ tableCount: Number(journal.tableCount)
4176
+ };
4177
+ return;
4178
+ }
4179
+ if (batchIndex !== journal.nextBatchIndex) {
4180
+ backupError("BACKUP_ARCHIVE_CORRUPT", "Restore batch index is not the next journal checkpoint");
4181
+ }
4182
+ if (!journal.schemaReplaced) {
4183
+ this.replaceRestorableSchemaSync();
4184
+ journal.schemaReplaced = 1;
4185
+ }
4186
+ for (const record of input.records)
4187
+ this.applyRestoreRecordSync(journal, record);
4188
+ journal.nextBatchIndex += 1;
4189
+ journal.lastBatchSha256 = input.batchSha256;
4190
+ journal.updatedAt = new Date().toISOString();
4191
+ this.ctx.storage.sql.exec(`UPDATE ${sqlIdentifier(backupRestoreJournalTable)}
4192
+ SET "nextBatchIndex" = ?, "lastBatchSha256" = ?,
4193
+ "recordCount" = ?, "rowCount" = ?, "tableCount" = ?,
4194
+ "currentTableId" = ?, "schemaReplaced" = ?,
4195
+ "metadataSeen" = ?, "logicalEndSeen" = ?, "updatedAt" = ?
4196
+ WHERE "operationId" = ?`, journal.nextBatchIndex, journal.lastBatchSha256, journal.recordCount, journal.rowCount, journal.tableCount, journal.currentTableId, journal.schemaReplaced, journal.metadataSeen, journal.logicalEndSeen, journal.updatedAt, operationId);
4197
+ result = {
4198
+ operationId,
4199
+ nextBatchIndex: journal.nextBatchIndex,
4200
+ duplicate: false,
4201
+ phase: journal.phase,
4202
+ recordCount: journal.recordCount,
4203
+ rowCount: journal.rowCount,
4204
+ tableCount: journal.tableCount
4205
+ };
4206
+ }, { kind: "restore", operationId });
4207
+ }
4208
+ catch (error) {
4209
+ await this.failBackupRestore({
4210
+ operationId,
4211
+ error: error instanceof Error ? error.message : String(error)
4212
+ }).catch(() => undefined);
4213
+ throw error;
4214
+ }
4215
+ finally {
4216
+ this.ctx.storage.sql.exec("PRAGMA foreign_keys=ON");
4217
+ }
4218
+ if (!result)
4219
+ throw new Error("Restore batch did not produce a journal checkpoint");
4220
+ return result;
4221
+ }
4222
+ async finishBackupRestore(input) {
4223
+ this.ensureBackupProtocolSchemaSync();
4224
+ const operationId = validateBackupOperationId(input.operationId, "Restore operation id");
4225
+ const journal = this.backupRestoreJournalSync(operationId);
4226
+ if (!journal)
4227
+ backupError("BACKUP_RESTORE_MAINTENANCE_REQUIRED", "Restore journal is missing");
4228
+ if (journal.phase === "completed")
4229
+ return restoreStatusFromRow(journal);
4230
+ if (journal.phase !== "applying" ||
4231
+ !journal.logicalEndSeen ||
4232
+ journal.currentTableId ||
4233
+ journal.recordCount !== journal.expectedRecordCount ||
4234
+ journal.rowCount !== journal.expectedRowCount ||
4235
+ journal.tableCount !== journal.expectedTableCount) {
4236
+ backupError("BACKUP_RESTORE_MAINTENANCE_REQUIRED", "Restore has not reached its verified logical end");
4237
+ }
4238
+ const restoredSchemas = this.ctx.storage.sql
4239
+ .exec(`SELECT "tableName" FROM ${sqlIdentifier(backupRestoreSchemasTable)}
4240
+ WHERE "operationId" = ? ORDER BY "tableId"`, operationId)
4241
+ .toArray();
4242
+ const expectedTables = restoredSchemas.map((schema) => schema.tableName).sort();
4243
+ const actualTables = this.ctx.storage.sql
4244
+ .exec(`SELECT "name" FROM "sqlite_master"
4245
+ WHERE "type" = 'table'
4246
+ AND "name" NOT LIKE 'sqlite_%'
4247
+ AND "name" NOT LIKE '_cf_%'
4248
+ ORDER BY "name"`)
4249
+ .toArray()
4250
+ .map((table) => table.name)
4251
+ .filter((name) => !backupOperationalTables.has(name) && !isRaybaseSearchInternalTable(name))
4252
+ .sort();
4253
+ if (JSON.stringify(actualTables) !== JSON.stringify(expectedTables)) {
4254
+ await this.failBackupRestore({ operationId, error: "Restored SQLite table-set integrity check failed" });
4255
+ backupError("BACKUP_ARCHIVE_CORRUPT", "Restored SQLite database contains an unexpected table set");
4256
+ }
4257
+ const restoredRows = restoredSchemas.reduce((total, schema) => {
4258
+ const count = this.ctx.storage.sql
4259
+ .exec(`SELECT COUNT(*) AS "count" FROM ${sqlIdentifier(schema.tableName)}`)
4260
+ .toArray()[0]?.count;
4261
+ return total + Number(count ?? 0);
4262
+ }, 0);
4263
+ if (restoredRows !== journal.expectedRowCount) {
4264
+ await this.failBackupRestore({ operationId, error: "Restored SQLite row-count integrity check failed" });
4265
+ backupError("BACKUP_ARCHIVE_CORRUPT", "Restored SQLite database failed its row-count integrity check");
4266
+ }
4267
+ let foreignKeyFailures = [];
4268
+ try {
4269
+ foreignKeyFailures = this.ctx.storage.sql
4270
+ .exec("PRAGMA foreign_key_check")
4271
+ .toArray();
4272
+ }
4273
+ catch (error) {
4274
+ if (!String(error).includes("SQLITE_AUTH"))
4275
+ throw error;
4276
+ // SqlStorage currently rejects global integrity PRAGMAs. The bounded
4277
+ // table/count checks above remain mandatory; Plan 5 status reports this
4278
+ // platform limit instead of claiming native SQLite integrity_check.
4279
+ }
4280
+ if (foreignKeyFailures.length) {
4281
+ await this.failBackupRestore({ operationId, error: "SQLite foreign-key check failed" });
4282
+ backupError("BACKUP_ARCHIVE_CORRUPT", "Restored SQLite database failed its foreign-key check");
4283
+ }
4284
+ this.ctx.storage.transactionSync(() => {
4285
+ const current = this.backupRestoreJournalSync(operationId);
4286
+ if (!current)
4287
+ backupError("BACKUP_RESTORE_MAINTENANCE_REQUIRED", "Restore journal disappeared");
4288
+ this.ctx.storage.sql.exec(`UPDATE ${sqlIdentifier(backupRestoreJournalTable)}
4289
+ SET "phase" = 'completed', "error" = NULL, "updatedAt" = ?
4290
+ WHERE "operationId" = ?`, new Date().toISOString(), operationId);
4291
+ this.ctx.storage.sql.exec(`DELETE FROM ${sqlIdentifier(backupRestoreSchemasTable)}
4292
+ WHERE "operationId" = ?`, operationId);
4293
+ this.ctx.storage.sql.exec(`DELETE FROM ${sqlIdentifier(backupRestoreSessionsTable)}
4294
+ WHERE "operationId" = ?`, operationId);
4295
+ this.ctx.storage.sql.exec(`UPDATE ${sqlIdentifier(backupStateTable)}
4296
+ SET "gateKind" = NULL, "gateOperationId" = NULL
4297
+ WHERE "id" = 1 AND "gateKind" = 'restore' AND "gateOperationId" = ?`, operationId);
4298
+ });
4299
+ this.initialized = true;
4300
+ this.ensuredSchemaBundles.clear();
4301
+ return restoreStatusFromRow(this.backupRestoreJournalSync(operationId));
4302
+ }
4303
+ async failBackupRestore(input) {
4304
+ this.ensureBackupProtocolSchemaSync();
4305
+ const operationId = validateBackupOperationId(input.operationId, "Restore operation id");
4306
+ const message = String(input.error).slice(0, 1_024);
4307
+ this.ctx.storage.transactionSync(() => {
4308
+ const journal = this.backupRestoreJournalSync(operationId);
4309
+ if (!journal)
4310
+ backupError("BACKUP_RESTORE_MAINTENANCE_REQUIRED", "Restore journal is missing");
4311
+ if (journal.phase === "completed")
4312
+ return;
4313
+ this.ctx.storage.sql.exec(`UPDATE ${sqlIdentifier(backupRestoreJournalTable)}
4314
+ SET "phase" = 'failed', "error" = ?, "updatedAt" = ?
4315
+ WHERE "operationId" = ?`, message, new Date().toISOString(), operationId);
4316
+ });
4317
+ return restoreStatusFromRow(this.backupRestoreJournalSync(operationId));
4318
+ }
4319
+ async markBackupRestoreRollbackRequested(input) {
4320
+ this.ensureBackupProtocolSchemaSync();
4321
+ const operationId = validateBackupOperationId(input.operationId, "Restore operation id");
4322
+ this.ctx.storage.transactionSync(() => {
4323
+ const journal = this.backupRestoreJournalSync(operationId);
4324
+ if (!journal)
4325
+ backupError("BACKUP_RESTORE_MAINTENANCE_REQUIRED", "Restore journal is missing");
4326
+ if (journal.phase === "completed")
4327
+ return;
4328
+ this.ctx.storage.sql.exec(`UPDATE ${sqlIdentifier(backupRestoreJournalTable)}
4329
+ SET "phase" = 'rollback-requested', "updatedAt" = ?
4330
+ WHERE "operationId" = ?`, new Date().toISOString(), operationId);
4331
+ });
4332
+ return restoreStatusFromRow(this.backupRestoreJournalSync(operationId));
4333
+ }
4334
+ async prepareAuditArchive(input) {
4335
+ await this.ensureSchema({ migrations: [] });
4336
+ const window = validateAuditArchiveWindow(input);
4337
+ const limitBytes = input.limitBytes ?? defaultAuditArchiveLimitBytes;
4338
+ if (!Number.isInteger(limitBytes) || limitBytes <= 0) {
4339
+ throw new Error("Audit archive limitBytes must be a positive integer");
4340
+ }
4341
+ const existing = this.ctx.storage.sql
4342
+ .exec(`SELECT "windowStart", "windowEnd", "objectKey", "rowCount", "firstCreatedAt", "lastCreatedAt", "sha256", "archivedAt", "prunedAt"
4343
+ FROM "_audit_archives"
4344
+ WHERE "windowStart" = ?`, window.windowStart)
4345
+ .toArray()[0];
4346
+ if (existing && existing.windowEnd !== window.windowEnd) {
4347
+ throw new Error("Audit archive manifest windowEnd mismatch");
4348
+ }
4349
+ if (existing?.prunedAt) {
4350
+ return { empty: true, windowStart: window.windowStart, windowEnd: window.windowEnd };
4351
+ }
4352
+ const startSql = isoToSqliteTimestamp(window.windowStart);
4353
+ const endSql = isoToSqliteTimestamp(window.windowEnd);
4354
+ const rows = this.ctx.storage.sql
4355
+ .exec(`SELECT "id", "actorId", "action", "targetType", "targetId", "metadata", "createdAt"
4356
+ FROM "_audit"
4357
+ WHERE "createdAt" >= ? AND "createdAt" < ?
4358
+ ORDER BY "createdAt" ASC, "id" ASC`, startSql, endSql)
4359
+ .toArray();
4360
+ if (!rows.length) {
4361
+ if (existing) {
4362
+ return { empty: false, alreadyArchived: true, ...archiveMetadataFromManifest(existing) };
4363
+ }
4364
+ return { empty: true, windowStart: window.windowStart, windowEnd: window.windowEnd };
4365
+ }
4366
+ const body = serializeAuditArchiveRows(rows);
4367
+ const bodyBytes = new TextEncoder().encode(body).byteLength;
4368
+ if (bodyBytes > limitBytes) {
4369
+ throw new Error(`Audit archive body is ${bodyBytes} bytes, exceeding limitBytes ${limitBytes}`);
4370
+ }
4371
+ const prepared = {
4372
+ empty: false,
4373
+ windowStart: window.windowStart,
4374
+ windowEnd: window.windowEnd,
4375
+ objectKey: auditArchiveObjectKey(window.windowStart),
4376
+ body,
4377
+ rowCount: rows.length,
4378
+ firstCreatedAt: rows[0].createdAt,
4379
+ lastCreatedAt: rows[rows.length - 1].createdAt,
4380
+ sha256: await sha256Hex(body)
4381
+ };
4382
+ if (existing) {
4383
+ assertArchiveMetadataMatches(archiveMetadataFromManifest(existing), prepared);
4384
+ }
4385
+ return prepared;
4386
+ }
4387
+ async confirmAuditArchive(input) {
4388
+ await this.ensureSchema({ migrations: [] });
4389
+ const expected = assertConfirmInput(input);
4390
+ const archivedAt = new Date().toISOString();
4391
+ this.mutateSync(() => {
4392
+ const existing = this.ctx.storage.sql
4393
+ .exec(`SELECT "windowStart", "windowEnd", "objectKey", "rowCount", "firstCreatedAt", "lastCreatedAt", "sha256", "archivedAt", "prunedAt"
4394
+ FROM "_audit_archives"
4395
+ WHERE "windowStart" = ?`, expected.windowStart)
4396
+ .toArray()[0];
4397
+ if (existing) {
4398
+ assertArchiveMetadataMatches(archiveMetadataFromManifest(existing), expected);
4399
+ return;
4400
+ }
4401
+ this.ctx.storage.sql.exec(`INSERT INTO "_audit_archives"
4402
+ ("windowStart", "windowEnd", "objectKey", "rowCount", "firstCreatedAt", "lastCreatedAt", "sha256", "archivedAt", "prunedAt")
4403
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL)`, expected.windowStart, expected.windowEnd, expected.objectKey, expected.rowCount, expected.firstCreatedAt, expected.lastCreatedAt, expected.sha256, archivedAt);
4404
+ });
4405
+ return { ok: true };
4406
+ }
4407
+ async pruneAuditArchive(input) {
4408
+ await this.ensureSchema({ migrations: [] });
4409
+ const window = validateAuditArchiveWindow(input);
4410
+ if (input.objectKey !== auditArchiveObjectKey(window.windowStart)) {
4411
+ throw new Error("Audit archive objectKey does not match the window");
4412
+ }
4413
+ const startSql = isoToSqliteTimestamp(window.windowStart);
4414
+ const endSql = isoToSqliteTimestamp(window.windowEnd);
4415
+ const prunedAt = new Date().toISOString();
4416
+ this.mutateSync(() => {
4417
+ const manifest = this.ctx.storage.sql
4418
+ .exec(`SELECT "windowStart", "windowEnd", "objectKey", "rowCount", "firstCreatedAt", "lastCreatedAt", "sha256", "archivedAt", "prunedAt"
4419
+ FROM "_audit_archives"
4420
+ WHERE "windowStart" = ?`, window.windowStart)
4421
+ .toArray()[0];
4422
+ if (!manifest)
4423
+ throw new Error("Audit archive manifest is missing");
4424
+ if (manifest.windowEnd !== window.windowEnd || manifest.objectKey !== input.objectKey || manifest.sha256 !== input.sha256) {
4425
+ throw new Error("Audit archive manifest mismatch");
4426
+ }
4427
+ if (manifest.prunedAt)
4428
+ return;
4429
+ this.ctx.storage.sql.exec(`DELETE FROM "_audit"
4430
+ WHERE "createdAt" >= ? AND "createdAt" < ?`, startSql, endSql);
4431
+ this.ctx.storage.sql.exec(`UPDATE "_audit_archives" SET "prunedAt" = ? WHERE "windowStart" = ?`, prunedAt, window.windowStart);
4432
+ });
4433
+ return { ok: true };
4434
+ }
4435
+ async dump() {
4436
+ await this.ensureSchema({ migrations: [] });
4437
+ this.assertReadableSync();
4438
+ const rawTables = this.ctx.storage.sql
4439
+ .exec(`SELECT "name", "sql" FROM "sqlite_master"
4440
+ WHERE "type" = 'table'
4441
+ AND "name" NOT LIKE 'sqlite_%'
4442
+ AND "name" NOT LIKE '_cf_%'
4443
+ AND "name" != '_changes'
4444
+ ORDER BY "name"`)
4445
+ .toArray()
4446
+ .filter((table) => !isRaybaseSearchInternalTable(table.name) && !backupOperationalTables.has(table.name));
4447
+ const tableNames = new Set(rawTables.map((table) => table.name));
4448
+ const tables = dependencyOrder(rawTables.map((table) => ({
4449
+ ...table,
4450
+ references: this.ctx.storage.sql
4451
+ .exec(`PRAGMA foreign_key_list(${sqlIdentifier(table.name)})`)
4452
+ .toArray()
4453
+ .map((foreignKey) => foreignKey.table)
4454
+ .filter((name) => tableNames.has(name))
4455
+ })));
4456
+ const statements = ["PRAGMA foreign_keys=OFF"];
4457
+ for (const table of [...tables].reverse()) {
4458
+ statements.push(`DROP TABLE IF EXISTS ${sqlIdentifier(table.name)}`);
4459
+ }
4460
+ for (const table of tables) {
4461
+ if (table.sql)
4462
+ statements.push(table.sql);
4463
+ }
4464
+ for (const table of tables) {
4465
+ const rows = this.ctx.storage.sql.exec(`SELECT * FROM ${sqlIdentifier(table.name)}`).toArray();
4466
+ for (const row of rows) {
4467
+ const columns = Object.keys(row);
4468
+ statements.push(`INSERT INTO ${sqlIdentifier(table.name)} (${columns.map(sqlIdentifier).join(", ")}) VALUES (${columns.map((column) => sqlLiteral(row[column])).join(", ")})`);
4469
+ }
4470
+ }
4471
+ statements.push("PRAGMA foreign_keys=ON");
4472
+ return { sql: `${statements.join(statementSeparator)};` };
4473
+ }
4474
+ async restoreDump(sql) {
4475
+ await this.ctx.blockConcurrencyWhile(async () => {
4476
+ this.ctx.storage.sql.exec("PRAGMA foreign_keys=OFF");
4477
+ try {
4478
+ this.mutateSync(() => {
4479
+ this.ensureJournal();
4480
+ const rawTables = this.ctx.storage.sql
4481
+ .exec(`SELECT "name" FROM "sqlite_master"
4482
+ WHERE "type" = 'table'
4483
+ AND "name" NOT LIKE 'sqlite_%'
4484
+ AND "name" NOT LIKE '_cf_%'`)
4485
+ .toArray()
4486
+ .filter((table) => !isRaybaseSearchInternalTable(table.name) && !backupOperationalTables.has(table.name));
4487
+ const tableNames = new Set(rawTables.map((table) => table.name));
4488
+ const tables = dependencyOrder(rawTables.map((table) => ({
4489
+ ...table,
4490
+ sql: "",
4491
+ references: this.ctx.storage.sql
4492
+ .exec(`PRAGMA foreign_key_list(${sqlIdentifier(table.name)})`)
4493
+ .toArray()
4494
+ .map((foreignKey) => foreignKey.table)
4495
+ .filter((name) => tableNames.has(name))
4496
+ })));
4497
+ for (const table of [...tables].reverse()) {
4498
+ this.ctx.storage.sql.exec(`DROP TABLE IF EXISTS ${sqlIdentifier(table.name)}`);
4499
+ }
4500
+ for (const statement of splitSqlStatements(sql)) {
4501
+ this.ctx.storage.sql.exec(statement);
4502
+ }
4503
+ });
4504
+ }
4505
+ finally {
4506
+ this.ctx.storage.sql.exec("PRAGMA foreign_keys=ON");
4507
+ }
4508
+ this.initialized = true;
4509
+ this.ensuredSchemaBundles.clear();
4510
+ });
4511
+ return { ok: true };
4512
+ }
4513
+ }
4514
+ /**
4515
+ * Builds a Wrangler-exportable database object around the shared Core
4516
+ * implementation. First-party layered distributions configure storage mode
4517
+ * and control-plane session revalidation without subclassing feature methods.
4518
+ */
4519
+ export function createRaybaseDatabaseObject(runtime) {
4520
+ return class RaybaseComposedDatabaseObject extends RaybaseDatabaseObject {
4521
+ runtimeOptions() {
4522
+ return runtime;
4523
+ }
4524
+ };
4525
+ }
4526
+ //# sourceMappingURL=do.js.map