devpush 0.0.0-stage → 1.0.0-beta.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 (456) hide show
  1. package/README.md +22 -2
  2. package/agent-release.json +37 -0
  3. package/dist/package/LICENSE.md +21 -0
  4. package/dist/package/migrate.mjs +182 -0
  5. package/dist/package/plugins.mjs +125 -0
  6. package/dist/package/vite.mjs +439 -0
  7. package/package.json +168 -3
  8. package/public/apple-touch-icon.png +0 -0
  9. package/public/favicon.svg +1 -0
  10. package/public/logo-email.png +0 -0
  11. package/public/robots.txt +3 -0
  12. package/public/social.png +0 -0
  13. package/src/cloudflare-runtime.d.ts +78 -0
  14. package/src/components/about-dialog.tsx +70 -0
  15. package/src/components/copy-button.tsx +74 -0
  16. package/src/components/logo-mark.tsx +20 -0
  17. package/src/components/pending-collection.tsx +103 -0
  18. package/src/components/resource-avatar.tsx +65 -0
  19. package/src/components/route-error-state.tsx +95 -0
  20. package/src/components/section-navigation.tsx +39 -0
  21. package/src/components/settings-layout.tsx +111 -0
  22. package/src/components/storage-capabilities.tsx +11 -0
  23. package/src/components/table-pager.tsx +101 -0
  24. package/src/components/ui/alert-dialog.tsx +180 -0
  25. package/src/components/ui/alert.tsx +78 -0
  26. package/src/components/ui/avatar.tsx +58 -0
  27. package/src/components/ui/badge.tsx +49 -0
  28. package/src/components/ui/button-group.tsx +38 -0
  29. package/src/components/ui/button.tsx +67 -0
  30. package/src/components/ui/card.tsx +82 -0
  31. package/src/components/ui/chart.tsx +307 -0
  32. package/src/components/ui/checkbox.tsx +30 -0
  33. package/src/components/ui/combobox.tsx +256 -0
  34. package/src/components/ui/dialog.tsx +163 -0
  35. package/src/components/ui/dropdown-menu.tsx +110 -0
  36. package/src/components/ui/empty.tsx +65 -0
  37. package/src/components/ui/field.tsx +192 -0
  38. package/src/components/ui/input-group.tsx +118 -0
  39. package/src/components/ui/input-otp.tsx +70 -0
  40. package/src/components/ui/input.tsx +19 -0
  41. package/src/components/ui/label.tsx +24 -0
  42. package/src/components/ui/pagination.tsx +34 -0
  43. package/src/components/ui/popover.tsx +86 -0
  44. package/src/components/ui/select.tsx +145 -0
  45. package/src/components/ui/skeleton.tsx +19 -0
  46. package/src/components/ui/sonner.tsx +47 -0
  47. package/src/components/ui/switch.tsx +31 -0
  48. package/src/components/ui/table.tsx +81 -0
  49. package/src/components/ui/textarea.tsx +18 -0
  50. package/src/components/ui/tooltip.tsx +55 -0
  51. package/src/features/admin/admin-settings.tsx +1048 -0
  52. package/src/features/auth/current-user-context-query.ts +11 -0
  53. package/src/features/auth/login-form.tsx +442 -0
  54. package/src/features/auth/sign-out-menu-item.tsx +42 -0
  55. package/src/features/deployments/deployment-actions.tsx +336 -0
  56. package/src/features/deployments/deployment-alias-badge.tsx +35 -0
  57. package/src/features/deployments/deployment-detail.tsx +596 -0
  58. package/src/features/deployments/deployment-filters.ts +66 -0
  59. package/src/features/deployments/deployment-list.tsx +465 -0
  60. package/src/features/deployments/deployment-visit-links.tsx +104 -0
  61. package/src/features/deployments/deployments-page.tsx +376 -0
  62. package/src/features/logs/log-scroll-area.tsx +69 -0
  63. package/src/features/notifications/email-preferences.tsx +232 -0
  64. package/src/features/onboarding/profile-onboarding.tsx +162 -0
  65. package/src/features/plugins/contributions.tsx +118 -0
  66. package/src/features/projects/backup-job-logs.tsx +143 -0
  67. package/src/features/projects/backup-policy-delete-dialog.tsx +82 -0
  68. package/src/features/projects/backup-restore-actions.tsx +573 -0
  69. package/src/features/projects/backup-status-icon.tsx +33 -0
  70. package/src/features/projects/backup-target-list.tsx +222 -0
  71. package/src/features/projects/backup-timestamp.tsx +32 -0
  72. package/src/features/projects/backup-volume-picker.tsx +158 -0
  73. package/src/features/projects/compose-service-input.tsx +64 -0
  74. package/src/features/projects/deploy-readiness.ts +28 -0
  75. package/src/features/projects/environment-dot.tsx +91 -0
  76. package/src/features/projects/log-keyword-filter.tsx +48 -0
  77. package/src/features/projects/manual-deploy-dialog.tsx +563 -0
  78. package/src/features/projects/pending-section.ts +26 -0
  79. package/src/features/projects/project-backup-details-pending.tsx +305 -0
  80. package/src/features/projects/project-backup-details.tsx +936 -0
  81. package/src/features/projects/project-backup-policy.tsx +644 -0
  82. package/src/features/projects/project-backup-utils.ts +190 -0
  83. package/src/features/projects/project-backups-pending.tsx +136 -0
  84. package/src/features/projects/project-backups.tsx +876 -0
  85. package/src/features/projects/project-environment-settings.tsx +1128 -0
  86. package/src/features/projects/project-heading.tsx +68 -0
  87. package/src/features/projects/project-layout.tsx +188 -0
  88. package/src/features/projects/project-logs.tsx +461 -0
  89. package/src/features/projects/project-overview.tsx +280 -0
  90. package/src/features/projects/project-route-pending.tsx +1064 -0
  91. package/src/features/projects/project-routing-settings.tsx +663 -0
  92. package/src/features/projects/project-settings-section.tsx +64 -0
  93. package/src/features/projects/project-settings-utils.ts +101 -0
  94. package/src/features/projects/project-settings.tsx +1472 -0
  95. package/src/features/projects/repository-picker-dialog.tsx +404 -0
  96. package/src/features/repositories/new-project-dialog-launcher.tsx +44 -0
  97. package/src/features/repositories/new-project-dialog.tsx +1204 -0
  98. package/src/features/repositories/new-project-pending.tsx +28 -0
  99. package/src/features/repositories/repository-list-pending.tsx +59 -0
  100. package/src/features/servers/server-availability-alert.tsx +44 -0
  101. package/src/features/servers/server-availability.ts +29 -0
  102. package/src/features/servers/server-avatar.tsx +52 -0
  103. package/src/features/servers/server-detail-pending.tsx +315 -0
  104. package/src/features/servers/server-detail.tsx +855 -0
  105. package/src/features/servers/server-metric-chart-pending.tsx +38 -0
  106. package/src/features/servers/server-metric-chart.tsx +182 -0
  107. package/src/features/servers/server-metric-format.ts +50 -0
  108. package/src/features/servers/server-metrics-pending.tsx +75 -0
  109. package/src/features/servers/server-metrics.tsx +136 -0
  110. package/src/features/servers/team-servers.tsx +432 -0
  111. package/src/features/settings/settings-route-pending.tsx +406 -0
  112. package/src/features/teams/backup-storage-settings.tsx +483 -0
  113. package/src/features/teams/navigation.ts +51 -0
  114. package/src/features/teams/new-team-dialog.tsx +132 -0
  115. package/src/features/teams/pending-section.ts +19 -0
  116. package/src/features/teams/team-header.tsx +1286 -0
  117. package/src/features/teams/team-heading.tsx +57 -0
  118. package/src/features/teams/team-home.tsx +391 -0
  119. package/src/features/teams/team-invite.tsx +155 -0
  120. package/src/features/teams/team-route-pending.tsx +505 -0
  121. package/src/features/teams/team-settings.tsx +1555 -0
  122. package/src/features/teams/use-workspace-updates.ts +126 -0
  123. package/src/features/users/api-keys-settings.tsx +544 -0
  124. package/src/features/users/api-scope-selection.tsx +269 -0
  125. package/src/features/users/connected-apps-settings.tsx +449 -0
  126. package/src/features/users/mcp-consent.tsx +220 -0
  127. package/src/features/users/profile-picture.tsx +171 -0
  128. package/src/features/users/user-settings.tsx +1064 -0
  129. package/src/lib/account-settings-schema.ts +78 -0
  130. package/src/lib/action-feedback.ts +81 -0
  131. package/src/lib/admin-search-schema.ts +8 -0
  132. package/src/lib/agent-release.d.mts +21 -0
  133. package/src/lib/agent-release.mjs +90 -0
  134. package/src/lib/allowlist-schema.ts +79 -0
  135. package/src/lib/api-key-schema.ts +176 -0
  136. package/src/lib/auth/client.ts +20 -0
  137. package/src/lib/avatar-schema.ts +11 -0
  138. package/src/lib/backup-cron.ts +118 -0
  139. package/src/lib/backup-database.ts +41 -0
  140. package/src/lib/backup-feature.ts +2 -0
  141. package/src/lib/backup-paths.ts +7 -0
  142. package/src/lib/backup-policy-schema.ts +85 -0
  143. package/src/lib/backup-resource-schema.ts +12 -0
  144. package/src/lib/backup-restore-schema.ts +15 -0
  145. package/src/lib/backup-storage-schema.ts +55 -0
  146. package/src/lib/deferred-module.ts +38 -0
  147. package/src/lib/deployment-logs-query.ts +63 -0
  148. package/src/lib/display-name.ts +7 -0
  149. package/src/lib/error-message.ts +119 -0
  150. package/src/lib/form-errors.ts +10 -0
  151. package/src/lib/internal-redirect.ts +25 -0
  152. package/src/lib/manual-deploy-options-query.ts +21 -0
  153. package/src/lib/notification-preferences.ts +43 -0
  154. package/src/lib/onboarding.ts +11 -0
  155. package/src/lib/operation-logs-query.ts +51 -0
  156. package/src/lib/page-head.ts +14 -0
  157. package/src/lib/project-context-query.ts +257 -0
  158. package/src/lib/project-creation-schema.ts +64 -0
  159. package/src/lib/project-env-schema.ts +23 -0
  160. package/src/lib/project-environments-schema.ts +56 -0
  161. package/src/lib/project-routing-schema.ts +94 -0
  162. package/src/lib/project-runtime-config.ts +131 -0
  163. package/src/lib/project-settings-schema.ts +75 -0
  164. package/src/lib/public-api-schema.ts +128 -0
  165. package/src/lib/public-error.ts +337 -0
  166. package/src/lib/query.ts +13 -0
  167. package/src/lib/repository-selection-query.ts +11 -0
  168. package/src/lib/resource-cache.ts +30 -0
  169. package/src/lib/runtime-id.ts +19 -0
  170. package/src/lib/search.ts +14 -0
  171. package/src/lib/server-context-query.ts +43 -0
  172. package/src/lib/server-name-schema.ts +11 -0
  173. package/src/lib/settings-conflict.ts +45 -0
  174. package/src/lib/settings-version.ts +7 -0
  175. package/src/lib/team-context-query.ts +126 -0
  176. package/src/lib/team-context-schema.ts +8 -0
  177. package/src/lib/team-forms-schema.ts +45 -0
  178. package/src/lib/team-role.ts +13 -0
  179. package/src/lib/team-settings-schema.ts +48 -0
  180. package/src/lib/urls.ts +3 -0
  181. package/src/lib/use-avatar-input.ts +61 -0
  182. package/src/lib/use-refresh-error.ts +18 -0
  183. package/src/lib/use-settings-version.ts +23 -0
  184. package/src/lib/utils.ts +6 -0
  185. package/src/lib/validation.ts +5 -0
  186. package/src/lib/workspace-updates.ts +53 -0
  187. package/src/package/config.ts +10 -0
  188. package/src/package/env.mjs +54 -0
  189. package/src/package/migrate-api.d.ts +5 -0
  190. package/src/package/migrate.ts +56 -0
  191. package/src/package/plugins.ts +141 -0
  192. package/src/package/sqlite.mjs +2 -0
  193. package/src/package/vite-api.d.ts +5 -0
  194. package/src/package/vite.ts +324 -0
  195. package/src/package/watch-guard.cjs +26 -0
  196. package/src/plugins/client.ts +57 -0
  197. package/src/plugins/contract.ts +188 -0
  198. package/src/plugins/default.server.ts +4 -0
  199. package/src/plugins/default.ui.ts +4 -0
  200. package/src/plugins/operations.ts +45 -0
  201. package/src/plugins/server.ts +197 -0
  202. package/src/plugins/version.ts +1 -0
  203. package/src/routeTree.gen.ts +1275 -0
  204. package/src/router.tsx +46 -0
  205. package/src/routes/$teamSlug.index.tsx +53 -0
  206. package/src/routes/$teamSlug.new-project.tsx +13 -0
  207. package/src/routes/$teamSlug.projects.$projectName.backups.$backupId.tsx +40 -0
  208. package/src/routes/$teamSlug.projects.$projectName.backups.tsx +82 -0
  209. package/src/routes/$teamSlug.projects.$projectName.deployments.$deploymentId.tsx +52 -0
  210. package/src/routes/$teamSlug.projects.$projectName.deployments.tsx +86 -0
  211. package/src/routes/$teamSlug.projects.$projectName.logs.tsx +55 -0
  212. package/src/routes/$teamSlug.projects.$projectName.settings.tsx +51 -0
  213. package/src/routes/$teamSlug.projects.$projectName.tsx +106 -0
  214. package/src/routes/$teamSlug.projects.tsx +40 -0
  215. package/src/routes/$teamSlug.servers.$serverId.tsx +31 -0
  216. package/src/routes/$teamSlug.servers.index.tsx +59 -0
  217. package/src/routes/$teamSlug.servers.tsx +7 -0
  218. package/src/routes/$teamSlug.settings.tsx +56 -0
  219. package/src/routes/$teamSlug.tsx +86 -0
  220. package/src/routes/[.]well-known.oauth-authorization-server.api.auth.ts +13 -0
  221. package/src/routes/[.]well-known.oauth-protected-resource.mcp.ts +6 -0
  222. package/src/routes/__root.tsx +155 -0
  223. package/src/routes/admin.tsx +55 -0
  224. package/src/routes/api/agents/certificates.ts +21 -0
  225. package/src/routes/api/agents/credentials/rotate.ts +22 -0
  226. package/src/routes/api/agents/heartbeat.ts +42 -0
  227. package/src/routes/api/agents/job-logs.ts +22 -0
  228. package/src/routes/api/agents/jobs/complete.ts +22 -0
  229. package/src/routes/api/agents/jobs/next.ts +29 -0
  230. package/src/routes/api/agents/jobs/progress.ts +32 -0
  231. package/src/routes/api/agents/jobs/status.ts +22 -0
  232. package/src/routes/api/agents/register.ts +45 -0
  233. package/src/routes/api/agents/runtime-logs.ts +22 -0
  234. package/src/routes/api/agents/switch.ts +25 -0
  235. package/src/routes/api/auth/$.ts +38 -0
  236. package/src/routes/api/cron/backups.ts +33 -0
  237. package/src/routes/api/cron/maintenance.ts +30 -0
  238. package/src/routes/api/github/install.callback.ts +51 -0
  239. package/src/routes/api/github/install.ts +41 -0
  240. package/src/routes/api/github/webhook.ts +11 -0
  241. package/src/routes/api/health.ts +19 -0
  242. package/src/routes/api/install/agent.$token.ts +50 -0
  243. package/src/routes/api/install/agent.binary.ts +83 -0
  244. package/src/routes/api/install/agent.checksum.ts +45 -0
  245. package/src/routes/api/install/agent.ts +26 -0
  246. package/src/routes/api/plugins/$.ts +11 -0
  247. package/src/routes/api/v1/$.ts +15 -0
  248. package/src/routes/api/workspaces/updates.ts +8 -0
  249. package/src/routes/auth.login.tsx +50 -0
  250. package/src/routes/deployments.$deploymentId.unavailable.tsx +21 -0
  251. package/src/routes/index.tsx +23 -0
  252. package/src/routes/invites.$token.tsx +35 -0
  253. package/src/routes/mcp.ts +13 -0
  254. package/src/routes/oauth.consent.tsx +54 -0
  255. package/src/routes/onboarding.tsx +37 -0
  256. package/src/routes/onboarding.workspace.tsx +44 -0
  257. package/src/routes/settings.tsx +30 -0
  258. package/src/routes/uploads/avatars/$fileName.ts +44 -0
  259. package/src/server/admin/access.ts +67 -0
  260. package/src/server/admin/functions.ts +122 -0
  261. package/src/server/admin/service.ts +260 -0
  262. package/src/server/agents/backup-state.ts +210 -0
  263. package/src/server/agents/certificates.ts +96 -0
  264. package/src/server/agents/claims.ts +116 -0
  265. package/src/server/agents/cleanup.ts +52 -0
  266. package/src/server/agents/compatibility.ts +116 -0
  267. package/src/server/agents/completion.ts +83 -0
  268. package/src/server/agents/container-cleanup-state.ts +51 -0
  269. package/src/server/agents/deployment-state.ts +111 -0
  270. package/src/server/agents/job-payloads.ts +159 -0
  271. package/src/server/agents/jobs.ts +1293 -0
  272. package/src/server/agents/protocol.ts +400 -0
  273. package/src/server/agents/reconciliation.ts +43 -0
  274. package/src/server/agents/runtime-logs.ts +337 -0
  275. package/src/server/agents/runtime-state.ts +125 -0
  276. package/src/server/api/admission.ts +12 -0
  277. package/src/server/api/authorization.ts +115 -0
  278. package/src/server/api/credentials.ts +569 -0
  279. package/src/server/api/cursors.ts +72 -0
  280. package/src/server/api/errors.ts +24 -0
  281. package/src/server/api/functions.ts +60 -0
  282. package/src/server/api/handler.ts +274 -0
  283. package/src/server/api/mutation-operations.ts +913 -0
  284. package/src/server/api/operation-context.ts +139 -0
  285. package/src/server/api/operation-contract.ts +1099 -0
  286. package/src/server/api/operations.ts +64 -0
  287. package/src/server/api/read-operations.ts +891 -0
  288. package/src/server/api/retention.ts +33 -0
  289. package/src/server/auth/access.ts +109 -0
  290. package/src/server/auth/auth-methods.ts +28 -0
  291. package/src/server/auth/better-auth.ts +518 -0
  292. package/src/server/auth/context.ts +28 -0
  293. package/src/server/auth/email-change.ts +47 -0
  294. package/src/server/auth/email-delivery.ts +51 -0
  295. package/src/server/auth/provider-identity.ts +19 -0
  296. package/src/server/auth/provider-tokens.ts +27 -0
  297. package/src/server/auth/session.server.ts +19 -0
  298. package/src/server/backups/database-commands.ts +40 -0
  299. package/src/server/backups/destination.ts +12 -0
  300. package/src/server/backups/feature.ts +6 -0
  301. package/src/server/backups/policy-validation.ts +51 -0
  302. package/src/server/backups/queue-state.ts +137 -0
  303. package/src/server/backups/service.ts +918 -0
  304. package/src/server/backups/storage-functions.ts +42 -0
  305. package/src/server/backups/storage-state.ts +67 -0
  306. package/src/server/backups/storage.ts +237 -0
  307. package/src/server/config.ts +274 -0
  308. package/src/server/db/atomic.ts +16 -0
  309. package/src/server/db/d1-connection.ts +83 -0
  310. package/src/server/db/errors.ts +17 -0
  311. package/src/server/db/index.cloudflare.ts +15 -0
  312. package/src/server/db/index.ts +35 -0
  313. package/src/server/db/libsql-connection.ts +72 -0
  314. package/src/server/db/log-schema.ts +102 -0
  315. package/src/server/db/migrate-libsql.ts +29 -0
  316. package/src/server/db/migrate-sqlite.ts +47 -0
  317. package/src/server/db/migration-history.ts +12 -0
  318. package/src/server/db/migrations-logs/0000_initial.sql +48 -0
  319. package/src/server/db/migrations-logs/0001_runtime_log_order.sql +2 -0
  320. package/src/server/db/migrations-logs/meta/0000_snapshot.json +327 -0
  321. package/src/server/db/migrations-logs/meta/0001_snapshot.json +329 -0
  322. package/src/server/db/migrations-logs/meta/_journal.json +20 -0
  323. package/src/server/db/migrations-sqlite/0000_initial.sql +894 -0
  324. package/src/server/db/migrations-sqlite/0001_agent_indexes.sql +5 -0
  325. package/src/server/db/migrations-sqlite/meta/0000_snapshot.json +5816 -0
  326. package/src/server/db/migrations-sqlite/meta/0001_snapshot.json +5841 -0
  327. package/src/server/db/migrations-sqlite/meta/_journal.json +20 -0
  328. package/src/server/db/oauth-schema.ts +216 -0
  329. package/src/server/db/remote-config.ts +47 -0
  330. package/src/server/db/schema.ts +1218 -0
  331. package/src/server/db/sqlite-connection.ts +94 -0
  332. package/src/server/db/sqlite-files.ts +27 -0
  333. package/src/server/db/sqlite-params.ts +19 -0
  334. package/src/server/db/worker-connection.ts +47 -0
  335. package/src/server/deleted-resources.ts +133 -0
  336. package/src/server/deletion-recovery.ts +272 -0
  337. package/src/server/email/layout.tsx +85 -0
  338. package/src/server/email/notification-template.tsx +62 -0
  339. package/src/server/email/otp-template.tsx +48 -0
  340. package/src/server/email/service.ts +251 -0
  341. package/src/server/email/team-invite-template.tsx +59 -0
  342. package/src/server/email/theme.ts +10 -0
  343. package/src/server/github/http.ts +14 -0
  344. package/src/server/github/install-state.ts +48 -0
  345. package/src/server/github/service.ts +831 -0
  346. package/src/server/github/webhook-handler.ts +198 -0
  347. package/src/server/github/webhooks.ts +205 -0
  348. package/src/server/http/auth.ts +11 -0
  349. package/src/server/http/error-diagnostics.ts +66 -0
  350. package/src/server/http/plugin-request.ts +15 -0
  351. package/src/server/http/proxy-request.ts +53 -0
  352. package/src/server/http/public-errors.ts +48 -0
  353. package/src/server/http/rate-limit.ts +59 -0
  354. package/src/server/http/request-body.ts +54 -0
  355. package/src/server/http/request-ip.ts +54 -0
  356. package/src/server/http/security-headers.ts +36 -0
  357. package/src/server/install/agent-binary.ts +230 -0
  358. package/src/server/install/agent-script.ts +565 -0
  359. package/src/server/install/release-binary.ts +65 -0
  360. package/src/server/logs/budget.ts +72 -0
  361. package/src/server/logs/cleanup.ts +64 -0
  362. package/src/server/logs/deployments.ts +98 -0
  363. package/src/server/logs/operations.ts +145 -0
  364. package/src/server/logs/retention.ts +39 -0
  365. package/src/server/maintenance.ts +61 -0
  366. package/src/server/mcp/connections.ts +358 -0
  367. package/src/server/mcp/functions.ts +63 -0
  368. package/src/server/mcp/handler.ts +295 -0
  369. package/src/server/mcp/oauth-discovery.cloudflare.ts +6 -0
  370. package/src/server/mcp/oauth-discovery.ts +8 -0
  371. package/src/server/mcp/oauth-provider.ts +220 -0
  372. package/src/server/mcp/urls.ts +20 -0
  373. package/src/server/notifications/functions.ts +51 -0
  374. package/src/server/notifications/observe.ts +303 -0
  375. package/src/server/notifications/outbox.ts +335 -0
  376. package/src/server/notifications/preferences.ts +131 -0
  377. package/src/server/notifications/query.ts +16 -0
  378. package/src/server/plugins/scope.ts +17 -0
  379. package/src/server/plugins/server-context.ts +157 -0
  380. package/src/server/plugins/services.server.ts +10 -0
  381. package/src/server/plugins/workspace-access.ts +74 -0
  382. package/src/server/projects/backup-policy-state.ts +68 -0
  383. package/src/server/projects/backups.ts +219 -0
  384. package/src/server/projects/compose-metadata.ts +174 -0
  385. package/src/server/projects/configuration-state.ts +90 -0
  386. package/src/server/projects/context.ts +909 -0
  387. package/src/server/projects/creation-state.ts +46 -0
  388. package/src/server/projects/deletion-state.ts +69 -0
  389. package/src/server/projects/deployment-config.ts +139 -0
  390. package/src/server/projects/deployment-logs.ts +97 -0
  391. package/src/server/projects/deployments.ts +1123 -0
  392. package/src/server/projects/domain-state.ts +71 -0
  393. package/src/server/projects/environment-branches.ts +75 -0
  394. package/src/server/projects/environment-state.ts +158 -0
  395. package/src/server/projects/environments.ts +424 -0
  396. package/src/server/projects/functions.ts +748 -0
  397. package/src/server/projects/generated-hostname.ts +77 -0
  398. package/src/server/projects/hostnames.ts +244 -0
  399. package/src/server/projects/mappers.ts +457 -0
  400. package/src/server/projects/names.ts +41 -0
  401. package/src/server/projects/project-env-vars.ts +28 -0
  402. package/src/server/projects/queue-state.ts +118 -0
  403. package/src/server/projects/repository.ts +368 -0
  404. package/src/server/projects/route-state.ts +37 -0
  405. package/src/server/projects/runtime-logs.ts +97 -0
  406. package/src/server/projects/settings-context.ts +58 -0
  407. package/src/server/projects/settings.ts +738 -0
  408. package/src/server/projects/types.ts +355 -0
  409. package/src/server/realtime/agent-events.ts +32 -0
  410. package/src/server/realtime/node-broadcaster.ts +71 -0
  411. package/src/server/realtime/node-handler.ts +55 -0
  412. package/src/server/realtime/service.ts +125 -0
  413. package/src/server/realtime/workspace-object.cloudflare.ts +62 -0
  414. package/src/server/realtime/workspace-object.ts +3 -0
  415. package/src/server/resources/color.ts +13 -0
  416. package/src/server/runtime/background.ts +21 -0
  417. package/src/server/runtime/node-asset-cache-plugin.ts +12 -0
  418. package/src/server/runtime/node-scheduler-plugin.ts +17 -0
  419. package/src/server/runtime/node-scheduler.ts +51 -0
  420. package/src/server/runtime/platform-env.cloudflare.ts +17 -0
  421. package/src/server/runtime/platform-env.ts +19 -0
  422. package/src/server/runtime/scheduled-task-error.ts +9 -0
  423. package/src/server/scheduled-tasks.ts +28 -0
  424. package/src/server/secrets/crypto.ts +56 -0
  425. package/src/server/servers/functions.ts +233 -0
  426. package/src/server/servers/lifecycle.ts +122 -0
  427. package/src/server/servers/metric-history-schema.ts +24 -0
  428. package/src/server/servers/metric-history.ts +55 -0
  429. package/src/server/servers/monitoring.ts +592 -0
  430. package/src/server/servers/service.ts +1211 -0
  431. package/src/server/servers/status.ts +32 -0
  432. package/src/server/servers/switch.ts +96 -0
  433. package/src/server/settings-version.ts +43 -0
  434. package/src/server/storage/capabilities.ts +6 -0
  435. package/src/server/storage/local-object-store.cloudflare.ts +7 -0
  436. package/src/server/storage/local-object-store.ts +50 -0
  437. package/src/server/storage/object-store.ts +160 -0
  438. package/src/server/teams/creation.ts +51 -0
  439. package/src/server/teams/delete-policy.ts +13 -0
  440. package/src/server/teams/deletion-state.ts +80 -0
  441. package/src/server/teams/functions.ts +324 -0
  442. package/src/server/teams/invitations.ts +38 -0
  443. package/src/server/teams/membership.ts +86 -0
  444. package/src/server/teams/queries.ts +261 -0
  445. package/src/server/teams/service.ts +1127 -0
  446. package/src/server/teams/types.ts +101 -0
  447. package/src/server/uploads/avatar.ts +133 -0
  448. package/src/server/users/deletion-state.ts +93 -0
  449. package/src/server/users/functions.ts +160 -0
  450. package/src/server/users/onboarding.ts +17 -0
  451. package/src/server/users/service.ts +644 -0
  452. package/src/server.ts +55 -0
  453. package/src/styles/shadcn.css +653 -0
  454. package/src/styles/theme.css +76 -0
  455. package/src/styles.css +177 -0
  456. package/wrangler.template.jsonc +37 -0
@@ -0,0 +1,303 @@
1
+ import { deferBackgroundTask } from '#devpush/server/runtime/background'
2
+ import { notificationRows } from './query'
3
+ import { eq, sql } from 'drizzle-orm'
4
+ import { db } from '#devpush/server/db'
5
+ import { config } from '#devpush/server/config'
6
+ import { notificationScanCursors } from '#devpush/server/db/schema'
7
+ import {
8
+ observeOperationalNotification,
9
+ deliverOperationalNotifications,
10
+ repairOperationalRecipients,
11
+ } from './outbox'
12
+
13
+ type ProjectSource = {
14
+ id: string
15
+ teamId: string
16
+ slug: string
17
+ name: string
18
+ teamSlug: string
19
+ }
20
+ async function projectSource(id: string) {
21
+ const [row] = await notificationRows<ProjectSource>(
22
+ sql`select p.id,p.team_id as teamId,p.slug,p.name,t.slug as teamSlug from projects p join teams t on t.id=p.team_id where p.id=${id} and p.status='active' and t.status='active'`,
23
+ ['id', 'teamId', 'slug', 'name', 'teamSlug'],
24
+ )
25
+ return row
26
+ }
27
+ export async function observeProjectNotifications(projectId: string) {
28
+ const p = await projectSource(projectId)
29
+ if (!p) return
30
+ const path = `/${encodeURIComponent(p.teamSlug)}/projects/${encodeURIComponent(p.slug)}`
31
+ const deployments = await notificationRows<{
32
+ id: string
33
+ environmentId: string
34
+ status: string
35
+ finishedAt: number
36
+ actorId: string | null
37
+ commitMeta: string
38
+ }>(
39
+ sql`
40
+ select d.id,d.environment_id as environmentId,d.status,d.finished_at as finishedAt,d.created_by_user_id as actorId,d.commit_meta as commitMeta
41
+ from deployments d join environments e on e.id=d.environment_id where e.project_id=${p.id} and e.status='active'
42
+ and d.status in ('failed','succeeded') and d.id=(select x.id from deployments x where x.environment_id=d.environment_id order by x.created_at desc,x.id desc limit 1)`,
43
+ ['id', 'environmentId', 'status', 'finishedAt', 'actorId', 'commitMeta'],
44
+ )
45
+ for (const d of deployments) {
46
+ if (!d.finishedAt || d.finishedAt < Date.now() - 86_400_000) continue
47
+ let actorId = d.actorId
48
+ if (!actorId) {
49
+ let sender: unknown
50
+ try {
51
+ sender = (JSON.parse(d.commitMeta) as { githubSenderId?: unknown })
52
+ .githubSenderId
53
+ } catch {
54
+ sender = null
55
+ }
56
+ if (typeof sender === 'string' && /^\d+$/.test(sender)) {
57
+ const linked = await notificationRows<{ userId: string }>(
58
+ sql`select a.user_id as userId from account a join team_members m on m.user_id=a.user_id where a.provider_id='github' and a.account_id=${sender} and m.team_id=${p.teamId}`,
59
+ ['userId'],
60
+ )
61
+ actorId = linked.length === 1 ? linked[0]!.userId : null
62
+ }
63
+ }
64
+ await observeOperationalNotification({
65
+ id: `deployment:${d.environmentId}`,
66
+ sourceId: d.environmentId,
67
+ teamId: p.teamId,
68
+ projectId: p.id,
69
+ category: 'deployments',
70
+ failed: d.status === 'failed',
71
+ revision: d.id,
72
+ at: d.finishedAt,
73
+ title: `Deployment failed: ${p.name}`,
74
+ message: `A deployment of ${p.name} failed. Open the deployment logs for details.`,
75
+ path: `${path}/deployments/${encodeURIComponent(d.id)}`,
76
+ actorId,
77
+ })
78
+ }
79
+ const backups = await notificationRows<{
80
+ id: string
81
+ environmentId: string
82
+ configId: string | null
83
+ status: string
84
+ finishedAt: number
85
+ }>(
86
+ sql`
87
+ select b.id,b.environment_id as environmentId,b.config_id as configId,b.status,b.finished_at as finishedAt from backup_runs b
88
+ where b.project_id=${p.id} and b.status in ('failed','succeeded') and b.id=(select x.id from backup_runs x where x.environment_id=b.environment_id and coalesce(x.config_id,'')=coalesce(b.config_id,'') order by x.created_at desc,x.id desc limit 1)`,
89
+ ['id', 'environmentId', 'configId', 'status', 'finishedAt'],
90
+ )
91
+ for (const b of backups.filter(
92
+ (backup) =>
93
+ backup.finishedAt && backup.finishedAt >= Date.now() - 86_400_000,
94
+ ))
95
+ await observeOperationalNotification({
96
+ id: `backup:${b.environmentId}:${b.configId ?? 'manual'}`,
97
+ sourceId: b.environmentId,
98
+ teamId: p.teamId,
99
+ projectId: p.id,
100
+ category: 'backups',
101
+ failed: b.status === 'failed',
102
+ revision: b.id,
103
+ at: b.finishedAt,
104
+ title: `Backup failed: ${p.name}`,
105
+ message: `A backup of ${p.name} failed. Check its storage destination and backup details.`,
106
+ path: `${path}/backups`,
107
+ })
108
+ const domains = await notificationRows<{
109
+ id: string
110
+ hostname: string
111
+ status: string
112
+ updatedAt: number
113
+ }>(
114
+ sql`select id,hostname,status,updated_at as updatedAt from domains where project_id=${p.id} and status in ('active','failed')`,
115
+ ['id', 'hostname', 'status', 'updatedAt'],
116
+ )
117
+ for (const d of domains.filter(
118
+ (domain) => domain.updatedAt >= Date.now() - 86_400_000,
119
+ ))
120
+ await observeOperationalNotification({
121
+ id: `domain:${d.id}`,
122
+ sourceId: d.id,
123
+ teamId: p.teamId,
124
+ projectId: p.id,
125
+ category: 'domains',
126
+ failed: d.status === 'failed',
127
+ revision: String(d.updatedAt),
128
+ at: d.updatedAt,
129
+ title: `Domain needs attention: ${d.hostname}`,
130
+ message: `Domain verification or HTTPS configuration for ${d.hostname} failed. Open routing settings for details.`,
131
+ path: `${path}/settings#domains`,
132
+ })
133
+ const restores = await notificationRows<{ id: string; finishedAt: number }>(
134
+ sql`select id,finished_at as finishedAt from agent_jobs where project_id=${p.id} and type='restore' and status in ('failed','abandoned') and finished_at>${Date.now() - 86_400_000}`,
135
+ ['id', 'finishedAt'],
136
+ )
137
+ for (const r of restores)
138
+ await observeOperationalNotification({
139
+ id: `restore:${r.id}`,
140
+ sourceId: r.id,
141
+ teamId: p.teamId,
142
+ projectId: p.id,
143
+ category: 'backups',
144
+ failed: true,
145
+ revision: r.id,
146
+ at: r.finishedAt,
147
+ title: `Restore failed: ${p.name}`,
148
+ message: `A restore of ${p.name} failed and may need manual recovery. Check restore details before making further changes.`,
149
+ path: `${path}/backups`,
150
+ })
151
+ }
152
+ export async function observeServerNotifications(
153
+ serverId: string,
154
+ now = Date.now(),
155
+ ) {
156
+ const [s] = await notificationRows<{
157
+ id: string
158
+ teamId: string
159
+ name: string
160
+ handle: string
161
+ teamSlug: string
162
+ status: string
163
+ lastSeenAt: number | null
164
+ diskUsed: number | null
165
+ diskTotal: number | null
166
+ metricAt: number | null
167
+ }>(
168
+ sql`
169
+ select s.id,s.team_id as teamId,s.name,s.handle,t.slug as teamSlug,s.status,s.last_seen_at as lastSeenAt,
170
+ m.disk_used_bytes as diskUsed,m.disk_total_bytes as diskTotal,m.observed_at as metricAt
171
+ from servers s join teams t on t.id=s.team_id left join server_metrics m on m.id=(select x.id from server_metrics x where x.server_id=s.id order by x.observed_at desc limit 1)
172
+ where s.id=${serverId} and s.status in ('connected','disconnected') and t.status='active'`,
173
+ [
174
+ 'id',
175
+ 'teamId',
176
+ 'name',
177
+ 'handle',
178
+ 'teamSlug',
179
+ 'status',
180
+ 'lastSeenAt',
181
+ 'diskUsed',
182
+ 'diskTotal',
183
+ 'metricAt',
184
+ ],
185
+ )
186
+ if (!s || !s.lastSeenAt || now - s.lastSeenAt > 86_400_000) return
187
+ const path = `/${encodeURIComponent(s.teamSlug)}/servers/${encodeURIComponent(s.id)}`
188
+ // A five-minute grace period avoids alerts for short network interruptions.
189
+ const offline = now - s.lastSeenAt >= 300_000
190
+ await observeOperationalNotification({
191
+ id: `server:${s.id}`,
192
+ sourceId: s.id,
193
+ teamId: s.teamId,
194
+ projectId: null,
195
+ category: 'servers',
196
+ failed: offline,
197
+ revision: String(s.lastSeenAt),
198
+ at: now,
199
+ title: `Server disconnected: ${s.name}`,
200
+ message: `${s.name} has not contacted devpush for at least five minutes. Existing applications may still be running.`,
201
+ path,
202
+ })
203
+ if (
204
+ s.metricAt &&
205
+ now - s.metricAt < 300_000 &&
206
+ s.diskTotal &&
207
+ s.diskUsed !== null
208
+ ) {
209
+ const ratio = s.diskUsed / s.diskTotal
210
+ // Recovery hysteresis prevents repeated notifications around the warning threshold.
211
+ if (ratio >= 0.9 || ratio < 0.85)
212
+ await observeOperationalNotification({
213
+ id: `disk:${s.id}`,
214
+ sourceId: s.id,
215
+ teamId: s.teamId,
216
+ projectId: null,
217
+ category: 'servers',
218
+ failed: ratio >= 0.9,
219
+ revision: String(s.metricAt),
220
+ at: now,
221
+ title: `Low disk space: ${s.name}`,
222
+ message: `Disk usage on ${s.name} is at least 90%. Free space to avoid failed deployments and backups.`,
223
+ path,
224
+ })
225
+ }
226
+ }
227
+ async function scan(source: 'projects' | 'servers') {
228
+ const [cursor] = await db
229
+ .select()
230
+ .from(notificationScanCursors)
231
+ .where(eq(notificationScanCursors.source, source))
232
+ .limit(1)
233
+ const table = sql.identifier(source)
234
+ const rows = await notificationRows<{ id: string }>(
235
+ sql`select id from ${table} where id>${cursor?.afterId ?? ''} and status ${source === 'projects' ? sql`='active'` : sql`in ('connected','disconnected')`} order by id limit 100`,
236
+ ['id'],
237
+ )
238
+ for (const row of rows) {
239
+ if (source === 'projects') await observeProjectNotifications(row.id)
240
+ else await observeServerNotifications(row.id)
241
+ // Advance only after successful observation, so failures are retried.
242
+ await db
243
+ .insert(notificationScanCursors)
244
+ .values({ source, afterId: row.id })
245
+ .onConflictDoUpdate({
246
+ target: notificationScanCursors.source,
247
+ set: { afterId: row.id },
248
+ })
249
+ }
250
+ if (rows.length < 100)
251
+ await db
252
+ .insert(notificationScanCursors)
253
+ .values({ source, afterId: '' })
254
+ .onConflictDoUpdate({
255
+ target: notificationScanCursors.source,
256
+ set: { afterId: '' },
257
+ })
258
+ }
259
+ export async function maintainOperationalNotifications() {
260
+ if (!config.EMAIL_FROM || !config.RESEND_API_KEY) return
261
+ await Promise.all([scan('projects'), scan('servers')])
262
+ await repairOperationalRecipients()
263
+ await deliverOperationalNotifications()
264
+ }
265
+ /** Best-effort immediate pass; durable source state and the scheduled scan repair interruptions. */
266
+ export function notifyAfterAgentCompletion(serverId: string) {
267
+ if (!config.EMAIL_FROM || !config.RESEND_API_KEY) return
268
+ deferBackgroundTask(async () => {
269
+ try {
270
+ const rows = await notificationRows<{ id: string }>(
271
+ sql`select distinct project_id as id from agent_jobs where server_id=${serverId} and project_id is not null and finished_at>${Date.now() - 60_000} union select distinct e.project_id as id from deployments d join environments e on e.id=d.environment_id where d.server_id=${serverId} and d.finished_at>${Date.now() - 60_000}`,
272
+ ['id'],
273
+ )
274
+ for (const p of rows) await observeProjectNotifications(p.id)
275
+ await deliverOperationalNotifications(10)
276
+ } catch {
277
+ console.warn('Operational notification pass deferred to scheduled retry.')
278
+ }
279
+ })
280
+ }
281
+
282
+ export function notifyAfterProjectUpdate(projectId: string) {
283
+ if (!config.EMAIL_FROM || !config.RESEND_API_KEY) return
284
+ deferBackgroundTask(async () => {
285
+ try {
286
+ await observeProjectNotifications(projectId)
287
+ await deliverOperationalNotifications(10)
288
+ } catch {
289
+ console.warn('Project notification pass deferred to scheduled retry.')
290
+ }
291
+ })
292
+ }
293
+ export function notifyAfterServerUpdate(serverId: string) {
294
+ if (!config.EMAIL_FROM || !config.RESEND_API_KEY) return
295
+ deferBackgroundTask(async () => {
296
+ try {
297
+ await observeServerNotifications(serverId)
298
+ await deliverOperationalNotifications(5)
299
+ } catch {
300
+ console.warn('Server notification pass deferred to scheduled retry.')
301
+ }
302
+ })
303
+ }
@@ -0,0 +1,335 @@
1
+ import { notificationRows } from './query'
2
+ import { createHash, randomUUID } from 'node:crypto'
3
+ import { and, eq, sql } from 'drizzle-orm'
4
+ import { db } from '#devpush/server/db'
5
+ import { config } from '#devpush/server/config'
6
+ import {
7
+ notificationIncidents,
8
+ notificationOutbox,
9
+ notificationScanCursors,
10
+ user,
11
+ } from '#devpush/server/db/schema'
12
+ import type { NotificationCategory } from '#devpush/lib/notification-preferences'
13
+ import { readNotificationPreferences } from './preferences'
14
+ import {
15
+ renderNotificationEmail,
16
+ sendEmail,
17
+ } from '#devpush/server/email/service'
18
+
19
+ export type OperationalObservation = {
20
+ id: string
21
+ sourceId: string
22
+ teamId: string
23
+ projectId: string | null
24
+ category: NotificationCategory
25
+ failed: boolean
26
+ revision: string
27
+ at: number
28
+ title: string
29
+ message: string
30
+ path: string
31
+ actorId?: string | null
32
+ }
33
+ export async function observeOperationalNotification(
34
+ input: OperationalObservation,
35
+ ) {
36
+ const from = config.EMAIL_FROM
37
+ if (!from) return
38
+ const current = await db
39
+ .select()
40
+ .from(notificationIncidents)
41
+ .where(eq(notificationIncidents.id, input.id))
42
+ .limit(1)
43
+ const previous = current[0]
44
+ if (previous && input.at < previous.updatedAt) return
45
+ if (!input.failed && !previous) return
46
+ const revision = previous?.active
47
+ ? previous.revision
48
+ : input.failed
49
+ ? input.revision
50
+ : previous!.revision
51
+ const value = {
52
+ id: input.id,
53
+ sourceId: input.sourceId,
54
+ teamId: input.teamId,
55
+ projectId: input.projectId,
56
+ category: input.category,
57
+ revision,
58
+ active: input.failed,
59
+ actorId: input.failed
60
+ ? (input.actorId ?? previous?.actorId ?? null)
61
+ : (previous?.actorId ?? null),
62
+ title: input.title,
63
+ message: input.message,
64
+ path: input.path,
65
+ updatedAt: input.at,
66
+ }
67
+ // Repeated observations repair any crash between saving the incident and its recipients.
68
+ await db
69
+ .insert(notificationIncidents)
70
+ .values(value)
71
+ .onConflictDoUpdate({
72
+ target: notificationIncidents.id,
73
+ set: value,
74
+ where: sql`${notificationIncidents.updatedAt} <= ${input.at}`,
75
+ })
76
+ const [incident] = await db
77
+ .select()
78
+ .from(notificationIncidents)
79
+ .where(eq(notificationIncidents.id, input.id))
80
+ .limit(1)
81
+ if (
82
+ !incident ||
83
+ incident.revision !== revision ||
84
+ incident.active !== input.failed
85
+ )
86
+ return
87
+ const recipients = input.failed
88
+ ? await notificationRows<{
89
+ id: string
90
+ actor: number
91
+ }>(
92
+ sql`select u.id,case when u.id=${input.actorId ?? incident.actorId ?? ''} then 1 else 0 end as actor from user u join team_members m on m.user_id=u.id
93
+ where m.team_id=${input.teamId} and u.status='active' and (m.role in ('owner','admin') or u.id=${input.actorId ?? incident.actorId ?? ''})`,
94
+ ['id', 'actor'],
95
+ )
96
+ : await notificationRows<{ id: string; actor: number }>(
97
+ sql`select user_id as id,actor from notification_outbox where incident_id=${input.id} and revision=${revision} and recovery=0 and delivered_at is not null`,
98
+ ['id', 'actor'],
99
+ )
100
+ for (const recipient of recipients) {
101
+ const id = createHash('sha256')
102
+ .update(JSON.stringify([input.id, revision, recipient.id, !input.failed]))
103
+ .digest('hex')
104
+ const [existing] = await db
105
+ .select({ id: notificationOutbox.id })
106
+ .from(notificationOutbox)
107
+ .where(eq(notificationOutbox.id, id))
108
+ .limit(1)
109
+ if (existing) continue
110
+ const [recipientUser] = await db
111
+ .select({ email: user.email })
112
+ .from(user)
113
+ .where(eq(user.id, recipient.id))
114
+ .limit(1)
115
+ if (!recipientUser) continue
116
+ const subject = input.failed
117
+ ? incident.title
118
+ : `Recovered: ${incident.title}`
119
+ const message = input.failed
120
+ ? incident.message
121
+ : 'The previously reported issue has been resolved.'
122
+ const url = new URL(incident.path, config.DEVPUSH_BASE_URL).toString()
123
+ const payload = {
124
+ from,
125
+ to: recipientUser.email,
126
+ subject,
127
+ text: `${message}\n\nView details: ${url}`,
128
+ html: await renderNotificationEmail({
129
+ to: recipientUser.email,
130
+ title: subject,
131
+ message,
132
+ action: { label: 'View details', url },
133
+ }),
134
+ }
135
+ await db
136
+ .insert(notificationOutbox)
137
+ .values({
138
+ id,
139
+ payload,
140
+ actor: Boolean(recipient.actor),
141
+ incidentId: input.id,
142
+ revision,
143
+ userId: recipient.id,
144
+ recovery: !input.failed,
145
+ createdAt: Date.now(),
146
+ })
147
+ .onConflictDoNothing()
148
+ }
149
+ }
150
+
151
+ async function eligible(
152
+ userId: string,
153
+ incident: typeof notificationIncidents.$inferSelect,
154
+ actor: boolean,
155
+ ) {
156
+ const [member] = await notificationRows<{
157
+ email: string
158
+ }>(
159
+ sql`select u.email from user u
160
+ join team_members m on m.user_id=u.id join teams t on t.id=m.team_id
161
+ where u.id=${userId} and u.status='active' and t.id=${incident.teamId} and t.status='active'
162
+ and (m.role in ('owner','admin') or ${actor ? 1 : 0}=1)
163
+ and (${incident.projectId} is null or exists(select 1 from projects p where p.id=${incident.projectId} and p.team_id=t.id and p.status='active'))
164
+ and not exists(select 1 from notification_mutes n where n.user_id=u.id and n.team_id=t.id and (n.project_id is null or n.project_id=${incident.projectId})) limit 1`,
165
+ ['email'],
166
+ )
167
+ if (!member) return null
168
+ if (incident.projectId === null) {
169
+ const [server] = await notificationRows<{ id: string }>(
170
+ sql`select id from servers where id=${incident.sourceId} and status in ('connected','disconnected')`,
171
+ ['id'],
172
+ )
173
+ if (!server) return null
174
+ }
175
+ if (
176
+ incident.id.startsWith('deployment:') ||
177
+ incident.id.startsWith('backup:')
178
+ ) {
179
+ const [environment] = await notificationRows<{ id: string }>(
180
+ sql`select id from environments where id=${incident.sourceId} and status='active'`,
181
+ ['id'],
182
+ )
183
+ if (!environment) return null
184
+ }
185
+ const preference = await readNotificationPreferences(userId)
186
+ if (!preference.preferences[incident.category as NotificationCategory])
187
+ return null
188
+ return member.email
189
+ }
190
+ export async function deliverOperationalNotifications(
191
+ limit = 50,
192
+ initialNow = Date.now(),
193
+ ) {
194
+ if (!config.EMAIL_FROM || !config.RESEND_API_KEY) return
195
+ const startedAt = Date.now()
196
+ const deadline = Date.now() + 15_000
197
+ for (let index = 0; index < limit && Date.now() < deadline; index++) {
198
+ const now = initialNow + Date.now() - startedAt
199
+ const lease = randomUUID()
200
+ const [row] = await db
201
+ .update(notificationOutbox)
202
+ .set({
203
+ leaseOwner: lease,
204
+ leaseUntil: sql`cast(unixepoch('subsec') * 1000 as integer) + 300000`,
205
+ attempts: sql`${notificationOutbox.attempts}+1`,
206
+ })
207
+ .where(
208
+ sql`${notificationOutbox.id} = (select id from notification_outbox where delivered_at is null and discarded_at is null and failed_at is null and next_attempt_at<=${now} and lease_until<=cast(unixepoch('subsec') * 1000 as integer) order by created_at,id limit 1) and ${notificationOutbox.leaseUntil}<=cast(unixepoch('subsec') * 1000 as integer)`,
209
+ )
210
+ .returning()
211
+ if (!row) break
212
+ const [incident] = await db
213
+ .select()
214
+ .from(notificationIncidents)
215
+ .where(eq(notificationIncidents.id, row.incidentId))
216
+ .limit(1)
217
+ const email =
218
+ incident &&
219
+ incident.revision === row.revision &&
220
+ incident.active !== row.recovery
221
+ ? await eligible(row.userId, incident, row.actor)
222
+ : null
223
+ const ownership = and(
224
+ eq(notificationOutbox.id, row.id),
225
+ eq(notificationOutbox.leaseOwner, lease),
226
+ sql`${notificationOutbox.leaseUntil} > cast(unixepoch('subsec') * 1000 as integer)`,
227
+ )
228
+ if (!incident || !email || email !== row.payload.to) {
229
+ await db
230
+ .update(notificationOutbox)
231
+ .set({ discardedAt: now, leaseOwner: null, leaseUntil: 0 })
232
+ .where(ownership)
233
+ continue
234
+ }
235
+ if (
236
+ row.attempts > 10 ||
237
+ (row.firstAttemptAt !== null && now - row.firstAttemptAt >= 86_400_000)
238
+ ) {
239
+ await db
240
+ .update(notificationOutbox)
241
+ .set({ failedAt: now, leaseOwner: null, leaseUntil: 0 })
242
+ .where(ownership)
243
+ console.error('Notification email needs operator review.', {
244
+ notificationId: row.id,
245
+ })
246
+ continue
247
+ }
248
+ try {
249
+ // Persist the complete provider payload and window before the first call.
250
+ const payload = row.payload
251
+ const held = await db
252
+ .update(notificationOutbox)
253
+ .set({
254
+ payload,
255
+ firstAttemptAt: row.firstAttemptAt ?? now,
256
+ })
257
+ .where(ownership)
258
+ .returning({ id: notificationOutbox.id })
259
+ if (!held.length) continue
260
+ await sendEmail(payload, `notification-${row.id}`)
261
+ await db
262
+ .update(notificationOutbox)
263
+ .set({ deliveredAt: now, leaseOwner: null, leaseUntil: 0 })
264
+ .where(ownership)
265
+ } catch {
266
+ await db
267
+ .update(notificationOutbox)
268
+ .set({
269
+ nextAttemptAt:
270
+ now + Math.min(3_600_000, 60_000 * 2 ** (row.attempts - 1)),
271
+ leaseOwner: null,
272
+ leaseUntil: 0,
273
+ })
274
+ .where(ownership)
275
+ console.warn('Notification email pending; retry persisted.', {
276
+ notificationId: row.id,
277
+ })
278
+ }
279
+ }
280
+ }
281
+
282
+ export async function pruneOperationalNotifications(now = Date.now()) {
283
+ const before = now - 30 * 86_400_000
284
+ await db
285
+ .delete(notificationOutbox)
286
+ .where(
287
+ sql`${notificationOutbox.createdAt}<${before} and exists(select 1 from notification_incidents i where i.id=${notificationOutbox.incidentId} and i.revision<>${notificationOutbox.revision})`,
288
+ )
289
+ await db.delete(notificationIncidents).where(
290
+ sql`${notificationIncidents.updatedAt}<${before} and (${notificationIncidents.active}=0 or ${notificationIncidents.id} like 'restore:%'
291
+ or (${notificationIncidents.projectId} is not null and not exists(select 1 from projects p where p.id=${notificationIncidents.projectId} and p.status='active'))
292
+ or (${notificationIncidents.projectId} is null and not exists(select 1 from servers s where s.id=${notificationIncidents.sourceId} and s.status<>'deleted'))
293
+ or (${notificationIncidents.id} like 'domain:%' and not exists(select 1 from domains d where d.id=${notificationIncidents.sourceId}))
294
+ or ((${notificationIncidents.id} like 'deployment:%' or ${notificationIncidents.id} like 'backup:%') and not exists(select 1 from environments e where e.id=${notificationIncidents.sourceId} and e.status='active')))`,
295
+ )
296
+ }
297
+
298
+ export async function repairOperationalRecipients() {
299
+ const source = 'recipients'
300
+ const [cursor] = await db
301
+ .select()
302
+ .from(notificationScanCursors)
303
+ .where(eq(notificationScanCursors.source, source))
304
+ .limit(1)
305
+ const rows = await db
306
+ .select()
307
+ .from(notificationIncidents)
308
+ .where(sql`${notificationIncidents.id}>${cursor?.afterId ?? ''}`)
309
+ .orderBy(notificationIncidents.id)
310
+ .limit(100)
311
+ for (const incident of rows) {
312
+ if (incident.updatedAt >= Date.now() - 86_400_000)
313
+ await observeOperationalNotification({
314
+ id: incident.id,
315
+ sourceId: incident.sourceId,
316
+ teamId: incident.teamId,
317
+ projectId: incident.projectId,
318
+ category: incident.category as NotificationCategory,
319
+ failed: incident.active,
320
+ revision: incident.revision,
321
+ at: incident.updatedAt,
322
+ title: incident.title,
323
+ message: incident.message,
324
+ path: incident.path,
325
+ actorId: incident.actorId,
326
+ })
327
+ }
328
+ await db
329
+ .insert(notificationScanCursors)
330
+ .values({ source, afterId: rows.length === 100 ? rows.at(-1)!.id : '' })
331
+ .onConflictDoUpdate({
332
+ target: notificationScanCursors.source,
333
+ set: { afterId: rows.length === 100 ? rows.at(-1)!.id : '' },
334
+ })
335
+ }