@raybase/core 0.0.2 → 0.3.3
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.
- package/LICENSE +21 -0
- package/dist/.admin-build/AdminApp.css +2 -0
- package/dist/.admin-build/AdminApp.js +77 -0
- package/dist/.admin-docs-build/AdminDocsApp.css +2 -0
- package/dist/.admin-docs-build/AdminDocsApp.js +45 -0
- package/dist/actions.d.ts +347 -0
- package/dist/actions.d.ts.map +1 -0
- package/dist/actions.js +661 -0
- package/dist/actions.js.map +1 -0
- package/dist/admin-assets/admin.7738f50720a8ed7b.css +2 -0
- package/dist/admin-assets/admin.8678cb61db34dc81.js +77 -0
- package/dist/admin-boot.d.ts +94 -0
- package/dist/admin-boot.d.ts.map +1 -0
- package/dist/admin-boot.js +139 -0
- package/dist/admin-boot.js.map +1 -0
- package/dist/admin-docs-content.d.ts +95 -0
- package/dist/admin-docs-content.d.ts.map +1 -0
- package/dist/admin-docs-content.js +463 -0
- package/dist/admin-docs-content.js.map +1 -0
- package/dist/admin-docs-ui.d.ts +11 -0
- package/dist/admin-docs-ui.d.ts.map +1 -0
- package/dist/admin-docs-ui.js +23 -0
- package/dist/admin-docs-ui.js.map +1 -0
- package/dist/admin-kit.d.ts +29 -0
- package/dist/admin-kit.js +1557 -0
- package/dist/admin-role.d.ts +8 -0
- package/dist/admin-role.d.ts.map +1 -0
- package/dist/admin-role.js +22 -0
- package/dist/admin-role.js.map +1 -0
- package/dist/admin-ui.d.ts +3 -0
- package/dist/admin-ui.d.ts.map +1 -0
- package/dist/admin-ui.js +16 -0
- package/dist/admin-ui.js.map +1 -0
- package/dist/agent-ai-chat.d.ts +127 -0
- package/dist/agent-ai-chat.d.ts.map +1 -0
- package/dist/agent-ai-chat.js +5975 -0
- package/dist/agent-ai-chat.js.map +1 -0
- package/dist/agent-chapter-archive.d.ts +214 -0
- package/dist/agent-chapter-archive.d.ts.map +1 -0
- package/dist/agent-chapter-archive.js +696 -0
- package/dist/agent-chapter-archive.js.map +1 -0
- package/dist/agent-chapter-search.d.ts +116 -0
- package/dist/agent-chapter-search.d.ts.map +1 -0
- package/dist/agent-chapter-search.js +605 -0
- package/dist/agent-chapter-search.js.map +1 -0
- package/dist/agent-chapter-tools.d.ts +45 -0
- package/dist/agent-chapter-tools.d.ts.map +1 -0
- package/dist/agent-chapter-tools.js +344 -0
- package/dist/agent-chapter-tools.js.map +1 -0
- package/dist/agent-chapters.d.ts +228 -0
- package/dist/agent-chapters.d.ts.map +1 -0
- package/dist/agent-chapters.js +737 -0
- package/dist/agent-chapters.js.map +1 -0
- package/dist/agent-data-parts.d.ts +167 -0
- package/dist/agent-data-parts.d.ts.map +1 -0
- package/dist/agent-data-parts.js +68 -0
- package/dist/agent-data-parts.js.map +1 -0
- package/dist/agent-errors.d.ts +41 -0
- package/dist/agent-errors.d.ts.map +1 -0
- package/dist/agent-errors.js +191 -0
- package/dist/agent-errors.js.map +1 -0
- package/dist/agent-follow-up-store.d.ts +273 -0
- package/dist/agent-follow-up-store.d.ts.map +1 -0
- package/dist/agent-follow-up-store.js +703 -0
- package/dist/agent-follow-up-store.js.map +1 -0
- package/dist/agent-follow-ups.d.ts +314 -0
- package/dist/agent-follow-ups.d.ts.map +1 -0
- package/dist/agent-follow-ups.js +840 -0
- package/dist/agent-follow-ups.js.map +1 -0
- package/dist/agent-memory.d.ts +323 -0
- package/dist/agent-memory.d.ts.map +1 -0
- package/dist/agent-memory.js +1270 -0
- package/dist/agent-memory.js.map +1 -0
- package/dist/agent-message-parts.d.ts +58 -0
- package/dist/agent-message-parts.d.ts.map +1 -0
- package/dist/agent-message-parts.js +163 -0
- package/dist/agent-message-parts.js.map +1 -0
- package/dist/agent-remote-mcp-credentials.d.ts +154 -0
- package/dist/agent-remote-mcp-credentials.d.ts.map +1 -0
- package/dist/agent-remote-mcp-credentials.js +287 -0
- package/dist/agent-remote-mcp-credentials.js.map +1 -0
- package/dist/agent-remote-mcp-crypto.d.ts +32 -0
- package/dist/agent-remote-mcp-crypto.d.ts.map +1 -0
- package/dist/agent-remote-mcp-crypto.js +101 -0
- package/dist/agent-remote-mcp-crypto.js.map +1 -0
- package/dist/agent-remote-mcp-install.d.ts +299 -0
- package/dist/agent-remote-mcp-install.d.ts.map +1 -0
- package/dist/agent-remote-mcp-install.js +785 -0
- package/dist/agent-remote-mcp-install.js.map +1 -0
- package/dist/agent-remote-mcp-oauth.d.ts +73 -0
- package/dist/agent-remote-mcp-oauth.d.ts.map +1 -0
- package/dist/agent-remote-mcp-oauth.js +344 -0
- package/dist/agent-remote-mcp-oauth.js.map +1 -0
- package/dist/agent-remote-mcp-transport.d.ts +46 -0
- package/dist/agent-remote-mcp-transport.d.ts.map +1 -0
- package/dist/agent-remote-mcp-transport.js +225 -0
- package/dist/agent-remote-mcp-transport.js.map +1 -0
- package/dist/agent-remote-mcp.d.ts +147 -0
- package/dist/agent-remote-mcp.d.ts.map +1 -0
- package/dist/agent-remote-mcp.js +681 -0
- package/dist/agent-remote-mcp.js.map +1 -0
- package/dist/agent-room-redaction.d.ts +66 -0
- package/dist/agent-room-redaction.d.ts.map +1 -0
- package/dist/agent-room-redaction.js +264 -0
- package/dist/agent-room-redaction.js.map +1 -0
- package/dist/agent-rooms.d.ts +209 -0
- package/dist/agent-rooms.d.ts.map +1 -0
- package/dist/agent-rooms.js +244 -0
- package/dist/agent-rooms.js.map +1 -0
- package/dist/agent-runtime.d.ts +541 -0
- package/dist/agent-runtime.d.ts.map +1 -0
- package/dist/agent-runtime.js +4260 -0
- package/dist/agent-runtime.js.map +1 -0
- package/dist/agent-skill-archive.d.ts +20 -0
- package/dist/agent-skill-archive.d.ts.map +1 -0
- package/dist/agent-skill-archive.js +316 -0
- package/dist/agent-skill-archive.js.map +1 -0
- package/dist/agent-skill-discovery.d.ts +96 -0
- package/dist/agent-skill-discovery.d.ts.map +1 -0
- package/dist/agent-skill-discovery.js +464 -0
- package/dist/agent-skill-discovery.js.map +1 -0
- package/dist/agent-skill-format.d.ts +57 -0
- package/dist/agent-skill-format.d.ts.map +1 -0
- package/dist/agent-skill-format.js +238 -0
- package/dist/agent-skill-format.js.map +1 -0
- package/dist/agent-skill-import.d.ts +64 -0
- package/dist/agent-skill-import.d.ts.map +1 -0
- package/dist/agent-skill-import.js +295 -0
- package/dist/agent-skill-import.js.map +1 -0
- package/dist/agent-skill-publish.d.ts +51 -0
- package/dist/agent-skill-publish.d.ts.map +1 -0
- package/dist/agent-skill-publish.js +136 -0
- package/dist/agent-skill-publish.js.map +1 -0
- package/dist/agent-skill-store.d.ts +252 -0
- package/dist/agent-skill-store.d.ts.map +1 -0
- package/dist/agent-skill-store.js +514 -0
- package/dist/agent-skill-store.js.map +1 -0
- package/dist/agent-skills.d.ts +85 -0
- package/dist/agent-skills.d.ts.map +1 -0
- package/dist/agent-skills.js +291 -0
- package/dist/agent-skills.js.map +1 -0
- package/dist/agent-transcript-pager.d.ts +77 -0
- package/dist/agent-transcript-pager.d.ts.map +1 -0
- package/dist/agent-transcript-pager.js +210 -0
- package/dist/agent-transcript-pager.js.map +1 -0
- package/dist/agent-user-data-parts.d.ts +20 -0
- package/dist/agent-user-data-parts.d.ts.map +1 -0
- package/dist/agent-user-data-parts.js +161 -0
- package/dist/agent-user-data-parts.js.map +1 -0
- package/dist/agent-user-transcript.d.ts +46 -0
- package/dist/agent-user-transcript.d.ts.map +1 -0
- package/dist/agent-user-transcript.js +106 -0
- package/dist/agent-user-transcript.js.map +1 -0
- package/dist/agent-voice-notes.d.ts +15 -0
- package/dist/agent-voice-notes.d.ts.map +1 -0
- package/dist/agent-voice-notes.js +17 -0
- package/dist/agent-voice-notes.js.map +1 -0
- package/dist/agent-wakes.d.ts +213 -0
- package/dist/agent-wakes.d.ts.map +1 -0
- package/dist/agent-wakes.js +534 -0
- package/dist/agent-wakes.js.map +1 -0
- package/dist/agent.d.ts +1780 -0
- package/dist/agent.d.ts.map +1 -0
- package/dist/agent.js +3123 -0
- package/dist/agent.js.map +1 -0
- package/dist/ai.d.ts +583 -0
- package/dist/ai.d.ts.map +1 -0
- package/dist/ai.js +1858 -0
- package/dist/ai.js.map +1 -0
- package/dist/app-manifest.d.ts +255 -0
- package/dist/app-manifest.d.ts.map +1 -0
- package/dist/app-manifest.js +372 -0
- package/dist/app-manifest.js.map +1 -0
- package/dist/app-users.d.ts +30 -0
- package/dist/app-users.d.ts.map +1 -0
- package/dist/app-users.js +91 -0
- package/dist/app-users.js.map +1 -0
- package/dist/audit-archive.d.ts +85 -0
- package/dist/audit-archive.d.ts.map +1 -0
- package/dist/audit-archive.js +178 -0
- package/dist/audit-archive.js.map +1 -0
- package/dist/audit.d.ts +54 -0
- package/dist/audit.d.ts.map +1 -0
- package/dist/audit.js +95 -0
- package/dist/audit.js.map +1 -0
- package/dist/auth-logger.d.ts +28 -0
- package/dist/auth-logger.d.ts.map +1 -0
- package/dist/auth-logger.js +53 -0
- package/dist/auth-logger.js.map +1 -0
- package/dist/auth-password-secret.d.ts +59 -0
- package/dist/auth-password-secret.d.ts.map +1 -0
- package/dist/auth-password-secret.js +409 -0
- package/dist/auth-password-secret.js.map +1 -0
- package/dist/auth-schema.d.ts +120 -0
- package/dist/auth-schema.d.ts.map +1 -0
- package/dist/auth-schema.js +285 -0
- package/dist/auth-schema.js.map +1 -0
- package/dist/auth.d.ts +321 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +1562 -0
- package/dist/auth.js.map +1 -0
- package/dist/backup-archive.d.ts +257 -0
- package/dist/backup-archive.d.ts.map +1 -0
- package/dist/backup-archive.js +2068 -0
- package/dist/backup-archive.js.map +1 -0
- package/dist/backup-crypto.d.ts +313 -0
- package/dist/backup-crypto.d.ts.map +1 -0
- package/dist/backup-crypto.js +693 -0
- package/dist/backup-crypto.js.map +1 -0
- package/dist/backup-format.d.ts +200 -0
- package/dist/backup-format.d.ts.map +1 -0
- package/dist/backup-format.js +1247 -0
- package/dist/backup-format.js.map +1 -0
- package/dist/cli.d.ts +9 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +15 -0
- package/dist/cli.js.map +1 -0
- package/dist/clock.d.ts +35 -0
- package/dist/clock.d.ts.map +1 -0
- package/dist/clock.js +83 -0
- package/dist/clock.js.map +1 -0
- package/dist/data-client.d.ts +187 -0
- package/dist/data-client.d.ts.map +1 -0
- package/dist/data-client.js +590 -0
- package/dist/data-client.js.map +1 -0
- package/dist/data-secrets.d.ts +164 -0
- package/dist/data-secrets.d.ts.map +1 -0
- package/dist/data-secrets.js +383 -0
- package/dist/data-secrets.js.map +1 -0
- package/dist/db.d.ts +453 -0
- package/dist/db.d.ts.map +1 -0
- package/dist/db.js +324 -0
- package/dist/db.js.map +1 -0
- package/dist/dev-activity.d.ts +48 -0
- package/dist/dev-activity.d.ts.map +1 -0
- package/dist/dev-activity.js +50 -0
- package/dist/dev-activity.js.map +1 -0
- package/dist/do.d.ts +429 -0
- package/dist/do.d.ts.map +1 -0
- package/dist/do.js +4526 -0
- package/dist/do.js.map +1 -0
- package/dist/email.d.ts +137 -0
- package/dist/email.d.ts.map +1 -0
- package/dist/email.js +527 -0
- package/dist/email.js.map +1 -0
- package/dist/generated/admin-assets.d.ts +16 -0
- package/dist/generated/admin-assets.d.ts.map +1 -0
- package/dist/generated/admin-assets.js +16 -0
- package/dist/generated/admin-assets.js.map +1 -0
- package/dist/generated/admin-docs-assets.d.ts +3 -0
- package/dist/generated/admin-docs-assets.d.ts.map +1 -0
- package/dist/generated/admin-docs-assets.js +3 -0
- package/dist/generated/admin-docs-assets.js.map +1 -0
- package/dist/handoff-consumer.d.ts +79 -0
- package/dist/handoff-consumer.d.ts.map +1 -0
- package/dist/handoff-consumer.js +300 -0
- package/dist/handoff-consumer.js.map +1 -0
- package/dist/handoff.d.ts +70 -0
- package/dist/handoff.d.ts.map +1 -0
- package/dist/handoff.js +228 -0
- package/dist/handoff.js.map +1 -0
- package/dist/host-auth.d.ts +47 -0
- package/dist/host-auth.d.ts.map +1 -0
- package/dist/host-auth.js +59 -0
- package/dist/host-auth.js.map +1 -0
- package/dist/host-user.d.ts +15 -0
- package/dist/host-user.d.ts.map +1 -0
- package/dist/host-user.js +23 -0
- package/dist/host-user.js.map +1 -0
- package/dist/html.d.ts +35 -0
- package/dist/html.d.ts.map +1 -0
- package/dist/html.js +634 -0
- package/dist/html.js.map +1 -0
- package/dist/http-error.d.ts +13 -0
- package/dist/http-error.d.ts.map +1 -0
- package/dist/http-error.js +15 -0
- package/dist/http-error.js.map +1 -0
- package/dist/imports.d.ts +219 -0
- package/dist/imports.d.ts.map +1 -0
- package/dist/imports.js +571 -0
- package/dist/imports.js.map +1 -0
- package/dist/index.d.ts +78 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +76 -0
- package/dist/index.js.map +1 -0
- package/dist/inference-tape.d.ts +64 -0
- package/dist/inference-tape.d.ts.map +1 -0
- package/dist/inference-tape.js +278 -0
- package/dist/inference-tape.js.map +1 -0
- package/dist/internal.d.ts +15 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +48 -0
- package/dist/internal.js.map +1 -0
- package/dist/jobs.d.ts +796 -0
- package/dist/jobs.d.ts.map +1 -0
- package/dist/jobs.js +538 -0
- package/dist/jobs.js.map +1 -0
- package/dist/json-schema.d.ts +91 -0
- package/dist/json-schema.d.ts.map +1 -0
- package/dist/json-schema.js +884 -0
- package/dist/json-schema.js.map +1 -0
- package/dist/knowledge.d.ts +33 -0
- package/dist/knowledge.d.ts.map +1 -0
- package/dist/knowledge.js +348 -0
- package/dist/knowledge.js.map +1 -0
- package/dist/local-dev-chrome.d.ts +29 -0
- package/dist/local-dev-chrome.d.ts.map +1 -0
- package/dist/local-dev-chrome.js +514 -0
- package/dist/local-dev-chrome.js.map +1 -0
- package/dist/local-dev-personas.d.ts +15 -0
- package/dist/local-dev-personas.d.ts.map +1 -0
- package/dist/local-dev-personas.js +46 -0
- package/dist/local-dev-personas.js.map +1 -0
- package/dist/mcp.d.ts +567 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +2480 -0
- package/dist/mcp.js.map +1 -0
- package/dist/modules.d.ts +145 -0
- package/dist/modules.d.ts.map +1 -0
- package/dist/modules.js +164 -0
- package/dist/modules.js.map +1 -0
- package/dist/notification-client.d.ts +94 -0
- package/dist/notification-client.d.ts.map +1 -0
- package/dist/notification-client.js +127 -0
- package/dist/notification-client.js.map +1 -0
- package/dist/notifications.d.ts +447 -0
- package/dist/notifications.d.ts.map +1 -0
- package/dist/notifications.js +1625 -0
- package/dist/notifications.js.map +1 -0
- package/dist/operator-data-query.d.ts +117 -0
- package/dist/operator-data-query.d.ts.map +1 -0
- package/dist/operator-data-query.js +431 -0
- package/dist/operator-data-query.js.map +1 -0
- package/dist/operator-mcp.d.ts +63 -0
- package/dist/operator-mcp.d.ts.map +1 -0
- package/dist/operator-mcp.js +2828 -0
- package/dist/operator-mcp.js.map +1 -0
- package/dist/outbound-https.d.ts +24 -0
- package/dist/outbound-https.d.ts.map +1 -0
- package/dist/outbound-https.js +172 -0
- package/dist/outbound-https.js.map +1 -0
- package/dist/pitr.d.ts +60 -0
- package/dist/pitr.d.ts.map +1 -0
- package/dist/pitr.js +94 -0
- package/dist/pitr.js.map +1 -0
- package/dist/platform.d.ts +199 -0
- package/dist/platform.d.ts.map +1 -0
- package/dist/platform.js +196 -0
- package/dist/platform.js.map +1 -0
- package/dist/policies.d.ts +115 -0
- package/dist/policies.d.ts.map +1 -0
- package/dist/policies.js +158 -0
- package/dist/policies.js.map +1 -0
- package/dist/principal.d.ts +143 -0
- package/dist/principal.d.ts.map +1 -0
- package/dist/principal.js +174 -0
- package/dist/principal.js.map +1 -0
- package/dist/realtime.d.ts +280 -0
- package/dist/realtime.d.ts.map +1 -0
- package/dist/realtime.js +578 -0
- package/dist/realtime.js.map +1 -0
- package/dist/retrieval.d.ts +263 -0
- package/dist/retrieval.d.ts.map +1 -0
- package/dist/retrieval.js +602 -0
- package/dist/retrieval.js.map +1 -0
- package/dist/runtime-error-client.d.ts +10 -0
- package/dist/runtime-error-client.d.ts.map +1 -0
- package/dist/runtime-error-client.js +70 -0
- package/dist/runtime-error-client.js.map +1 -0
- package/dist/runtime-errors.d.ts +180 -0
- package/dist/runtime-errors.d.ts.map +1 -0
- package/dist/runtime-errors.js +572 -0
- package/dist/runtime-errors.js.map +1 -0
- package/dist/schema.d.ts +434 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +232 -0
- package/dist/schema.js.map +1 -0
- package/dist/search.d.ts +216 -0
- package/dist/search.d.ts.map +1 -0
- package/dist/search.js +785 -0
- package/dist/search.js.map +1 -0
- package/dist/settings.d.ts +191 -0
- package/dist/settings.d.ts.map +1 -0
- package/dist/settings.js +478 -0
- package/dist/settings.js.map +1 -0
- package/dist/sql.d.ts +7 -0
- package/dist/sql.d.ts.map +1 -0
- package/dist/sql.js +30 -0
- package/dist/sql.js.map +1 -0
- package/dist/storage-client.d.ts +69 -0
- package/dist/storage-client.d.ts.map +1 -0
- package/dist/storage-client.js +469 -0
- package/dist/storage-client.js.map +1 -0
- package/dist/storage.d.ts +21 -0
- package/dist/storage.d.ts.map +1 -0
- package/dist/storage.js +82 -0
- package/dist/storage.js.map +1 -0
- package/dist/subject-domains.d.ts +15 -0
- package/dist/subject-domains.d.ts.map +1 -0
- package/dist/subject-domains.js +38 -0
- package/dist/subject-domains.js.map +1 -0
- package/dist/support.d.ts +132 -0
- package/dist/support.d.ts.map +1 -0
- package/dist/support.js +483 -0
- package/dist/support.js.map +1 -0
- package/dist/system-tables.d.ts +12 -0
- package/dist/system-tables.d.ts.map +1 -0
- package/dist/system-tables.js +225 -0
- package/dist/system-tables.js.map +1 -0
- package/dist/test.d.ts +57 -0
- package/dist/test.d.ts.map +1 -0
- package/dist/test.js +454 -0
- package/dist/test.js.map +1 -0
- package/dist/vite.d.ts +82 -0
- package/dist/vite.d.ts.map +1 -0
- package/dist/vite.js +82 -0
- package/dist/vite.js.map +1 -0
- package/dist/webpush-crypto.d.ts +39 -0
- package/dist/webpush-crypto.d.ts.map +1 -0
- package/dist/webpush-crypto.js +213 -0
- package/dist/webpush-crypto.js.map +1 -0
- package/dist/webpush-private.d.ts +11 -0
- package/dist/webpush-private.d.ts.map +1 -0
- package/dist/webpush-private.js +11 -0
- package/dist/webpush-private.js.map +1 -0
- package/dist/webpush.d.ts +86 -0
- package/dist/webpush.d.ts.map +1 -0
- package/dist/webpush.js +375 -0
- package/dist/webpush.js.map +1 -0
- package/dist/worker.d.ts +185 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +13021 -0
- package/dist/worker.js.map +1 -0
- package/package.json +161 -8
- 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
|