@agent-native/core 0.115.4 → 0.117.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/agent-native.eject.json +1 -0
- package/corpus/README.md +3 -3
- package/corpus/core/CHANGELOG.md +43 -0
- package/corpus/core/agent-native.eject.json +1 -0
- package/corpus/core/docs/AGENTS.md +1 -1
- package/corpus/core/docs/content/agent-mentions.mdx +2 -2
- package/corpus/core/docs/content/{workspace.mdx → agent-resources.mdx} +1 -7
- package/corpus/core/docs/content/agent-teams.mdx +4 -4
- package/corpus/core/docs/content/dispatch.mdx +10 -10
- package/corpus/core/docs/content/drop-in-agent.mdx +4 -4
- package/corpus/core/docs/content/external-agents.mdx +1 -1
- package/corpus/core/docs/content/frames.mdx +1 -1
- package/corpus/core/docs/content/integrations.mdx +13 -2
- package/corpus/core/docs/content/key-concepts.mdx +2 -2
- package/corpus/core/docs/content/mcp-clients.mdx +7 -1
- package/corpus/core/docs/content/multi-app-workspace.mdx +3 -3
- package/corpus/core/docs/content/real-time-collaboration.mdx +33 -15
- package/corpus/core/docs/content/recurring-jobs.mdx +7 -7
- package/corpus/core/docs/content/skills-guide.mdx +4 -4
- package/corpus/core/docs/content/template-brain.mdx +1 -1
- package/corpus/core/docs/content/toolkit-collaboration.mdx +1 -1
- package/corpus/core/docs/content/using-your-agent.mdx +1 -1
- package/corpus/core/docs/content/what-is-agent-native.mdx +4 -4
- package/corpus/core/docs/content/workspace-management.mdx +4 -4
- package/corpus/core/package.json +4 -1
- package/corpus/core/src/agent/production-agent.ts +13 -3
- package/corpus/core/src/cli/doctor.ts +6 -1
- package/corpus/core/src/client/AgentPanel.tsx +79 -41
- package/corpus/core/src/client/AssistantChat.tsx +71 -25
- package/corpus/core/src/client/FeedbackButton.tsx +33 -7
- package/corpus/core/src/client/MultiTabAssistantChat.tsx +6 -8
- package/corpus/core/src/client/agent-chat-adapter.ts +11 -2
- package/corpus/core/src/client/agent-page/AgentTabFrame.tsx +22 -3
- package/corpus/core/src/client/agent-page/AgentTabsPage.tsx +34 -19
- package/corpus/core/src/client/analytics.ts +7 -0
- package/corpus/core/src/client/chat/message-components.tsx +43 -16
- package/corpus/core/src/client/chat/tool-call-display.tsx +30 -18
- package/corpus/core/src/client/chat/tool-render-registry.tsx +2 -0
- package/corpus/core/src/client/resources/McpConnectionSuggestion.tsx +19 -2
- package/corpus/core/src/client/resources/ResourceTree.tsx +7 -10
- package/corpus/core/src/client/resources/ResourcesPanel.tsx +33 -70
- package/corpus/core/src/client/resources/mcp-connection-resume.ts +107 -0
- package/corpus/core/src/client/resources/mcp-integration-catalog.ts +65 -12
- package/corpus/core/src/client/resources/mcp-integration-logos.ts +4 -0
- package/corpus/core/src/client/sse-event-processor.ts +50 -14
- package/corpus/core/src/client/use-db-sync.ts +370 -21
- package/corpus/core/src/collab/client.ts +26 -2
- package/corpus/core/src/connections/catalog.ts +7 -1
- package/corpus/core/src/db/ddl-guard.ts +49 -10
- package/corpus/core/src/deploy/build.ts +25 -2
- package/corpus/core/src/eject/remote-mcp-presets.ts +37 -5
- package/corpus/core/src/guards/explicit-collab-access.ts +229 -0
- package/corpus/core/src/guards/index.ts +1 -0
- package/corpus/core/src/localization/default-messages.ts +54 -5
- package/corpus/core/src/mcp/connect-route.ts +34 -51
- package/corpus/core/src/provider-api/index.ts +1 -1
- package/corpus/core/src/realtime-protocol.ts +92 -0
- package/corpus/core/src/server/collab-plugin.ts +137 -18
- package/corpus/core/src/server/core-routes-plugin.ts +7 -0
- package/corpus/core/src/server/index.ts +2 -0
- package/corpus/core/src/server/poll-events.ts +29 -8
- package/corpus/core/src/server/poll.ts +1123 -865
- package/corpus/core/src/server/realtime-token.ts +106 -0
- package/corpus/core/src/server/sentry-config.ts +40 -0
- package/corpus/core/src/server/short-lived-token.ts +156 -0
- package/corpus/core/src/server/sse.ts +15 -0
- package/corpus/core/src/server/ssr-handler.ts +9 -3
- package/corpus/core/src/styles/agent-native.css +40 -0
- package/corpus/core/src/templates/default/.agents/skills/agent-native-toolkit/SKILL.md +4 -1
- package/corpus/core/src/templates/default/.agents/skills/real-time-collab/SKILL.md +35 -13
- package/corpus/core/src/templates/default/.agents/skills/real-time-sync/SKILL.md +1 -1
- package/corpus/core/src/templates/headless/.agents/skills/agent-native-toolkit/SKILL.md +4 -1
- package/corpus/core/src/templates/workspace-core/.agents/skills/agent-native-toolkit/SKILL.md +4 -1
- package/corpus/core/src/templates/workspace-core/.agents/skills/real-time-collab/SKILL.md +35 -13
- package/corpus/core/src/templates/workspace-core/.agents/skills/real-time-sync/SKILL.md +1 -1
- package/corpus/core/src/templates/workspace-core/.agents/skills/voice-transcription/SKILL.md +12 -8
- package/corpus/templates/analytics/.agents/skills/agent-native-toolkit/SKILL.md +4 -1
- package/corpus/templates/analytics/.agents/skills/real-time-sync/SKILL.md +1 -1
- package/corpus/templates/analytics/AGENTS.md +7 -2
- package/corpus/templates/analytics/actions/export-dashboard-panel-to-google-sheet.ts +144 -0
- package/corpus/templates/analytics/app/components/layout/Layout.tsx +7 -88
- package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +34 -37
- package/corpus/templates/analytics/app/global.css +0 -4
- package/corpus/templates/analytics/app/hooks/use-navigation-state.ts +2 -7
- package/corpus/templates/analytics/app/i18n-data.ts +107 -0
- package/corpus/templates/analytics/app/lib/data-source-status.ts +9 -0
- package/corpus/templates/analytics/app/lib/sql-query.ts +1 -2
- package/corpus/templates/analytics/app/pages/Ask.tsx +2 -7
- package/corpus/templates/analytics/app/pages/DataSources.tsx +48 -0
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/SqlChartCard.tsx +59 -0
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/index.tsx +2 -0
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/report-panel-window.ts +3 -1
- package/corpus/templates/analytics/changelog/2026-07-22-clear-background-chat-progress.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-22-daily-dashboard-email-query-batches.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-22-export-table-panels-directly-to-google-sheets.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-22-full-page-chat-keeps-the-active-conversation-when-moving-to-.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-22-recent-chats-can-expand-in-the-sidebar.md +6 -0
- package/corpus/templates/analytics/seeds/dashboards/agent-native-templates-first-party.json +8 -8
- package/corpus/templates/analytics/server/lib/canonical-first-party-dashboard-repair.ts +31 -0
- package/corpus/templates/analytics/server/lib/dashboard-report-subscriptions.ts +3 -5
- package/corpus/templates/analytics/server/lib/dashboard-report.ts +12 -0
- package/corpus/templates/analytics/server/lib/first-party-dashboard-repair.ts +3 -5
- package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +150 -21
- package/corpus/templates/analytics/server/lib/google-sheets-export.ts +188 -0
- package/corpus/templates/analytics/server/lib/provider-api.ts +7 -2
- package/corpus/templates/analytics/server/plugins/collab.ts +6 -3
- package/corpus/templates/analytics/shared/sql-query-limits.ts +1 -0
- package/corpus/templates/assets/.agents/skills/agent-native-toolkit/SKILL.md +4 -1
- package/corpus/templates/assets/.agents/skills/real-time-collab/SKILL.md +35 -13
- package/corpus/templates/assets/.agents/skills/real-time-sync/SKILL.md +1 -1
- package/corpus/templates/assets/app/components/layout/Layout.tsx +1 -1
- package/corpus/templates/assets/app/components/layout/Sidebar.tsx +54 -62
- package/corpus/templates/assets/app/hooks/use-navigation-state.ts +2 -6
- package/corpus/templates/assets/app/routes/_index.tsx +8 -5
- package/corpus/templates/assets/changelog/2026-07-22-full-page-chat-keeps-the-active-conversation-when-moving-to-.md +6 -0
- package/corpus/templates/assets/changelog/2026-07-22-recent-chats-expand-from-a-compact-list.md +6 -0
- package/corpus/templates/brain/.agents/skills/agent-native-toolkit/SKILL.md +4 -1
- package/corpus/templates/brain/.agents/skills/real-time-sync/SKILL.md +1 -1
- package/corpus/templates/brain/actions/rotate-source-ingest-token.ts +77 -0
- package/corpus/templates/brain/app/components/layout/Layout.tsx +5 -1
- package/corpus/templates/brain/app/components/layout/Sidebar.tsx +38 -51
- package/corpus/templates/brain/app/hooks/use-navigation-state.ts +2 -9
- package/corpus/templates/brain/app/i18n-data.ts +120 -0
- package/corpus/templates/brain/app/routes/sources.tsx +156 -5
- package/corpus/templates/brain/changelog/2026-07-22-chat-runs-always-end-with-an-answer.md +6 -0
- package/corpus/templates/brain/changelog/2026-07-22-clear-background-chat-progress.md +6 -0
- package/corpus/templates/brain/changelog/2026-07-22-compact-sidebar-footer.md +6 -0
- package/corpus/templates/brain/changelog/2026-07-22-full-page-chat-keeps-the-active-conversation-when-moving-to-.md +6 -0
- package/corpus/templates/brain/changelog/2026-07-22-long-chat-titles-stay-inside-the-sidebar.md +6 -0
- package/corpus/templates/brain/changelog/2026-07-22-new-clips-and-webhook-sources-now-show-their-secure-connecti.md +6 -0
- package/corpus/templates/brain/jobs/emit-netlify-brain-queue-cron.ts +125 -0
- package/corpus/templates/brain/netlify.toml +5 -1
- package/corpus/templates/brain/scripts/configure-prod-clips-source.ts +50 -0
- package/corpus/templates/brain/server/lib/capture-sanitization.ts +64 -6
- package/corpus/templates/brain/server/plugins/agent-chat.ts +4 -0
- package/corpus/templates/brain/server/plugins/brain-jobs.ts +1 -0
- package/corpus/templates/brain/server/routes/api/brain/queue/run.post.ts +55 -0
- package/corpus/templates/calendar/.agents/skills/agent-native-toolkit/SKILL.md +4 -1
- package/corpus/templates/calendar/.agents/skills/real-time-sync/SKILL.md +1 -1
- package/corpus/templates/chat/.agents/skills/agent-native-toolkit/SKILL.md +4 -1
- package/corpus/templates/chat/.agents/skills/real-time-sync/SKILL.md +1 -1
- package/corpus/templates/chat/app/components/layout/Layout.tsx +1 -0
- package/corpus/templates/chat/app/components/layout/Sidebar.tsx +43 -67
- package/corpus/templates/chat/app/hooks/use-navigation-state.ts +2 -7
- package/corpus/templates/chat/app/routes/_index.tsx +8 -5
- package/corpus/templates/chat/changelog/2026-07-22-compact-sidebar-footer.md +6 -0
- package/corpus/templates/chat/changelog/2026-07-22-full-page-chat-keeps-the-active-conversation-when-moving-to-.md +6 -0
- package/corpus/templates/chat/changelog/2026-07-22-recent-chats-are-easier-to-scan-and-expand.md +6 -0
- package/corpus/templates/clips/.agents/skills/agent-native-toolkit/SKILL.md +4 -1
- package/corpus/templates/clips/AGENTS.md +9 -1
- package/corpus/templates/clips/actions/export-to-brain.ts +82 -6
- package/corpus/templates/clips/actions/request-transcript.ts +25 -14
- package/corpus/templates/clips/app/components/sharing/share-ui.tsx +12 -0
- package/corpus/templates/clips/app/i18n/en-US.ts +1 -0
- package/corpus/templates/clips/changelog/2026-07-22-clips-transcripts-now-retry-brain-export.md +5 -0
- package/corpus/templates/clips/changelog/2026-07-22-german-umlauts-and-other-non-ascii-characters-now-paste-corr.md +6 -0
- package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +26 -3
- package/corpus/templates/clips/jobs/emit-netlify-brain-export-cron.ts +118 -0
- package/corpus/templates/clips/netlify.toml +4 -1
- package/corpus/templates/clips/scripts/configure-prod-brain-export.ts +92 -0
- package/corpus/templates/clips/server/jobs/brain-export.ts +182 -0
- package/corpus/templates/clips/server/lib/brain-export-state.ts +42 -0
- package/corpus/templates/clips/server/lib/post-finalize-dispatch.ts +5 -1
- package/corpus/templates/clips/server/plugins/calendar-jobs.ts +2 -0
- package/corpus/templates/clips/server/register-secrets.ts +42 -0
- package/corpus/templates/clips/server/routes/api/_agent-native-background/post-finalize-worker.post.ts +10 -1
- package/corpus/templates/clips/server/routes/api/clips/brain-export/run.post.ts +45 -0
- package/corpus/templates/content/.agents/skills/agent-native-toolkit/SKILL.md +4 -1
- package/corpus/templates/content/.agents/skills/real-time-sync/SKILL.md +1 -1
- package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +566 -299
- package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +14 -0
- package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +1 -1
- package/corpus/templates/content/app/i18n-data.ts +90 -0
- package/corpus/templates/content/server/plugins/collab.ts +1 -1
- package/corpus/templates/crm/.agents/skills/agent-native-toolkit/SKILL.md +4 -1
- package/corpus/templates/crm/actions/_crm-data-program-actions.ts +15 -0
- package/corpus/templates/crm/actions/get-crm-dashboard-panel.ts +2 -0
- package/corpus/templates/crm/actions/run.ts +2 -6
- package/corpus/templates/crm/actions/update-crm-record.ts +4 -2
- package/corpus/templates/crm/app/components/layout/CrmLayout.tsx +6 -0
- package/corpus/templates/crm/changelog/2026-07-22-crm-field-validation-now-explains-which-fields-cannot-be-edi.md +6 -0
- package/corpus/templates/crm/changelog/2026-07-22-fixed-hubspot-contact-syncs-missing-records-updated-after-th.md +6 -0
- package/corpus/templates/crm/changelog/2026-07-22-fixed-pipeline-dashboard-panels-so-their-opportunity-data-lo.md +6 -0
- package/corpus/templates/crm/changelog/2026-07-22-full-page-chat-keeps-the-active-conversation-when-moving-to-.md +6 -0
- package/corpus/templates/crm/server/crm/hubspot-adapter.ts +4 -1
- package/corpus/templates/design/.agents/skills/agent-native-toolkit/SKILL.md +4 -1
- package/corpus/templates/design/app/components/layout/Sidebar.tsx +1 -1
- package/corpus/templates/design/server/plugins/collab.ts +11 -8
- package/corpus/templates/dispatch/.agents/skills/agent-native-toolkit/SKILL.md +4 -1
- package/corpus/templates/forms/.agents/skills/agent-native-toolkit/SKILL.md +4 -1
- package/corpus/templates/forms/.agents/skills/real-time-sync/SKILL.md +1 -1
- package/corpus/templates/forms/app/components/layout/Layout.tsx +1 -0
- package/corpus/templates/forms/app/components/layout/Sidebar.tsx +1 -1
- package/corpus/templates/forms/app/hooks/use-navigation-state.ts +2 -9
- package/corpus/templates/forms/app/root.tsx +1 -6
- package/corpus/templates/forms/changelog/2026-07-22-full-page-chat-keeps-the-active-conversation-when-moving-to-.md +6 -0
- package/corpus/templates/macros/.agents/skills/agent-native-toolkit/SKILL.md +4 -1
- package/corpus/templates/mail/.agents/skills/agent-native-toolkit/SKILL.md +4 -1
- package/corpus/templates/mail/.agents/skills/real-time-sync/SKILL.md +1 -1
- package/corpus/templates/mail/app/components/layout/AppLayout.tsx +2 -2
- package/corpus/templates/plan/.agents/skills/agent-native-toolkit/SKILL.md +4 -1
- package/corpus/templates/plan/.agents/skills/real-time-sync/SKILL.md +1 -1
- package/corpus/templates/plan/app/components/layout/Layout.tsx +5 -1
- package/corpus/templates/plan/app/components/layout/Sidebar.tsx +57 -72
- package/corpus/templates/plan/app/hooks/use-navigation-state.ts +2 -9
- package/corpus/templates/plan/app/lib/create-plan-routing.ts +2 -2
- package/corpus/templates/plan/app/pages/PlanChatPage.tsx +0 -1
- package/corpus/templates/plan/changelog/2026-07-22-compact-sidebar-footer.md +6 -0
- package/corpus/templates/plan/changelog/2026-07-22-full-page-chat-keeps-the-active-conversation-when-moving-to-.md +6 -0
- package/corpus/templates/plan/changelog/2026-07-22-recent-chats-and-sidebar-controls-are-more-compact.md +6 -0
- package/corpus/templates/plan/server/plugins/collab.ts +5 -2
- package/corpus/templates/slides/.agents/skills/agent-native-toolkit/SKILL.md +4 -1
- package/corpus/templates/slides/app/components/layout/Sidebar.tsx +1 -1
- package/corpus/templates/slides/server/plugins/collab.ts +11 -8
- package/corpus/templates/tasks/.agents/skills/agent-native-toolkit/SKILL.md +4 -1
- package/corpus/templates/tasks/.agents/skills/real-time-sync/SKILL.md +1 -1
- package/corpus/templates/tasks/app/components/layout/Sidebar.tsx +5 -22
- package/corpus/templates/tasks/changelog/2026-07-22-compact-sidebar-footer.md +6 -0
- package/corpus/toolkit/CHANGELOG.md +7 -0
- package/corpus/toolkit/README.md +12 -3
- package/corpus/toolkit/package.json +1 -1
- package/corpus/toolkit/src/chat-history/ChatHistoryRail.tsx +89 -0
- package/corpus/toolkit/src/chat-history/index.ts +5 -0
- package/corpus/toolkit/src/chat-history.css +59 -0
- package/corpus/toolkit/src/composer/RealtimeVoiceMode.tsx +106 -36
- package/corpus/toolkit/src/composer/VoiceButton.tsx +49 -0
- package/corpus/toolkit/src/composer/realtime-voice-audio-level.ts +2 -1
- package/corpus/toolkit/src/composer/useRealtimeVoiceMode.tsx +5 -3
- package/corpus/toolkit/src/styles.css +35 -0
- package/dist/agent/production-agent.d.ts.map +1 -1
- package/dist/agent/production-agent.js +13 -3
- package/dist/agent/production-agent.js.map +1 -1
- package/dist/cli/doctor.d.ts +1 -1
- package/dist/cli/doctor.d.ts.map +1 -1
- package/dist/cli/doctor.js +5 -2
- package/dist/cli/doctor.js.map +1 -1
- package/dist/client/AgentPanel.d.ts +1 -0
- package/dist/client/AgentPanel.d.ts.map +1 -1
- package/dist/client/AgentPanel.js +38 -18
- package/dist/client/AgentPanel.js.map +1 -1
- package/dist/client/AssistantChat.d.ts.map +1 -1
- package/dist/client/AssistantChat.js +55 -20
- package/dist/client/AssistantChat.js.map +1 -1
- package/dist/client/FeedbackButton.d.ts +4 -2
- package/dist/client/FeedbackButton.d.ts.map +1 -1
- package/dist/client/FeedbackButton.js +18 -2
- package/dist/client/FeedbackButton.js.map +1 -1
- package/dist/client/MultiTabAssistantChat.d.ts +1 -1
- package/dist/client/MultiTabAssistantChat.d.ts.map +1 -1
- package/dist/client/MultiTabAssistantChat.js +2 -3
- package/dist/client/MultiTabAssistantChat.js.map +1 -1
- package/dist/client/agent-chat-adapter.d.ts.map +1 -1
- package/dist/client/agent-chat-adapter.js +11 -3
- package/dist/client/agent-chat-adapter.js.map +1 -1
- package/dist/client/agent-page/AgentTabFrame.d.ts +3 -1
- package/dist/client/agent-page/AgentTabFrame.d.ts.map +1 -1
- package/dist/client/agent-page/AgentTabFrame.js +3 -2
- package/dist/client/agent-page/AgentTabFrame.js.map +1 -1
- package/dist/client/agent-page/AgentTabsPage.d.ts +2 -0
- package/dist/client/agent-page/AgentTabsPage.d.ts.map +1 -1
- package/dist/client/agent-page/AgentTabsPage.js +26 -15
- package/dist/client/agent-page/AgentTabsPage.js.map +1 -1
- package/dist/client/analytics.d.ts +10 -0
- package/dist/client/analytics.d.ts.map +1 -1
- package/dist/client/analytics.js.map +1 -1
- package/dist/client/chat/message-components.d.ts +6 -1
- package/dist/client/chat/message-components.d.ts.map +1 -1
- package/dist/client/chat/message-components.js +17 -11
- package/dist/client/chat/message-components.js.map +1 -1
- package/dist/client/chat/tool-call-display.d.ts +11 -1
- package/dist/client/chat/tool-call-display.d.ts.map +1 -1
- package/dist/client/chat/tool-call-display.js +16 -17
- package/dist/client/chat/tool-call-display.js.map +1 -1
- package/dist/client/chat/tool-render-registry.d.ts +2 -0
- package/dist/client/chat/tool-render-registry.d.ts.map +1 -1
- package/dist/client/chat/tool-render-registry.js.map +1 -1
- package/dist/client/resources/McpConnectionSuggestion.d.ts.map +1 -1
- package/dist/client/resources/McpConnectionSuggestion.js +15 -1
- package/dist/client/resources/McpConnectionSuggestion.js.map +1 -1
- package/dist/client/resources/ResourceTree.d.ts.map +1 -1
- package/dist/client/resources/ResourceTree.js +3 -3
- package/dist/client/resources/ResourceTree.js.map +1 -1
- package/dist/client/resources/ResourcesPanel.d.ts +1 -0
- package/dist/client/resources/ResourcesPanel.d.ts.map +1 -1
- package/dist/client/resources/ResourcesPanel.js +34 -20
- package/dist/client/resources/ResourcesPanel.js.map +1 -1
- package/dist/client/resources/mcp-connection-resume.d.ts +11 -0
- package/dist/client/resources/mcp-connection-resume.d.ts.map +1 -0
- package/dist/client/resources/mcp-connection-resume.js +95 -0
- package/dist/client/resources/mcp-connection-resume.js.map +1 -0
- package/dist/client/resources/mcp-integration-catalog.d.ts +1 -0
- package/dist/client/resources/mcp-integration-catalog.d.ts.map +1 -1
- package/dist/client/resources/mcp-integration-catalog.js +63 -8
- package/dist/client/resources/mcp-integration-catalog.js.map +1 -1
- package/dist/client/resources/mcp-integration-logos.d.ts.map +1 -1
- package/dist/client/resources/mcp-integration-logos.js +4 -0
- package/dist/client/resources/mcp-integration-logos.js.map +1 -1
- package/dist/client/sse-event-processor.d.ts +5 -0
- package/dist/client/sse-event-processor.d.ts.map +1 -1
- package/dist/client/sse-event-processor.js +39 -13
- package/dist/client/sse-event-processor.js.map +1 -1
- package/dist/client/use-db-sync.d.ts +4 -3
- package/dist/client/use-db-sync.d.ts.map +1 -1
- package/dist/client/use-db-sync.js +332 -21
- package/dist/client/use-db-sync.js.map +1 -1
- package/dist/collab/client.d.ts.map +1 -1
- package/dist/collab/client.js +26 -2
- package/dist/collab/client.js.map +1 -1
- package/dist/collab/routes.d.ts +1 -1
- package/dist/connections/catalog.d.ts +1 -1
- package/dist/connections/catalog.d.ts.map +1 -1
- package/dist/connections/catalog.js +7 -1
- package/dist/connections/catalog.js.map +1 -1
- package/dist/db/ddl-guard.d.ts +7 -2
- package/dist/db/ddl-guard.d.ts.map +1 -1
- package/dist/db/ddl-guard.js +21 -9
- package/dist/db/ddl-guard.js.map +1 -1
- package/dist/deploy/build.d.ts.map +1 -1
- package/dist/deploy/build.js +25 -2
- package/dist/deploy/build.js.map +1 -1
- package/dist/eject/remote-mcp-presets.d.ts.map +1 -1
- package/dist/eject/remote-mcp-presets.js +33 -3
- package/dist/eject/remote-mcp-presets.js.map +1 -1
- package/dist/guards/explicit-collab-access.d.ts +8 -0
- package/dist/guards/explicit-collab-access.d.ts.map +1 -0
- package/dist/guards/explicit-collab-access.js +214 -0
- package/dist/guards/explicit-collab-access.js.map +1 -0
- package/dist/guards/index.d.ts +1 -0
- package/dist/guards/index.d.ts.map +1 -1
- package/dist/guards/index.js +1 -0
- package/dist/guards/index.js.map +1 -1
- package/dist/localization/default-messages.d.ts +43 -0
- package/dist/localization/default-messages.d.ts.map +1 -1
- package/dist/localization/default-messages.js +48 -5
- package/dist/localization/default-messages.js.map +1 -1
- package/dist/mcp/connect-route.d.ts.map +1 -1
- package/dist/mcp/connect-route.js +34 -48
- package/dist/mcp/connect-route.js.map +1 -1
- package/dist/observability/routes.d.ts +5 -5
- package/dist/provider-api/actions/custom-provider-registration.d.ts +7 -7
- package/dist/provider-api/actions/provider-api.d.ts +7 -7
- package/dist/provider-api/corpus-jobs.d.ts +2 -2
- package/dist/provider-api/index.js +1 -1
- package/dist/provider-api/index.js.map +1 -1
- package/dist/realtime-protocol.d.ts +40 -0
- package/dist/realtime-protocol.d.ts.map +1 -0
- package/dist/realtime-protocol.js +69 -0
- package/dist/realtime-protocol.js.map +1 -0
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/secrets/routes.d.ts +3 -3
- package/dist/server/collab-plugin.d.ts +34 -3
- package/dist/server/collab-plugin.d.ts.map +1 -1
- package/dist/server/collab-plugin.js +65 -12
- package/dist/server/collab-plugin.js.map +1 -1
- package/dist/server/core-routes-plugin.d.ts.map +1 -1
- package/dist/server/core-routes-plugin.js +3 -0
- package/dist/server/core-routes-plugin.js.map +1 -1
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js.map +1 -1
- package/dist/server/poll-events.d.ts +7 -1
- package/dist/server/poll-events.d.ts.map +1 -1
- package/dist/server/poll-events.js +24 -7
- package/dist/server/poll-events.js.map +1 -1
- package/dist/server/poll.d.ts +223 -42
- package/dist/server/poll.d.ts.map +1 -1
- package/dist/server/poll.js +849 -737
- package/dist/server/poll.js.map +1 -1
- package/dist/server/realtime-token.d.ts +34 -0
- package/dist/server/realtime-token.d.ts.map +1 -0
- package/dist/server/realtime-token.js +85 -0
- package/dist/server/realtime-token.js.map +1 -0
- package/dist/server/sentry-config.d.ts +12 -0
- package/dist/server/sentry-config.d.ts.map +1 -1
- package/dist/server/sentry-config.js +34 -0
- package/dist/server/sentry-config.js.map +1 -1
- package/dist/server/short-lived-token.d.ts +46 -0
- package/dist/server/short-lived-token.d.ts.map +1 -1
- package/dist/server/short-lived-token.js +104 -0
- package/dist/server/short-lived-token.js.map +1 -1
- package/dist/server/sse.d.ts +1 -1
- package/dist/server/sse.d.ts.map +1 -1
- package/dist/server/sse.js +4 -0
- package/dist/server/sse.js.map +1 -1
- package/dist/server/ssr-handler.d.ts.map +1 -1
- package/dist/server/ssr-handler.js +5 -3
- package/dist/server/ssr-handler.js.map +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/dist/styles/agent-native.css +40 -0
- package/dist/templates/chat/.agents/skills/agent-native-toolkit/SKILL.md +4 -1
- package/dist/templates/chat/.agents/skills/real-time-sync/SKILL.md +1 -1
- package/dist/templates/chat/app/components/layout/Layout.tsx +1 -0
- package/dist/templates/chat/app/components/layout/Sidebar.tsx +43 -67
- package/dist/templates/chat/app/hooks/use-navigation-state.ts +2 -7
- package/dist/templates/chat/app/routes/_index.tsx +8 -5
- package/dist/templates/chat/changelog/2026-07-22-compact-sidebar-footer.md +6 -0
- package/dist/templates/chat/changelog/2026-07-22-full-page-chat-keeps-the-active-conversation-when-moving-to-.md +6 -0
- package/dist/templates/chat/changelog/2026-07-22-recent-chats-are-easier-to-scan-and-expand.md +6 -0
- package/dist/templates/default/.agents/skills/agent-native-toolkit/SKILL.md +4 -1
- package/dist/templates/default/.agents/skills/real-time-collab/SKILL.md +35 -13
- package/dist/templates/default/.agents/skills/real-time-sync/SKILL.md +1 -1
- package/dist/templates/headless/.agents/skills/agent-native-toolkit/SKILL.md +4 -1
- package/dist/templates/workspace-core/.agents/skills/agent-native-toolkit/SKILL.md +4 -1
- package/dist/templates/workspace-core/.agents/skills/real-time-collab/SKILL.md +35 -13
- package/dist/templates/workspace-core/.agents/skills/real-time-sync/SKILL.md +1 -1
- package/dist/templates/workspace-core/.agents/skills/voice-transcription/SKILL.md +12 -8
- package/docs/AGENTS.md +1 -1
- package/docs/content/agent-mentions.mdx +2 -2
- package/docs/content/{workspace.mdx → agent-resources.mdx} +1 -7
- package/docs/content/agent-teams.mdx +4 -4
- package/docs/content/dispatch.mdx +10 -10
- package/docs/content/drop-in-agent.mdx +4 -4
- package/docs/content/external-agents.mdx +1 -1
- package/docs/content/frames.mdx +1 -1
- package/docs/content/integrations.mdx +13 -2
- package/docs/content/key-concepts.mdx +2 -2
- package/docs/content/mcp-clients.mdx +7 -1
- package/docs/content/multi-app-workspace.mdx +3 -3
- package/docs/content/real-time-collaboration.mdx +33 -15
- package/docs/content/recurring-jobs.mdx +7 -7
- package/docs/content/skills-guide.mdx +4 -4
- package/docs/content/template-brain.mdx +1 -1
- package/docs/content/toolkit-collaboration.mdx +1 -1
- package/docs/content/using-your-agent.mdx +1 -1
- package/docs/content/what-is-agent-native.mdx +4 -4
- package/docs/content/workspace-management.mdx +4 -4
- package/package.json +6 -3
- package/src/agent/production-agent.ts +13 -3
- package/src/cli/doctor.ts +6 -1
- package/src/client/AgentPanel.tsx +79 -41
- package/src/client/AssistantChat.tsx +71 -25
- package/src/client/FeedbackButton.tsx +33 -7
- package/src/client/MultiTabAssistantChat.tsx +6 -8
- package/src/client/agent-chat-adapter.ts +11 -2
- package/src/client/agent-page/AgentTabFrame.tsx +22 -3
- package/src/client/agent-page/AgentTabsPage.tsx +34 -19
- package/src/client/analytics.ts +7 -0
- package/src/client/chat/message-components.tsx +43 -16
- package/src/client/chat/tool-call-display.tsx +30 -18
- package/src/client/chat/tool-render-registry.tsx +2 -0
- package/src/client/resources/McpConnectionSuggestion.tsx +19 -2
- package/src/client/resources/ResourceTree.tsx +7 -10
- package/src/client/resources/ResourcesPanel.tsx +33 -70
- package/src/client/resources/mcp-connection-resume.ts +107 -0
- package/src/client/resources/mcp-integration-catalog.ts +65 -12
- package/src/client/resources/mcp-integration-logos.ts +4 -0
- package/src/client/sse-event-processor.ts +50 -14
- package/src/client/use-db-sync.ts +370 -21
- package/src/collab/client.ts +26 -2
- package/src/connections/catalog.ts +7 -1
- package/src/db/ddl-guard.ts +49 -10
- package/src/deploy/build.ts +25 -2
- package/src/eject/remote-mcp-presets.ts +37 -5
- package/src/guards/explicit-collab-access.ts +229 -0
- package/src/guards/index.ts +1 -0
- package/src/localization/default-messages.ts +54 -5
- package/src/mcp/connect-route.ts +34 -51
- package/src/provider-api/index.ts +1 -1
- package/src/realtime-protocol.ts +92 -0
- package/src/server/collab-plugin.ts +137 -18
- package/src/server/core-routes-plugin.ts +7 -0
- package/src/server/index.ts +2 -0
- package/src/server/poll-events.ts +29 -8
- package/src/server/poll.ts +1123 -865
- package/src/server/realtime-token.ts +106 -0
- package/src/server/sentry-config.ts +40 -0
- package/src/server/short-lived-token.ts +156 -0
- package/src/server/sse.ts +15 -0
- package/src/server/ssr-handler.ts +9 -3
- package/src/styles/agent-native.css +40 -0
- package/src/templates/chat/.agents/skills/agent-native-toolkit/SKILL.md +4 -1
- package/src/templates/chat/.agents/skills/real-time-sync/SKILL.md +1 -1
- package/src/templates/chat/app/components/layout/Layout.tsx +1 -0
- package/src/templates/chat/app/components/layout/Sidebar.tsx +43 -67
- package/src/templates/chat/app/hooks/use-navigation-state.ts +2 -7
- package/src/templates/chat/app/routes/_index.tsx +8 -5
- package/src/templates/chat/changelog/2026-07-22-compact-sidebar-footer.md +6 -0
- package/src/templates/chat/changelog/2026-07-22-full-page-chat-keeps-the-active-conversation-when-moving-to-.md +6 -0
- package/src/templates/chat/changelog/2026-07-22-recent-chats-are-easier-to-scan-and-expand.md +6 -0
- package/src/templates/default/.agents/skills/agent-native-toolkit/SKILL.md +4 -1
- package/src/templates/default/.agents/skills/real-time-collab/SKILL.md +35 -13
- package/src/templates/default/.agents/skills/real-time-sync/SKILL.md +1 -1
- package/src/templates/headless/.agents/skills/agent-native-toolkit/SKILL.md +4 -1
- package/src/templates/workspace-core/.agents/skills/agent-native-toolkit/SKILL.md +4 -1
- package/src/templates/workspace-core/.agents/skills/real-time-collab/SKILL.md +35 -13
- package/src/templates/workspace-core/.agents/skills/real-time-sync/SKILL.md +1 -1
- package/src/templates/workspace-core/.agents/skills/voice-transcription/SKILL.md +12 -8
|
@@ -11,8 +11,16 @@
|
|
|
11
11
|
* application_state and settings tables' updated_at timestamps. This ensures
|
|
12
12
|
* that changes made by external processes (e.g., CLI actions, cron jobs)
|
|
13
13
|
* are picked up even though they don't call recordChange() in this process.
|
|
14
|
+
*
|
|
15
|
+
* All change-tracking state lives on an {@link AppSyncState} instance rather
|
|
16
|
+
* than in module-level singletons, so a single process can hold one isolated
|
|
17
|
+
* instance per app (the hosted Realtime Gateway serves many apps at once). The
|
|
18
|
+
* module-level exports below delegate to a lazily-created default instance
|
|
19
|
+
* bound to the process-global DB, so self-hosted apps run exactly one code
|
|
20
|
+
* path with no behavioral change.
|
|
14
21
|
*/
|
|
15
22
|
|
|
23
|
+
import { createHash } from "node:crypto";
|
|
16
24
|
import { EventEmitter } from "node:events";
|
|
17
25
|
|
|
18
26
|
import { defineEventHandler, getQuery, setResponseStatus } from "h3";
|
|
@@ -23,7 +31,7 @@ import {
|
|
|
23
31
|
type ActionChangeTarget,
|
|
24
32
|
} from "../action-change-marker.js";
|
|
25
33
|
import { getAppStateEmitter } from "../application-state/emitter.js";
|
|
26
|
-
import { getDbExec, isPostgres } from "../db/client.js";
|
|
34
|
+
import { type DbExec, getDbExec, isPostgres } from "../db/client.js";
|
|
27
35
|
import { ensureIndexExists, ensureTableExists } from "../db/ddl-guard.js";
|
|
28
36
|
import {
|
|
29
37
|
EXTENSION_CHANGE_MARKER_KEY,
|
|
@@ -70,71 +78,20 @@ const MAX_BUFFER = 200;
|
|
|
70
78
|
const DURABLE_READ_LIMIT = 1000;
|
|
71
79
|
const DURABLE_RETENTION_MS = 24 * 60 * 60 * 1000;
|
|
72
80
|
const LEGACY_DB_CHECK_INTERVAL_MS = 1000;
|
|
73
|
-
const DURABLE_LEGACY_DB_CHECK_INTERVAL_MS = 30_000;
|
|
74
|
-
let _version = 0;
|
|
75
|
-
const _buffer: ChangeEvent[] = [];
|
|
81
|
+
export const DURABLE_LEGACY_DB_CHECK_INTERVAL_MS = 30_000;
|
|
76
82
|
export const POLL_CHANGE_EVENT = "poll-change";
|
|
77
|
-
const _pollEmitter = new EventEmitter();
|
|
78
|
-
_pollEmitter.setMaxListeners(0);
|
|
79
|
-
let _syncEventsInitPromise: Promise<boolean> | undefined;
|
|
80
|
-
let _lastDurablePrune = 0;
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Whether we've seeded _version from the DB. In serverless (Netlify,
|
|
84
|
-
* Vercel, etc.) each invocation starts fresh — without seeding, _version
|
|
85
|
-
* resets to 0 and polling clients see the version jump backwards, causing
|
|
86
|
-
* duplicate events and stuck UI.
|
|
87
|
-
*/
|
|
88
|
-
let _versionSeeded = false;
|
|
89
|
-
|
|
90
|
-
/** Tracks the latest updated_at we've seen from the DB, per table. */
|
|
91
|
-
let _lastDbCheck = 0;
|
|
92
|
-
// Coalesces concurrent checkExternalDbChanges runs. The 1s throttle alone does
|
|
93
|
-
// not prevent overlap when a single check takes longer than 1s — two overlapping
|
|
94
|
-
// runs would each read+advance the shared watermarks and double-emit events.
|
|
95
|
-
let _checkPromise: Promise<void> | null = null;
|
|
96
|
-
let _lastAppStateTs = 0;
|
|
97
|
-
let _lastSettingsTs = 0;
|
|
98
|
-
let _lastExtensionsTs = 0;
|
|
99
|
-
let _lastExtensionsUpdatedAt: string | number | undefined;
|
|
100
|
-
let _lastExtensionMarkerTs = 0;
|
|
101
|
-
let _lastActionMarkerTs = 0;
|
|
102
83
|
|
|
84
|
+
/** TTL for an allowed (true) cache entry. */
|
|
85
|
+
const ACCESS_CACHE_TTL_MS = 30_000;
|
|
103
86
|
/**
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
* the main content subtree via React key.
|
|
108
|
-
*
|
|
109
|
-
* `_screenRefreshInitialized` guards against spurious emits on the first
|
|
110
|
-
* poll after a restart (where an existing row would look like a fresh bump).
|
|
111
|
-
* Once we've taken a baseline reading, any subsequent increase emits.
|
|
87
|
+
* Shorter TTL for a denied (false) entry so a transient DB error (which we
|
|
88
|
+
* fail-closed on) doesn't lock a legitimate user out of the push path for the
|
|
89
|
+
* full 30s — they recover on their next event after this window.
|
|
112
90
|
*/
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
// tracked independently so a refresh triggered by one user only remounts that
|
|
117
|
-
// user's screen (owner-scoped), never every authenticated poller.
|
|
118
|
-
const _lastScreenRefreshTsBySession = new Map<string, number>();
|
|
91
|
+
const ACCESS_CACHE_DENY_TTL_MS = 5_000;
|
|
92
|
+
/** Max cache entries before FIFO eviction kicks in. */
|
|
93
|
+
const ACCESS_CACHE_MAX = 500;
|
|
119
94
|
const SCREEN_REFRESH_KEY = "__screen_refresh__";
|
|
120
|
-
let _localEmittersWired = false;
|
|
121
|
-
|
|
122
|
-
function wireLocalEmitters(): void {
|
|
123
|
-
if (_localEmittersWired) return;
|
|
124
|
-
_localEmittersWired = true;
|
|
125
|
-
getAppStateEmitter().on("app-state", (event) => {
|
|
126
|
-
if (
|
|
127
|
-
event.key === EXTENSION_CHANGE_MARKER_KEY ||
|
|
128
|
-
event.key === ACTION_CHANGE_MARKER_KEY
|
|
129
|
-
) {
|
|
130
|
-
return;
|
|
131
|
-
}
|
|
132
|
-
recordChange(event);
|
|
133
|
-
});
|
|
134
|
-
getSettingsEmitter().on("settings", (event) => {
|
|
135
|
-
recordChange(event);
|
|
136
|
-
});
|
|
137
|
-
}
|
|
138
95
|
|
|
139
96
|
function timestampValue(value: unknown): number {
|
|
140
97
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
@@ -159,122 +116,6 @@ function syncEventsDisabled(): boolean {
|
|
|
159
116
|
);
|
|
160
117
|
}
|
|
161
118
|
|
|
162
|
-
async function ensureSyncEventsTable(): Promise<boolean> {
|
|
163
|
-
if (syncEventsDisabled()) return false;
|
|
164
|
-
if (!_syncEventsInitPromise) {
|
|
165
|
-
_syncEventsInitPromise = (async () => {
|
|
166
|
-
const client = getDbExec();
|
|
167
|
-
const createSql = `
|
|
168
|
-
CREATE TABLE IF NOT EXISTS sync_events (
|
|
169
|
-
id TEXT PRIMARY KEY,
|
|
170
|
-
version BIGINT NOT NULL,
|
|
171
|
-
event_json TEXT NOT NULL,
|
|
172
|
-
source TEXT NOT NULL,
|
|
173
|
-
type TEXT NOT NULL,
|
|
174
|
-
event_key TEXT,
|
|
175
|
-
owner TEXT,
|
|
176
|
-
org_id TEXT,
|
|
177
|
-
resource_type TEXT,
|
|
178
|
-
resource_id TEXT,
|
|
179
|
-
created_at BIGINT NOT NULL
|
|
180
|
-
)
|
|
181
|
-
`;
|
|
182
|
-
|
|
183
|
-
if (isPostgres()) {
|
|
184
|
-
await ensureTableExists("sync_events", createSql);
|
|
185
|
-
await ensureIndexExists(
|
|
186
|
-
"sync_events_version_idx",
|
|
187
|
-
"CREATE INDEX IF NOT EXISTS sync_events_version_idx ON sync_events (version)",
|
|
188
|
-
);
|
|
189
|
-
await ensureIndexExists(
|
|
190
|
-
"sync_events_owner_version_idx",
|
|
191
|
-
"CREATE INDEX IF NOT EXISTS sync_events_owner_version_idx ON sync_events (owner, version)",
|
|
192
|
-
);
|
|
193
|
-
await ensureIndexExists(
|
|
194
|
-
"sync_events_org_version_idx",
|
|
195
|
-
"CREATE INDEX IF NOT EXISTS sync_events_org_version_idx ON sync_events (org_id, version)",
|
|
196
|
-
);
|
|
197
|
-
return true;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
await client.execute(createSql);
|
|
201
|
-
for (const ddl of [
|
|
202
|
-
"CREATE INDEX IF NOT EXISTS sync_events_version_idx ON sync_events (version)",
|
|
203
|
-
"CREATE INDEX IF NOT EXISTS sync_events_owner_version_idx ON sync_events (owner, version)",
|
|
204
|
-
"CREATE INDEX IF NOT EXISTS sync_events_org_version_idx ON sync_events (org_id, version)",
|
|
205
|
-
]) {
|
|
206
|
-
try {
|
|
207
|
-
await client.execute(ddl);
|
|
208
|
-
} catch {
|
|
209
|
-
// Index already exists or the dialect rejected a duplicate.
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
return true;
|
|
213
|
-
})().catch(() => {
|
|
214
|
-
_syncEventsInitPromise = undefined;
|
|
215
|
-
return false;
|
|
216
|
-
});
|
|
217
|
-
}
|
|
218
|
-
return _syncEventsInitPromise;
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
function durableEventId(version: number): string {
|
|
222
|
-
return `${version}-${Math.random().toString(36).slice(2, 10)}`;
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
async function pruneDurableEvents(client: ReturnType<typeof getDbExec>) {
|
|
226
|
-
const now = Date.now();
|
|
227
|
-
if (now - _lastDurablePrune < 5 * 60 * 1000) return;
|
|
228
|
-
_lastDurablePrune = now;
|
|
229
|
-
await client
|
|
230
|
-
.execute({
|
|
231
|
-
sql: "DELETE FROM sync_events WHERE created_at < ?",
|
|
232
|
-
args: [now - DURABLE_RETENTION_MS],
|
|
233
|
-
})
|
|
234
|
-
.catch(() => {});
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
async function persistSyncEvent(event: ChangeEvent): Promise<void> {
|
|
238
|
-
if (!(await ensureSyncEventsTable())) return;
|
|
239
|
-
const client = getDbExec();
|
|
240
|
-
await client
|
|
241
|
-
.execute({
|
|
242
|
-
sql: isPostgres()
|
|
243
|
-
? `INSERT INTO sync_events (id, version, event_json, source, type, event_key, owner, org_id, resource_type, resource_id, created_at)
|
|
244
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
245
|
-
ON CONFLICT (id) DO NOTHING`
|
|
246
|
-
: `INSERT OR IGNORE INTO sync_events (id, version, event_json, source, type, event_key, owner, org_id, resource_type, resource_id, created_at)
|
|
247
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
248
|
-
args: [
|
|
249
|
-
durableEventId(event.version),
|
|
250
|
-
event.version,
|
|
251
|
-
JSON.stringify(event),
|
|
252
|
-
event.source,
|
|
253
|
-
event.type,
|
|
254
|
-
event.key ?? null,
|
|
255
|
-
event.owner ?? null,
|
|
256
|
-
event.orgId ?? null,
|
|
257
|
-
event.resourceType ?? null,
|
|
258
|
-
event.resourceId ?? null,
|
|
259
|
-
Date.now(),
|
|
260
|
-
],
|
|
261
|
-
})
|
|
262
|
-
.catch(() => {});
|
|
263
|
-
await pruneDurableEvents(client);
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
async function readMaxSyncEventVersion(): Promise<number> {
|
|
267
|
-
if (!(await ensureSyncEventsTable())) return 0;
|
|
268
|
-
try {
|
|
269
|
-
const result = await getDbExec().execute(
|
|
270
|
-
"SELECT MAX(version) as max_version FROM sync_events",
|
|
271
|
-
);
|
|
272
|
-
return timestampValue(result.rows[0]?.max_version);
|
|
273
|
-
} catch {
|
|
274
|
-
return 0;
|
|
275
|
-
}
|
|
276
|
-
}
|
|
277
|
-
|
|
278
119
|
async function readMaxUpdatedAtRaw(
|
|
279
120
|
db: {
|
|
280
121
|
execute: (
|
|
@@ -337,44 +178,17 @@ async function readActionMarkerMaxUpdatedAt(db: {
|
|
|
337
178
|
}
|
|
338
179
|
}
|
|
339
180
|
|
|
340
|
-
/** Get the current global version counter. */
|
|
341
|
-
export function getVersion(): number {
|
|
342
|
-
return _version;
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
export function getPollEmitter(): EventEmitter {
|
|
346
|
-
return _pollEmitter;
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
/**
|
|
350
|
-
* In-memory, TTL'd access cache for the access-aware branch of
|
|
351
|
-
* `canSeeChangeForUser`. Keyed by `${userEmail}|${resourceType}|${resourceId}`.
|
|
352
|
-
*
|
|
353
|
-
* Insertion order doubles as FIFO for eviction (JS Maps preserve insertion
|
|
354
|
-
* order), so we can evict the oldest entries when we exceed the cap.
|
|
355
|
-
*/
|
|
356
|
-
const _accessCache = new Map<string, { allowed: boolean; checkedAt: number }>();
|
|
357
|
-
/** In-flight background access checks, keyed identically, to dedupe bursts. */
|
|
358
|
-
const _accessInFlight = new Set<string>();
|
|
359
|
-
/** Per-resource generation bumped when shares/visibility change. */
|
|
360
|
-
const _accessInvalidationEpoch = new Map<string, number>();
|
|
361
|
-
/** TTL for an allowed (true) cache entry. */
|
|
362
|
-
const ACCESS_CACHE_TTL_MS = 30_000;
|
|
363
|
-
/**
|
|
364
|
-
* Shorter TTL for a denied (false) entry so a transient DB error (which we
|
|
365
|
-
* fail-closed on) doesn't lock a legitimate user out of the push path for the
|
|
366
|
-
* full 30s — they recover on their next event after this window.
|
|
367
|
-
*/
|
|
368
|
-
const ACCESS_CACHE_DENY_TTL_MS = 5_000;
|
|
369
|
-
/** Max cache entries before FIFO eviction kicks in. */
|
|
370
|
-
const ACCESS_CACHE_MAX = 500;
|
|
371
|
-
|
|
372
181
|
function accessCacheKey(
|
|
373
182
|
userEmail: string,
|
|
183
|
+
orgId: string | undefined,
|
|
374
184
|
resourceType: string,
|
|
375
185
|
resourceId: string,
|
|
376
186
|
): string {
|
|
377
|
-
|
|
187
|
+
// orgId is part of the key: org visibility and org shares are org-scoped, so a
|
|
188
|
+
// user in multiple orgs must not reuse an org-A decision under an org-B
|
|
189
|
+
// session. The trailing `|resourceType|resourceId` still lets
|
|
190
|
+
// invalidateCollabAccessCache match by suffix.
|
|
191
|
+
return `${userEmail}|${orgId ?? ""}|${resourceType}|${resourceId}`;
|
|
378
192
|
}
|
|
379
193
|
|
|
380
194
|
function accessResourceKey(resourceType: string, resourceId: string): string {
|
|
@@ -385,189 +199,6 @@ function accessCacheTtl(allowed: boolean): number {
|
|
|
385
199
|
return allowed ? ACCESS_CACHE_TTL_MS : ACCESS_CACHE_DENY_TTL_MS;
|
|
386
200
|
}
|
|
387
201
|
|
|
388
|
-
export function invalidateCollabAccessCache(
|
|
389
|
-
resourceType: string,
|
|
390
|
-
resourceId: string,
|
|
391
|
-
): void {
|
|
392
|
-
const resourceKey = accessResourceKey(resourceType, resourceId);
|
|
393
|
-
_accessInvalidationEpoch.set(
|
|
394
|
-
resourceKey,
|
|
395
|
-
(_accessInvalidationEpoch.get(resourceKey) ?? 0) + 1,
|
|
396
|
-
);
|
|
397
|
-
const suffix = `|${resourceKey}`;
|
|
398
|
-
for (const key of Array.from(_accessCache.keys())) {
|
|
399
|
-
if (key.endsWith(suffix)) _accessCache.delete(key);
|
|
400
|
-
}
|
|
401
|
-
for (const key of Array.from(_accessInFlight)) {
|
|
402
|
-
if (key.endsWith(suffix)) _accessInFlight.delete(key);
|
|
403
|
-
}
|
|
404
|
-
}
|
|
405
|
-
|
|
406
|
-
function setAccessCache(key: string, allowed: boolean, now: number): void {
|
|
407
|
-
// Re-insert so the key moves to the end (most-recent) for FIFO ordering.
|
|
408
|
-
_accessCache.delete(key);
|
|
409
|
-
_accessCache.set(key, { allowed, checkedAt: now });
|
|
410
|
-
if (_accessCache.size > ACCESS_CACHE_MAX) {
|
|
411
|
-
// Evict the oldest entries (front of insertion order) back under the cap.
|
|
412
|
-
const overflow = _accessCache.size - ACCESS_CACHE_MAX;
|
|
413
|
-
let removed = 0;
|
|
414
|
-
for (const oldestKey of _accessCache.keys()) {
|
|
415
|
-
_accessCache.delete(oldestKey);
|
|
416
|
-
if (++removed >= overflow) break;
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
/**
|
|
422
|
-
* Fire a background access check for a cache-miss key. Never awaited by the
|
|
423
|
-
* caller — the current event is NOT delivered (we returned false), but the
|
|
424
|
-
* result is cached so the user's NEXT event within the TTL is pushed. Dedupes
|
|
425
|
-
* concurrent checks for the same key via `_accessInFlight`.
|
|
426
|
-
*/
|
|
427
|
-
function scheduleAccessCheck(
|
|
428
|
-
key: string,
|
|
429
|
-
resourceType: string,
|
|
430
|
-
resourceId: string,
|
|
431
|
-
userEmail: string,
|
|
432
|
-
orgId: string | undefined,
|
|
433
|
-
): void {
|
|
434
|
-
if (_accessInFlight.has(key)) return;
|
|
435
|
-
_accessInFlight.add(key);
|
|
436
|
-
const resourceKey = accessResourceKey(resourceType, resourceId);
|
|
437
|
-
const epoch = _accessInvalidationEpoch.get(resourceKey) ?? 0;
|
|
438
|
-
void (async () => {
|
|
439
|
-
try {
|
|
440
|
-
// Dynamic import to avoid a load-order/circular-import hazard: poll.ts is
|
|
441
|
-
// imported very widely, and the sharing/access module pulls in the
|
|
442
|
-
// resource registry. Importing it lazily inside this background function
|
|
443
|
-
// keeps the module graph acyclic at load time.
|
|
444
|
-
const { resolveAccess } = await import("../sharing/access.js");
|
|
445
|
-
const access = await resolveAccess(resourceType, resourceId, {
|
|
446
|
-
userEmail,
|
|
447
|
-
orgId,
|
|
448
|
-
});
|
|
449
|
-
if ((_accessInvalidationEpoch.get(resourceKey) ?? 0) !== epoch) return;
|
|
450
|
-
setAccessCache(key, access != null, Date.now());
|
|
451
|
-
} catch {
|
|
452
|
-
// Fail closed on any error (DB not ready, missing registration, etc.),
|
|
453
|
-
// but with the short deny TTL so a transient failure self-heals quickly.
|
|
454
|
-
if ((_accessInvalidationEpoch.get(resourceKey) ?? 0) !== epoch) return;
|
|
455
|
-
setAccessCache(key, false, Date.now());
|
|
456
|
-
} finally {
|
|
457
|
-
_accessInFlight.delete(key);
|
|
458
|
-
}
|
|
459
|
-
})();
|
|
460
|
-
}
|
|
461
|
-
|
|
462
|
-
/**
|
|
463
|
-
* Test-only: clear the access cache and in-flight set so cases don't bleed
|
|
464
|
-
* into each other. Underscore-prefixed and intentionally NOT part of the
|
|
465
|
-
* public API — do not rely on it outside tests.
|
|
466
|
-
*/
|
|
467
|
-
export function __resetCollabAccessCacheForTests(): void {
|
|
468
|
-
_accessCache.clear();
|
|
469
|
-
_accessInFlight.clear();
|
|
470
|
-
_accessInvalidationEpoch.clear();
|
|
471
|
-
}
|
|
472
|
-
|
|
473
|
-
type ChangeVisibility = "visible" | "hidden" | "pending";
|
|
474
|
-
type ChangeReadResult = {
|
|
475
|
-
version: number;
|
|
476
|
-
events: ChangeEvent[];
|
|
477
|
-
/**
|
|
478
|
-
* True when the returned version is an intentional cursor stop, not the
|
|
479
|
-
* source high-water mark. This happens when access is still pending or when a
|
|
480
|
-
* durable page hit the read limit and more rows may remain unread.
|
|
481
|
-
*/
|
|
482
|
-
cursorLimited?: boolean;
|
|
483
|
-
};
|
|
484
|
-
|
|
485
|
-
/**
|
|
486
|
-
* Decide whether a poll/SSE change event should be delivered to a user.
|
|
487
|
-
*
|
|
488
|
-
* SYNC-CACHE VARIANT — WHY THIS IS SYNCHRONOUS:
|
|
489
|
-
* This function is called on hot, synchronous paths: the SSE emitter callback
|
|
490
|
-
* `push(change)` in poll-events.ts (fires per event) and the
|
|
491
|
-
* `getChangesSinceForUser` loop in this file. Making it async would be
|
|
492
|
-
* invasive (it would ripple through both call sites and their emitters).
|
|
493
|
-
* Instead, for the access-aware branch we consult an in-memory cache and, on a
|
|
494
|
-
* miss, fire a NON-BLOCKING background access check and return `false` for the
|
|
495
|
-
* current event. Because the poll fallback (`getChangesSinceForUser` on the
|
|
496
|
-
* next `/poll` cycle) re-evaluates with the now-populated cache, delivery is
|
|
497
|
-
* eventually guaranteed — the only cost is that the very first event for a
|
|
498
|
-
* fresh (user, resource) pair goes over poll instead of push, and every
|
|
499
|
-
* subsequent event within the TTL is pushed.
|
|
500
|
-
*
|
|
501
|
-
* Security: a cache MISS returns `false`, so we NEVER deliver to a user before
|
|
502
|
-
* their access has been affirmatively confirmed by `resolveAccess` — the same
|
|
503
|
-
* authority that gates the HTTP routes. Errors fail closed (cached deny). The
|
|
504
|
-
* owner/org fast paths below are unchanged and evaluated first.
|
|
505
|
-
*/
|
|
506
|
-
export function canSeeChangeForUser(
|
|
507
|
-
event: Pick<ChangeEvent, "owner" | "orgId" | "resourceType" | "resourceId">,
|
|
508
|
-
userEmail: string,
|
|
509
|
-
orgId: string | undefined,
|
|
510
|
-
): boolean {
|
|
511
|
-
return getChangeVisibilityForUser(event, userEmail, orgId) === "visible";
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
function getChangeVisibilityForUser(
|
|
515
|
-
event: Pick<ChangeEvent, "owner" | "orgId" | "resourceType" | "resourceId">,
|
|
516
|
-
userEmail: string,
|
|
517
|
-
orgId: string | undefined,
|
|
518
|
-
): ChangeVisibility {
|
|
519
|
-
// Global / unowned events: every authenticated user gets them. Events that
|
|
520
|
-
// predate resource tagging (owner/org only, no resourceType) keep the exact
|
|
521
|
-
// conservative contract they had before.
|
|
522
|
-
if (!event.owner && !event.orgId && !event.resourceType) return "visible";
|
|
523
|
-
if (event.owner && event.owner === userEmail) return "visible";
|
|
524
|
-
if (event.orgId && orgId && event.orgId === orgId) return "visible";
|
|
525
|
-
|
|
526
|
-
// Access-aware branch: only when the event carries BOTH resourceType and
|
|
527
|
-
// resourceId and the owner/org fast paths above did not already grant.
|
|
528
|
-
if (event.resourceType && event.resourceId) {
|
|
529
|
-
const key = accessCacheKey(userEmail, event.resourceType, event.resourceId);
|
|
530
|
-
const cached = _accessCache.get(key);
|
|
531
|
-
const now = Date.now();
|
|
532
|
-
if (cached && now - cached.checkedAt < accessCacheTtl(cached.allowed)) {
|
|
533
|
-
// Fresh, non-expired cache hit → trust the cached decision.
|
|
534
|
-
return cached.allowed ? "visible" : "hidden";
|
|
535
|
-
}
|
|
536
|
-
// Miss or expired: do NOT deliver this event, but schedule the async check
|
|
537
|
-
// so the user's next event (or poll cycle) resolves correctly.
|
|
538
|
-
scheduleAccessCheck(
|
|
539
|
-
key,
|
|
540
|
-
event.resourceType,
|
|
541
|
-
event.resourceId,
|
|
542
|
-
userEmail,
|
|
543
|
-
orgId,
|
|
544
|
-
);
|
|
545
|
-
return "pending";
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
return "hidden";
|
|
549
|
-
}
|
|
550
|
-
|
|
551
|
-
/** Record a change event. Called by emitter listeners. */
|
|
552
|
-
export function recordChange(event: {
|
|
553
|
-
source: string;
|
|
554
|
-
type: string;
|
|
555
|
-
key?: string;
|
|
556
|
-
[k: string]: unknown;
|
|
557
|
-
}): void {
|
|
558
|
-
// Use timestamp-aligned versions so all serverless instances produce
|
|
559
|
-
// values in the same range (seeded from DB, then incremented via
|
|
560
|
-
// Date.now). Plain ++counter diverges across cold starts.
|
|
561
|
-
_version = Math.max(_version + 1, Date.now());
|
|
562
|
-
const entry: ChangeEvent = { ...event, version: _version };
|
|
563
|
-
_buffer.push(entry);
|
|
564
|
-
if (_buffer.length > MAX_BUFFER) {
|
|
565
|
-
_buffer.splice(0, _buffer.length - MAX_BUFFER);
|
|
566
|
-
}
|
|
567
|
-
_pollEmitter.emit(POLL_CHANGE_EVENT, entry);
|
|
568
|
-
void persistSyncEvent(entry);
|
|
569
|
-
}
|
|
570
|
-
|
|
571
202
|
function extensionTargetKey(target: ExtensionChangeTarget): string | null {
|
|
572
203
|
if (target.owner) return `owner:${target.owner}`;
|
|
573
204
|
if (target.orgId) return `org:${target.orgId}`;
|
|
@@ -582,33 +213,6 @@ function addExtensionTarget(
|
|
|
582
213
|
if (key) targets.set(key, target);
|
|
583
214
|
}
|
|
584
215
|
|
|
585
|
-
function recordExtensionChanges(targets: ExtensionChangeTarget[]): void {
|
|
586
|
-
const uniqueTargets = new Map<string, ExtensionChangeTarget>();
|
|
587
|
-
for (const target of targets) addExtensionTarget(uniqueTargets, target);
|
|
588
|
-
for (const target of uniqueTargets.values()) {
|
|
589
|
-
recordChange({
|
|
590
|
-
source: "extensions",
|
|
591
|
-
type: "change",
|
|
592
|
-
key: "*",
|
|
593
|
-
...(target.owner ? { owner: target.owner } : {}),
|
|
594
|
-
...(target.orgId ? { orgId: target.orgId } : {}),
|
|
595
|
-
});
|
|
596
|
-
}
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
function recordActionChanges(targets: ActionChangeTarget[]): void {
|
|
600
|
-
for (const target of targets) {
|
|
601
|
-
recordChange({
|
|
602
|
-
source: "action",
|
|
603
|
-
type: "change",
|
|
604
|
-
key: target.actionName ?? "*",
|
|
605
|
-
...(target.owner ? { owner: target.owner } : {}),
|
|
606
|
-
...(target.orgId ? { orgId: target.orgId } : {}),
|
|
607
|
-
...(target.requestSource ? { requestSource: target.requestSource } : {}),
|
|
608
|
-
});
|
|
609
|
-
}
|
|
610
|
-
}
|
|
611
|
-
|
|
612
216
|
function extensionTargetsForRow(
|
|
613
217
|
row: Record<string, unknown>,
|
|
614
218
|
shareRows: Array<Record<string, unknown>>,
|
|
@@ -678,513 +282,1163 @@ async function readExtensionTargetsForRows(
|
|
|
678
282
|
);
|
|
679
283
|
}
|
|
680
284
|
|
|
681
|
-
|
|
682
|
-
|
|
285
|
+
type ChangeVisibility = "visible" | "hidden" | "pending";
|
|
286
|
+
|
|
287
|
+
export type ChangeReadResult = {
|
|
683
288
|
version: number;
|
|
684
289
|
events: ChangeEvent[];
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
290
|
+
/**
|
|
291
|
+
* True when the returned version is an intentional cursor stop, not the
|
|
292
|
+
* source high-water mark. This happens when access is still pending or when a
|
|
293
|
+
* durable page hit the read limit and more rows may remain unread.
|
|
294
|
+
*/
|
|
295
|
+
cursorLimited?: boolean;
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Resolve whether `userEmail`/`orgId` may access a shareable resource. Injected
|
|
300
|
+
* so the hosted gateway can bind the check to a specific app's resource
|
|
301
|
+
* registry + DB; the default resolves the framework's process-global registry
|
|
302
|
+
* lazily to avoid a load-order/circular-import hazard (poll.ts is imported very
|
|
303
|
+
* widely and the sharing module pulls in the resource registry).
|
|
304
|
+
*/
|
|
305
|
+
export type AccessResolver = (
|
|
306
|
+
resourceType: string,
|
|
307
|
+
resourceId: string,
|
|
308
|
+
ctx: { userEmail: string; orgId: string | undefined },
|
|
309
|
+
) => Promise<unknown | null>;
|
|
310
|
+
|
|
311
|
+
const defaultResolveAccess: AccessResolver = async (
|
|
312
|
+
resourceType,
|
|
313
|
+
resourceId,
|
|
314
|
+
ctx,
|
|
315
|
+
) => {
|
|
316
|
+
const { resolveAccess } = await import("../sharing/access.js");
|
|
317
|
+
return resolveAccess(resourceType, resourceId, ctx);
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
export interface AppSyncStateOptions {
|
|
321
|
+
/**
|
|
322
|
+
* Per-app DB accessor. Called per query (not memoized here) so a mocked or
|
|
323
|
+
* hot-swapped exec is always honored. Defaults to the process-global
|
|
324
|
+
* `getDbExec`.
|
|
325
|
+
*/
|
|
326
|
+
getDb?: () => DbExec;
|
|
327
|
+
/** Whether this app's DB is Postgres. Defaults to the process-global check. */
|
|
328
|
+
isPostgres?: () => boolean;
|
|
329
|
+
/** Access-aware delivery resolver. Defaults to the framework registry. */
|
|
330
|
+
resolveAccess?: AccessResolver;
|
|
331
|
+
/**
|
|
332
|
+
* Derive durable-event ids deterministically from the event's logical
|
|
333
|
+
* identity plus a caller-supplied dedupe signal (the source row's
|
|
334
|
+
* `updated_at`), instead of the default `version-<random>` id.
|
|
335
|
+
*
|
|
336
|
+
* Off by default: a single-process app has one writer, so random ids are
|
|
337
|
+
* fine and `ON CONFLICT (id) DO NOTHING` never needs to fire. The hosted
|
|
338
|
+
* gateway sets this so two instances that independently detect the same
|
|
339
|
+
* out-of-band write persist the SAME id and dedupe to one row. The signal
|
|
340
|
+
* intentionally excludes `version` (which is per-instance) so it collides
|
|
341
|
+
* across instances.
|
|
342
|
+
*/
|
|
343
|
+
deterministicEventIds?: boolean;
|
|
691
344
|
}
|
|
692
345
|
|
|
693
346
|
/**
|
|
694
|
-
*
|
|
695
|
-
*
|
|
696
|
-
*
|
|
697
|
-
*
|
|
698
|
-
* - Events without an `owner`/`orgId`/`resourceType` are deployment-global
|
|
699
|
-
* (table-level pings, screen-refresh, etc.) and visible to every
|
|
700
|
-
* authenticated user.
|
|
701
|
-
* - Events with `owner === userEmail` go to that user.
|
|
702
|
-
* - Events with `orgId === orgId` go to anyone in that org.
|
|
703
|
-
* - Events carrying `resourceType` + `resourceId` additionally reach explicit
|
|
704
|
-
* viewer+ sharees via the access-aware cache in `canSeeChangeForUser`.
|
|
705
|
-
* - All other owned events are filtered out.
|
|
347
|
+
* Per-app change-tracking state and read path. One instance per deployed app.
|
|
348
|
+
* The framework runs a single default instance ({@link getDefaultAppSyncState})
|
|
349
|
+
* bound to the process DB; the hosted Realtime Gateway constructs one instance
|
|
350
|
+
* per app with that app's pooled Neon connection injected.
|
|
706
351
|
*/
|
|
707
|
-
export
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
352
|
+
export class AppSyncState {
|
|
353
|
+
private readonly getDb: () => DbExec;
|
|
354
|
+
private readonly isPg: () => boolean;
|
|
355
|
+
private readonly resolveAccessFn: AccessResolver;
|
|
356
|
+
private readonly deterministicEventIds: boolean;
|
|
357
|
+
|
|
358
|
+
// Timestamp-aligned versions so all serverless instances produce values in
|
|
359
|
+
// the same range (seeded from DB, then incremented via Date.now). Plain
|
|
360
|
+
// ++counter diverges across cold starts.
|
|
361
|
+
private version = 0;
|
|
362
|
+
private readonly buffer: ChangeEvent[] = [];
|
|
363
|
+
private readonly pollEmitter = new EventEmitter();
|
|
364
|
+
private syncEventsInitPromise: Promise<boolean> | undefined;
|
|
365
|
+
private lastDurablePrune = 0;
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Whether we've seeded `version` from the DB. In serverless (Netlify,
|
|
369
|
+
* Vercel, etc.) each invocation starts fresh — without seeding, `version`
|
|
370
|
+
* resets to 0 and polling clients see the version jump backwards, causing
|
|
371
|
+
* duplicate events and stuck UI.
|
|
372
|
+
*/
|
|
373
|
+
private versionSeeded = false;
|
|
374
|
+
|
|
375
|
+
private lastDbCheck = 0;
|
|
376
|
+
// Coalesces concurrent checkExternalDbChanges runs. The throttle alone does
|
|
377
|
+
// not prevent overlap when a single check takes longer than the interval —
|
|
378
|
+
// two overlapping runs would each read+advance the watermarks and double-emit.
|
|
379
|
+
private checkPromise: Promise<void> | null = null;
|
|
380
|
+
private lastAppStateTs = 0;
|
|
381
|
+
private lastSettingsTs = 0;
|
|
382
|
+
private lastExtensionsTs = 0;
|
|
383
|
+
private lastExtensionsUpdatedAt: string | number | undefined;
|
|
384
|
+
private lastExtensionMarkerTs = 0;
|
|
385
|
+
private lastActionMarkerTs = 0;
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Tracks the latest updated_at seen on the `__screen_refresh__` key.
|
|
389
|
+
* `screenRefreshInitialized` guards against spurious emits on the first poll
|
|
390
|
+
* after a restart (where an existing row would look like a fresh bump).
|
|
391
|
+
*/
|
|
392
|
+
private lastScreenRefreshTs = 0;
|
|
393
|
+
private screenRefreshInitialized = false;
|
|
394
|
+
private readonly lastScreenRefreshTsBySession = new Map<string, number>();
|
|
395
|
+
private localEmittersWired = false;
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* TTL'd access cache for the access-aware branch of `canSeeChangeForUser`,
|
|
399
|
+
* keyed `${userEmail}|${resourceType}|${resourceId}`. Insertion order doubles
|
|
400
|
+
* as FIFO for eviction (JS Maps preserve insertion order). Held per-instance
|
|
401
|
+
* so one gateway process serving many apps never leaks one app's cached
|
|
402
|
+
* access decision into another app's delivery filter.
|
|
403
|
+
*/
|
|
404
|
+
private readonly accessCache = new Map<
|
|
405
|
+
string,
|
|
406
|
+
{ allowed: boolean; checkedAt: number }
|
|
407
|
+
>();
|
|
408
|
+
/** In-flight background access checks, keyed identically, to dedupe bursts. */
|
|
409
|
+
private readonly accessInFlight = new Set<string>();
|
|
410
|
+
/** Per-resource generation bumped when shares/visibility change. */
|
|
411
|
+
private readonly accessInvalidationEpoch = new Map<string, number>();
|
|
412
|
+
|
|
413
|
+
constructor(options: AppSyncStateOptions = {}) {
|
|
414
|
+
this.getDb = options.getDb ?? getDbExec;
|
|
415
|
+
this.isPg = options.isPostgres ?? isPostgres;
|
|
416
|
+
this.resolveAccessFn = options.resolveAccess ?? defaultResolveAccess;
|
|
417
|
+
this.deterministicEventIds = options.deterministicEventIds ?? false;
|
|
418
|
+
this.pollEmitter.setMaxListeners(0);
|
|
714
419
|
}
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Durable-event id. Deterministic (hash of logical identity + `dedupeKey`,
|
|
423
|
+
* excluding the per-instance `version`) when the instance opts in AND a
|
|
424
|
+
* dedupe signal is supplied — that combination lets `ON CONFLICT (id) DO
|
|
425
|
+
* NOTHING` collapse the same out-of-band write detected by multiple gateway
|
|
426
|
+
* instances into one row. Otherwise the historical `version-<random>` id,
|
|
427
|
+
* which is unique-per-write for a single-writer app.
|
|
428
|
+
*/
|
|
429
|
+
private durableEventId(event: ChangeEvent, dedupeKey?: string): string {
|
|
430
|
+
if (this.deterministicEventIds && dedupeKey !== undefined) {
|
|
431
|
+
const identity = [
|
|
432
|
+
event.source,
|
|
433
|
+
event.type,
|
|
434
|
+
event.key ?? "",
|
|
435
|
+
event.owner ?? "",
|
|
436
|
+
event.orgId ?? "",
|
|
437
|
+
event.resourceType ?? "",
|
|
438
|
+
event.resourceId ?? "",
|
|
439
|
+
dedupeKey,
|
|
440
|
+
].join("");
|
|
441
|
+
return createHash("sha256").update(identity).digest("hex").slice(0, 32);
|
|
727
442
|
}
|
|
443
|
+
return `${event.version}-${Math.random().toString(36).slice(2, 10)}`;
|
|
728
444
|
}
|
|
729
|
-
return { version, events };
|
|
730
|
-
}
|
|
731
445
|
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
orgId: string | undefined,
|
|
736
|
-
): Promise<ChangeReadResult> {
|
|
737
|
-
if (since <= 0 || !(await ensureSyncEventsTable())) {
|
|
738
|
-
return { version: _version, events: [] };
|
|
446
|
+
/** Get the current version counter. */
|
|
447
|
+
getVersion(): number {
|
|
448
|
+
return this.version;
|
|
739
449
|
}
|
|
740
450
|
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
// event: deployment-global rows (no owner, no org), the caller's own
|
|
745
|
-
// rows, the caller's org's rows, and resource-scoped rows (owner/org
|
|
746
|
-
// don't gate those — access is decided below by
|
|
747
|
-
// `getChangeVisibilityForUser`'s access-aware branch, which can grant a
|
|
748
|
-
// non-owner sharee, so resource-scoped rows must still flow through that
|
|
749
|
-
// check regardless of who owns them). This lets Postgres/SQLite use the
|
|
750
|
-
// `sync_events_owner_version_idx` / `sync_events_org_version_idx` indexes
|
|
751
|
-
// instead of scanning every tenant's activity on every poll. The OR group
|
|
752
|
-
// is parenthesized so `version > ?` ANDs against the whole group, not
|
|
753
|
-
// just the first term. A caller with no org passes a null `orgId` bind
|
|
754
|
-
// param, which makes `org_id = ?` evaluate to no match for every row
|
|
755
|
-
// (including null-org rows) in both dialects — mirroring the
|
|
756
|
-
// `event.orgId && orgId` truthy check in `getChangeVisibilityForUser`.
|
|
757
|
-
const result = await getDbExec().execute({
|
|
758
|
-
sql: `SELECT version, event_json FROM sync_events WHERE version > ?
|
|
759
|
-
AND (
|
|
760
|
-
(owner IS NULL AND org_id IS NULL)
|
|
761
|
-
OR owner = ?
|
|
762
|
-
OR org_id = ?
|
|
763
|
-
OR resource_type IS NOT NULL
|
|
764
|
-
)
|
|
765
|
-
ORDER BY version ASC LIMIT ?`,
|
|
766
|
-
args: [since, userEmail, orgId ?? null, DURABLE_READ_LIMIT + 1],
|
|
767
|
-
});
|
|
768
|
-
const events: ChangeEvent[] = [];
|
|
769
|
-
let version = Math.max(_version, since);
|
|
770
|
-
let lastDurableVersion = since;
|
|
771
|
-
const rows = result.rows.slice(0, DURABLE_READ_LIMIT);
|
|
772
|
-
const overflowVersion = timestampValue(
|
|
773
|
-
result.rows[DURABLE_READ_LIMIT]?.version,
|
|
774
|
-
);
|
|
451
|
+
getPollEmitter(): EventEmitter {
|
|
452
|
+
return this.pollEmitter;
|
|
453
|
+
}
|
|
775
454
|
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
version: rawVersion || (parsed as ChangeEvent).version,
|
|
792
|
-
};
|
|
793
|
-
}
|
|
794
|
-
} catch {
|
|
795
|
-
event = null;
|
|
455
|
+
/**
|
|
456
|
+
* Wire the in-process app-state/settings emitters into `recordChange` so
|
|
457
|
+
* same-process writes surface on the poll/SSE fast path. Idempotent. Only the
|
|
458
|
+
* default (in-process) instance calls this — the gateway learns of changes by
|
|
459
|
+
* tailing the DB, not from these process-global emitters.
|
|
460
|
+
*/
|
|
461
|
+
wireLocalEmitters(): void {
|
|
462
|
+
if (this.localEmittersWired) return;
|
|
463
|
+
this.localEmittersWired = true;
|
|
464
|
+
getAppStateEmitter().on("app-state", (event) => {
|
|
465
|
+
if (
|
|
466
|
+
event.key === EXTENSION_CHANGE_MARKER_KEY ||
|
|
467
|
+
event.key === ACTION_CHANGE_MARKER_KEY
|
|
468
|
+
) {
|
|
469
|
+
return;
|
|
796
470
|
}
|
|
797
|
-
|
|
471
|
+
this.recordChange(event);
|
|
472
|
+
});
|
|
473
|
+
getSettingsEmitter().on("settings", (event) => {
|
|
474
|
+
this.recordChange(event);
|
|
475
|
+
});
|
|
476
|
+
}
|
|
798
477
|
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
478
|
+
async ensureSyncEventsTable(): Promise<boolean> {
|
|
479
|
+
if (syncEventsDisabled()) return false;
|
|
480
|
+
if (!this.syncEventsInitPromise) {
|
|
481
|
+
this.syncEventsInitPromise = (async () => {
|
|
482
|
+
const client = this.getDb();
|
|
483
|
+
const createSql = `
|
|
484
|
+
CREATE TABLE IF NOT EXISTS sync_events (
|
|
485
|
+
id TEXT PRIMARY KEY,
|
|
486
|
+
version BIGINT NOT NULL,
|
|
487
|
+
event_json TEXT NOT NULL,
|
|
488
|
+
source TEXT NOT NULL,
|
|
489
|
+
type TEXT NOT NULL,
|
|
490
|
+
event_key TEXT,
|
|
491
|
+
owner TEXT,
|
|
492
|
+
org_id TEXT,
|
|
493
|
+
resource_type TEXT,
|
|
494
|
+
resource_id TEXT,
|
|
495
|
+
created_at BIGINT NOT NULL
|
|
496
|
+
)
|
|
497
|
+
`;
|
|
498
|
+
|
|
499
|
+
if (this.isPg()) {
|
|
500
|
+
// Run DDL against THIS app's DB, not the process-global one — the
|
|
501
|
+
// gateway injects a per-app getDb, and ddl-guard otherwise probes/
|
|
502
|
+
// creates via the global exec. The dialect override matters for the
|
|
503
|
+
// same reason: ddl-guard's own isPostgres() reads the process-global
|
|
504
|
+
// DB config, which in a gateway process is not this app's dialect.
|
|
505
|
+
const guardOptions = {
|
|
506
|
+
injectedClient: client,
|
|
507
|
+
dialectIsPostgres: true,
|
|
508
|
+
};
|
|
509
|
+
await ensureTableExists("sync_events", createSql, guardOptions);
|
|
510
|
+
await ensureIndexExists(
|
|
511
|
+
"sync_events_version_idx",
|
|
512
|
+
"CREATE INDEX IF NOT EXISTS sync_events_version_idx ON sync_events (version)",
|
|
513
|
+
guardOptions,
|
|
514
|
+
);
|
|
515
|
+
await ensureIndexExists(
|
|
516
|
+
"sync_events_owner_version_idx",
|
|
517
|
+
"CREATE INDEX IF NOT EXISTS sync_events_owner_version_idx ON sync_events (owner, version)",
|
|
518
|
+
guardOptions,
|
|
519
|
+
);
|
|
520
|
+
await ensureIndexExists(
|
|
521
|
+
"sync_events_org_version_idx",
|
|
522
|
+
"CREATE INDEX IF NOT EXISTS sync_events_org_version_idx ON sync_events (org_id, version)",
|
|
523
|
+
guardOptions,
|
|
524
|
+
);
|
|
525
|
+
return true;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
await client.execute(createSql);
|
|
529
|
+
for (const ddl of [
|
|
530
|
+
"CREATE INDEX IF NOT EXISTS sync_events_version_idx ON sync_events (version)",
|
|
531
|
+
"CREATE INDEX IF NOT EXISTS sync_events_owner_version_idx ON sync_events (owner, version)",
|
|
532
|
+
"CREATE INDEX IF NOT EXISTS sync_events_org_version_idx ON sync_events (org_id, version)",
|
|
533
|
+
]) {
|
|
534
|
+
try {
|
|
535
|
+
await client.execute(ddl);
|
|
536
|
+
} catch {
|
|
537
|
+
// Index already exists or the dialect rejected a duplicate.
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
return true;
|
|
541
|
+
})().catch(() => {
|
|
542
|
+
this.syncEventsInitPromise = undefined;
|
|
543
|
+
return false;
|
|
544
|
+
});
|
|
811
545
|
}
|
|
546
|
+
return this.syncEventsInitPromise;
|
|
547
|
+
}
|
|
812
548
|
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
549
|
+
private async pruneDurableEvents(client: DbExec): Promise<void> {
|
|
550
|
+
const now = Date.now();
|
|
551
|
+
if (now - this.lastDurablePrune < 5 * 60 * 1000) return;
|
|
552
|
+
this.lastDurablePrune = now;
|
|
553
|
+
await client
|
|
554
|
+
.execute({
|
|
555
|
+
sql: "DELETE FROM sync_events WHERE created_at < ?",
|
|
556
|
+
args: [now - DURABLE_RETENTION_MS],
|
|
557
|
+
})
|
|
558
|
+
.catch(() => {});
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
async persistSyncEvent(
|
|
562
|
+
event: ChangeEvent,
|
|
563
|
+
dedupeKey?: string,
|
|
564
|
+
): Promise<void> {
|
|
565
|
+
if (!(await this.ensureSyncEventsTable())) return;
|
|
566
|
+
const client = this.getDb();
|
|
567
|
+
await client
|
|
568
|
+
.execute({
|
|
569
|
+
sql: this.isPg()
|
|
570
|
+
? `INSERT INTO sync_events (id, version, event_json, source, type, event_key, owner, org_id, resource_type, resource_id, created_at)
|
|
571
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
572
|
+
ON CONFLICT (id) DO NOTHING`
|
|
573
|
+
: `INSERT OR IGNORE INTO sync_events (id, version, event_json, source, type, event_key, owner, org_id, resource_type, resource_id, created_at)
|
|
574
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
575
|
+
args: [
|
|
576
|
+
this.durableEventId(event, dedupeKey),
|
|
577
|
+
event.version,
|
|
578
|
+
JSON.stringify(event),
|
|
579
|
+
event.source,
|
|
580
|
+
event.type,
|
|
581
|
+
event.key ?? null,
|
|
582
|
+
event.owner ?? null,
|
|
583
|
+
event.orgId ?? null,
|
|
584
|
+
event.resourceType ?? null,
|
|
585
|
+
event.resourceId ?? null,
|
|
586
|
+
Date.now(),
|
|
587
|
+
],
|
|
588
|
+
})
|
|
589
|
+
.catch(() => {});
|
|
590
|
+
await this.pruneDurableEvents(client);
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
async readMaxSyncEventVersion(): Promise<number> {
|
|
594
|
+
if (!(await this.ensureSyncEventsTable())) return 0;
|
|
595
|
+
try {
|
|
596
|
+
const result = await this.getDb().execute(
|
|
597
|
+
"SELECT MAX(version) as max_version FROM sync_events",
|
|
598
|
+
);
|
|
599
|
+
return timestampValue(result.rows[0]?.max_version);
|
|
600
|
+
} catch {
|
|
601
|
+
return 0;
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Oldest retained durable version. Used to detect a reconnect cursor that
|
|
607
|
+
* predates the 24h retention window so the gateway can signal a full
|
|
608
|
+
* resync instead of silently dropping events. One indexed aggregate over
|
|
609
|
+
* `sync_events_version_idx`; no schema change.
|
|
610
|
+
*/
|
|
611
|
+
async readMinSyncEventVersion(): Promise<number> {
|
|
612
|
+
if (!(await this.ensureSyncEventsTable())) return 0;
|
|
613
|
+
try {
|
|
614
|
+
const result = await this.getDb().execute(
|
|
615
|
+
"SELECT MIN(version) as min_version FROM sync_events",
|
|
616
|
+
);
|
|
617
|
+
return timestampValue(result.rows[0]?.min_version);
|
|
618
|
+
} catch {
|
|
619
|
+
return 0;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
invalidateCollabAccessCache(resourceType: string, resourceId: string): void {
|
|
624
|
+
const resourceKey = accessResourceKey(resourceType, resourceId);
|
|
625
|
+
this.accessInvalidationEpoch.set(
|
|
626
|
+
resourceKey,
|
|
627
|
+
(this.accessInvalidationEpoch.get(resourceKey) ?? 0) + 1,
|
|
628
|
+
);
|
|
629
|
+
const suffix = `|${resourceKey}`;
|
|
630
|
+
for (const key of Array.from(this.accessCache.keys())) {
|
|
631
|
+
if (key.endsWith(suffix)) this.accessCache.delete(key);
|
|
632
|
+
}
|
|
633
|
+
for (const key of Array.from(this.accessInFlight)) {
|
|
634
|
+
if (key.endsWith(suffix)) this.accessInFlight.delete(key);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
private setAccessCache(key: string, allowed: boolean, now: number): void {
|
|
639
|
+
// Re-insert so the key moves to the end (most-recent) for FIFO ordering.
|
|
640
|
+
this.accessCache.delete(key);
|
|
641
|
+
this.accessCache.set(key, { allowed, checkedAt: now });
|
|
642
|
+
if (this.accessCache.size > ACCESS_CACHE_MAX) {
|
|
643
|
+
// Evict the oldest entries (front of insertion order) back under the cap.
|
|
644
|
+
const overflow = this.accessCache.size - ACCESS_CACHE_MAX;
|
|
645
|
+
let removed = 0;
|
|
646
|
+
for (const oldestKey of this.accessCache.keys()) {
|
|
647
|
+
this.accessCache.delete(oldestKey);
|
|
648
|
+
if (++removed >= overflow) break;
|
|
821
649
|
}
|
|
822
|
-
return {
|
|
823
|
-
version: Math.max(since, lastDurableVersion),
|
|
824
|
-
events,
|
|
825
|
-
cursorLimited: true,
|
|
826
|
-
};
|
|
827
650
|
}
|
|
651
|
+
}
|
|
828
652
|
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
653
|
+
/**
|
|
654
|
+
* Fire a background access check for a cache-miss key. Never awaited by the
|
|
655
|
+
* caller — the current event is NOT delivered (we returned false), but the
|
|
656
|
+
* result is cached so the user's NEXT event within the TTL is pushed. Dedupes
|
|
657
|
+
* concurrent checks for the same key via `accessInFlight`.
|
|
658
|
+
*/
|
|
659
|
+
private scheduleAccessCheck(
|
|
660
|
+
key: string,
|
|
661
|
+
resourceType: string,
|
|
662
|
+
resourceId: string,
|
|
663
|
+
userEmail: string,
|
|
664
|
+
orgId: string | undefined,
|
|
665
|
+
): void {
|
|
666
|
+
if (this.accessInFlight.has(key)) return;
|
|
667
|
+
this.accessInFlight.add(key);
|
|
668
|
+
const resourceKey = accessResourceKey(resourceType, resourceId);
|
|
669
|
+
const epoch = this.accessInvalidationEpoch.get(resourceKey) ?? 0;
|
|
670
|
+
void (async () => {
|
|
671
|
+
try {
|
|
672
|
+
const access = await this.resolveAccessFn(resourceType, resourceId, {
|
|
673
|
+
userEmail,
|
|
674
|
+
orgId,
|
|
675
|
+
});
|
|
676
|
+
if ((this.accessInvalidationEpoch.get(resourceKey) ?? 0) !== epoch) {
|
|
677
|
+
return;
|
|
678
|
+
}
|
|
679
|
+
this.setAccessCache(key, access != null, Date.now());
|
|
680
|
+
} catch {
|
|
681
|
+
// Fail closed on any error (DB not ready, missing registration, etc.),
|
|
682
|
+
// but with the short deny TTL so a transient failure self-heals quickly.
|
|
683
|
+
if ((this.accessInvalidationEpoch.get(resourceKey) ?? 0) !== epoch) {
|
|
684
|
+
return;
|
|
685
|
+
}
|
|
686
|
+
this.setAccessCache(key, false, Date.now());
|
|
687
|
+
} finally {
|
|
688
|
+
this.accessInFlight.delete(key);
|
|
689
|
+
}
|
|
690
|
+
})();
|
|
832
691
|
}
|
|
833
|
-
}
|
|
834
692
|
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
)
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
693
|
+
/**
|
|
694
|
+
* Test-only: clear the access cache and in-flight set so cases don't bleed
|
|
695
|
+
* into each other. Intentionally NOT part of the public API.
|
|
696
|
+
*/
|
|
697
|
+
__resetAccessCacheForTests(): void {
|
|
698
|
+
this.accessCache.clear();
|
|
699
|
+
this.accessInFlight.clear();
|
|
700
|
+
this.accessInvalidationEpoch.clear();
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* Decide whether a poll/SSE change event should be delivered to a user.
|
|
705
|
+
*
|
|
706
|
+
* SYNC-CACHE VARIANT — WHY THIS IS SYNCHRONOUS:
|
|
707
|
+
* This function is called on hot, synchronous paths: the SSE emitter callback
|
|
708
|
+
* `push(change)` in poll-events.ts (fires per event) and the
|
|
709
|
+
* `getChangesSinceForUser` loop in this file. Making it async would be
|
|
710
|
+
* invasive. Instead, for the access-aware branch we consult an in-memory
|
|
711
|
+
* cache and, on a miss, fire a NON-BLOCKING background access check and
|
|
712
|
+
* return `false` for the current event. Because the poll fallback re-evaluates
|
|
713
|
+
* with the now-populated cache, delivery is eventually guaranteed — the only
|
|
714
|
+
* cost is that the very first event for a fresh (user, resource) pair goes
|
|
715
|
+
* over poll instead of push, and every subsequent event within the TTL is
|
|
716
|
+
* pushed.
|
|
717
|
+
*
|
|
718
|
+
* Security: a cache MISS returns `false`, so we NEVER deliver to a user before
|
|
719
|
+
* their access has been affirmatively confirmed by the resolver — the same
|
|
720
|
+
* authority that gates the HTTP routes. Errors fail closed (cached deny). The
|
|
721
|
+
* owner/org fast paths below are unchanged and evaluated first.
|
|
722
|
+
*/
|
|
723
|
+
canSeeChangeForUser(
|
|
724
|
+
event: Pick<ChangeEvent, "owner" | "orgId" | "resourceType" | "resourceId">,
|
|
725
|
+
userEmail: string,
|
|
726
|
+
orgId: string | undefined,
|
|
727
|
+
): boolean {
|
|
728
|
+
return (
|
|
729
|
+
this.getChangeVisibilityForUser(event, userEmail, orgId) === "visible"
|
|
730
|
+
);
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
private getChangeVisibilityForUser(
|
|
734
|
+
event: Pick<ChangeEvent, "owner" | "orgId" | "resourceType" | "resourceId">,
|
|
735
|
+
userEmail: string,
|
|
736
|
+
orgId: string | undefined,
|
|
737
|
+
): ChangeVisibility {
|
|
738
|
+
// Global / unowned events: every authenticated user gets them. Events that
|
|
739
|
+
// predate resource tagging (owner/org only, no resourceType) keep the exact
|
|
740
|
+
// conservative contract they had before.
|
|
741
|
+
if (!event.owner && !event.orgId && !event.resourceType) return "visible";
|
|
742
|
+
if (event.owner && event.owner === userEmail) return "visible";
|
|
743
|
+
if (event.orgId && orgId && event.orgId === orgId) return "visible";
|
|
744
|
+
|
|
745
|
+
// Access-aware branch: only when the event carries BOTH resourceType and
|
|
746
|
+
// resourceId and the owner/org fast paths above did not already grant.
|
|
747
|
+
if (event.resourceType && event.resourceId) {
|
|
748
|
+
const key = accessCacheKey(
|
|
749
|
+
userEmail,
|
|
750
|
+
orgId,
|
|
855
751
|
event.resourceType,
|
|
856
752
|
event.resourceId,
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
753
|
+
);
|
|
754
|
+
const cached = this.accessCache.get(key);
|
|
755
|
+
const now = Date.now();
|
|
756
|
+
if (cached && now - cached.checkedAt < accessCacheTtl(cached.allowed)) {
|
|
757
|
+
// Fresh, non-expired cache hit → trust the cached decision.
|
|
758
|
+
return cached.allowed ? "visible" : "hidden";
|
|
759
|
+
}
|
|
760
|
+
// Miss or expired: do NOT deliver this event, but schedule the async check
|
|
761
|
+
// so the user's next event (or poll cycle) resolves correctly.
|
|
762
|
+
this.scheduleAccessCheck(
|
|
763
|
+
key,
|
|
764
|
+
event.resourceType,
|
|
765
|
+
event.resourceId,
|
|
766
|
+
userEmail,
|
|
767
|
+
orgId,
|
|
768
|
+
);
|
|
769
|
+
return "pending";
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
return "hidden";
|
|
860
773
|
}
|
|
861
|
-
const events = Array.from(byIdentity.values()).sort(
|
|
862
|
-
(a, b) => a.version - b.version,
|
|
863
|
-
);
|
|
864
|
-
const limitedVersions = [memory, durable]
|
|
865
|
-
.filter((result) => result.cursorLimited)
|
|
866
|
-
.map((result) => result.version);
|
|
867
|
-
return {
|
|
868
|
-
version:
|
|
869
|
-
limitedVersions.length > 0
|
|
870
|
-
? Math.min(...limitedVersions)
|
|
871
|
-
: Math.max(memory.version, durable.version, since),
|
|
872
|
-
events:
|
|
873
|
-
limitedVersions.length > 0
|
|
874
|
-
? events.filter(
|
|
875
|
-
(event) => event.version <= Math.min(...limitedVersions),
|
|
876
|
-
)
|
|
877
|
-
: events,
|
|
878
|
-
};
|
|
879
|
-
}
|
|
880
774
|
|
|
881
|
-
/**
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
775
|
+
/**
|
|
776
|
+
* Record a change event. Called by emitter listeners and the tail detector.
|
|
777
|
+
* `dedupeKey` is a stable cross-instance signal (a source row's `updated_at`)
|
|
778
|
+
* used only when the instance derives deterministic ids; it is a persistence
|
|
779
|
+
* concern and never stored on the event object.
|
|
780
|
+
*/
|
|
781
|
+
recordChange(
|
|
782
|
+
event: {
|
|
783
|
+
source: string;
|
|
784
|
+
type: string;
|
|
785
|
+
key?: string;
|
|
786
|
+
[k: string]: unknown;
|
|
787
|
+
},
|
|
788
|
+
opts?: { dedupeKey?: string },
|
|
789
|
+
): void {
|
|
790
|
+
this.version = Math.max(this.version + 1, Date.now());
|
|
791
|
+
const entry: ChangeEvent = { ...event, version: this.version };
|
|
792
|
+
this.buffer.push(entry);
|
|
793
|
+
if (this.buffer.length > MAX_BUFFER) {
|
|
794
|
+
this.buffer.splice(0, this.buffer.length - MAX_BUFFER);
|
|
795
|
+
}
|
|
796
|
+
this.pollEmitter.emit(POLL_CHANGE_EVENT, entry);
|
|
797
|
+
void this.persistSyncEvent(entry, opts?.dedupeKey);
|
|
798
|
+
}
|
|
888
799
|
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
args: [SCREEN_REFRESH_KEY],
|
|
911
|
-
})
|
|
912
|
-
.catch(() => ({ rows: [] as Record<string, unknown>[] })),
|
|
913
|
-
]);
|
|
914
|
-
|
|
915
|
-
const extensionsTs = timestampValue(extensionsMaxUpdatedAt);
|
|
916
|
-
let refreshTs = 0;
|
|
917
|
-
for (const row of refreshResult.rows) {
|
|
918
|
-
refreshTs = Math.max(refreshTs, timestampValue(row.updated_at));
|
|
800
|
+
private recordExtensionChanges(
|
|
801
|
+
targets: ExtensionChangeTarget[],
|
|
802
|
+
dedupeKey?: string,
|
|
803
|
+
): void {
|
|
804
|
+
const uniqueTargets = new Map<string, ExtensionChangeTarget>();
|
|
805
|
+
for (const target of targets) addExtensionTarget(uniqueTargets, target);
|
|
806
|
+
for (const target of uniqueTargets.values()) {
|
|
807
|
+
this.recordChange(
|
|
808
|
+
{
|
|
809
|
+
source: "extensions",
|
|
810
|
+
type: "change",
|
|
811
|
+
key: "*",
|
|
812
|
+
...(target.owner ? { owner: target.owner } : {}),
|
|
813
|
+
...(target.orgId ? { orgId: target.orgId } : {}),
|
|
814
|
+
},
|
|
815
|
+
dedupeKey !== undefined
|
|
816
|
+
? {
|
|
817
|
+
dedupeKey: `${dedupeKey}|${target.owner ?? ""}|${target.orgId ?? ""}`,
|
|
818
|
+
}
|
|
819
|
+
: undefined,
|
|
820
|
+
);
|
|
919
821
|
}
|
|
822
|
+
}
|
|
920
823
|
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
824
|
+
private recordActionChanges(
|
|
825
|
+
targets: ActionChangeTarget[],
|
|
826
|
+
dedupeKey?: string,
|
|
827
|
+
): void {
|
|
828
|
+
for (const target of targets) {
|
|
829
|
+
this.recordChange(
|
|
830
|
+
{
|
|
831
|
+
source: "action",
|
|
832
|
+
type: "change",
|
|
833
|
+
key: target.actionName ?? "*",
|
|
834
|
+
...(target.owner ? { owner: target.owner } : {}),
|
|
835
|
+
...(target.orgId ? { orgId: target.orgId } : {}),
|
|
836
|
+
...(target.requestSource
|
|
837
|
+
? { requestSource: target.requestSource }
|
|
838
|
+
: {}),
|
|
839
|
+
},
|
|
840
|
+
dedupeKey !== undefined
|
|
841
|
+
? {
|
|
842
|
+
dedupeKey: `${dedupeKey}|${target.actionName ?? ""}|${target.owner ?? ""}|${target.orgId ?? ""}`,
|
|
843
|
+
}
|
|
844
|
+
: undefined,
|
|
845
|
+
);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
931
848
|
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
849
|
+
/** Get all changes after a given version. */
|
|
850
|
+
getChangesSince(since: number): { version: number; events: ChangeEvent[] } {
|
|
851
|
+
if (since >= this.version) {
|
|
852
|
+
return { version: this.version, events: [] };
|
|
853
|
+
}
|
|
854
|
+
const events = this.buffer.filter((e) => e.version > since);
|
|
855
|
+
return { version: this.version, events };
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* Get changes after a given version, filtered to events the caller is
|
|
860
|
+
* allowed to see.
|
|
861
|
+
*/
|
|
862
|
+
getChangesSinceForUser(
|
|
863
|
+
since: number,
|
|
864
|
+
userEmail: string,
|
|
865
|
+
orgId: string | undefined,
|
|
866
|
+
): ChangeReadResult {
|
|
867
|
+
if (since >= this.version) {
|
|
868
|
+
return { version: this.version, events: [] };
|
|
869
|
+
}
|
|
870
|
+
const events: ChangeEvent[] = [];
|
|
871
|
+
let version = this.version;
|
|
872
|
+
for (const event of this.buffer) {
|
|
873
|
+
if (event.version <= since) continue;
|
|
874
|
+
const visibility = this.getChangeVisibilityForUser(
|
|
875
|
+
event,
|
|
876
|
+
userEmail,
|
|
877
|
+
orgId,
|
|
878
|
+
);
|
|
879
|
+
if (visibility === "visible") {
|
|
880
|
+
events.push(event);
|
|
881
|
+
continue;
|
|
882
|
+
}
|
|
883
|
+
if (visibility === "pending") {
|
|
884
|
+
version = Math.max(since, event.version - 1);
|
|
885
|
+
return { version, events, cursorLimited: true };
|
|
950
886
|
}
|
|
951
887
|
}
|
|
952
|
-
|
|
953
|
-
// Skip the redundant cold-start recheck unless there is an existing durable
|
|
954
|
-
// action marker that the first poll still needs to emit.
|
|
955
|
-
_lastDbCheck = actionMarkerTs > 0 ? 0 : Date.now();
|
|
956
|
-
} catch {
|
|
957
|
-
// Tables may not exist yet — ignore
|
|
888
|
+
return { version, events };
|
|
958
889
|
}
|
|
959
|
-
}
|
|
960
890
|
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
const interval = options.durableEvents
|
|
970
|
-
? DURABLE_LEGACY_DB_CHECK_INTERVAL_MS
|
|
971
|
-
: LEGACY_DB_CHECK_INTERVAL_MS;
|
|
972
|
-
if (now - _lastDbCheck < interval) return;
|
|
973
|
-
// Coalesce: if a check is already running, await it instead of starting a
|
|
974
|
-
// second overlapping run that would double-advance the shared watermarks
|
|
975
|
-
// (and double-emit change events).
|
|
976
|
-
if (_checkPromise) return _checkPromise;
|
|
977
|
-
_lastDbCheck = now;
|
|
978
|
-
_checkPromise = doCheckExternalDbChanges().finally(() => {
|
|
979
|
-
_checkPromise = null;
|
|
980
|
-
});
|
|
981
|
-
return _checkPromise;
|
|
982
|
-
}
|
|
891
|
+
async getDurableChangesSinceForUser(
|
|
892
|
+
since: number,
|
|
893
|
+
userEmail: string,
|
|
894
|
+
orgId: string | undefined,
|
|
895
|
+
): Promise<ChangeReadResult> {
|
|
896
|
+
if (since <= 0 || !(await this.ensureSyncEventsTable())) {
|
|
897
|
+
return { version: this.version, events: [] };
|
|
898
|
+
}
|
|
983
899
|
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
args: [SCREEN_REFRESH_KEY],
|
|
1012
|
-
}),
|
|
1013
|
-
readExtensionMarkerMaxUpdatedAt(db),
|
|
1014
|
-
readMaxUpdatedAt(db, "settings"),
|
|
1015
|
-
readMaxUpdatedAtRaw(db, "tools"),
|
|
1016
|
-
]);
|
|
1017
|
-
|
|
1018
|
-
// Check application_state for external writes. Preserve the changed key so
|
|
1019
|
-
// clients can invalidate one-shot command queries (`navigate`, `__set_url__`)
|
|
1020
|
-
// only when those command rows actually change; noisy keys such as
|
|
1021
|
-
// `slide-fit-check` should not wake navigation readers.
|
|
1022
|
-
if (appResult.rows.length > 0) {
|
|
1023
|
-
const appTs = appResult.rows.reduce(
|
|
1024
|
-
(max, row) => Math.max(max, timestampValue(row.updated_at)),
|
|
1025
|
-
_lastAppStateTs,
|
|
900
|
+
try {
|
|
901
|
+
// Scope the fetch to rows that could ever be visible to this caller
|
|
902
|
+
// before paying to JSON.parse and visibility-check every deployment-wide
|
|
903
|
+
// event: deployment-global rows (no owner, no org), the caller's own
|
|
904
|
+
// rows, the caller's org's rows, and resource-scoped rows (access is
|
|
905
|
+
// decided below by the access-aware branch, which can grant a non-owner
|
|
906
|
+
// sharee, so resource-scoped rows must still flow through that check
|
|
907
|
+
// regardless of who owns them). A caller with no org passes a null
|
|
908
|
+
// `orgId` bind param, which makes `org_id = ?` match no row in both
|
|
909
|
+
// dialects — mirroring the `event.orgId && orgId` truthy check.
|
|
910
|
+
const result = await this.getDb().execute({
|
|
911
|
+
sql: `SELECT version, event_json FROM sync_events WHERE version > ?
|
|
912
|
+
AND (
|
|
913
|
+
(owner IS NULL AND org_id IS NULL)
|
|
914
|
+
OR owner = ?
|
|
915
|
+
OR org_id = ?
|
|
916
|
+
OR resource_type IS NOT NULL
|
|
917
|
+
)
|
|
918
|
+
ORDER BY version ASC LIMIT ?`,
|
|
919
|
+
args: [since, userEmail, orgId ?? null, DURABLE_READ_LIMIT + 1],
|
|
920
|
+
});
|
|
921
|
+
const events: ChangeEvent[] = [];
|
|
922
|
+
let version = Math.max(this.version, since);
|
|
923
|
+
let lastDurableVersion = since;
|
|
924
|
+
const rows = result.rows.slice(0, DURABLE_READ_LIMIT);
|
|
925
|
+
const overflowVersion = timestampValue(
|
|
926
|
+
result.rows[DURABLE_READ_LIMIT]?.version,
|
|
1026
927
|
);
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
928
|
+
|
|
929
|
+
for (const row of rows) {
|
|
930
|
+
const rawVersion = timestampValue(row.version);
|
|
931
|
+
if (rawVersion > lastDurableVersion) lastDurableVersion = rawVersion;
|
|
932
|
+
if (rawVersion > version) version = rawVersion;
|
|
933
|
+
let event: ChangeEvent | null = null;
|
|
934
|
+
try {
|
|
935
|
+
const parsed = JSON.parse(String(row.event_json));
|
|
1030
936
|
if (
|
|
1031
|
-
|
|
1032
|
-
|
|
937
|
+
parsed &&
|
|
938
|
+
typeof parsed === "object" &&
|
|
939
|
+
typeof parsed.source === "string" &&
|
|
940
|
+
typeof parsed.type === "string"
|
|
1033
941
|
) {
|
|
1034
|
-
|
|
942
|
+
event = {
|
|
943
|
+
...(parsed as ChangeEvent),
|
|
944
|
+
version: rawVersion || (parsed as ChangeEvent).version,
|
|
945
|
+
};
|
|
1035
946
|
}
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
947
|
+
} catch {
|
|
948
|
+
event = null;
|
|
949
|
+
}
|
|
950
|
+
if (!event) continue;
|
|
951
|
+
|
|
952
|
+
const visibility = this.getChangeVisibilityForUser(
|
|
953
|
+
event,
|
|
954
|
+
userEmail,
|
|
955
|
+
orgId,
|
|
956
|
+
);
|
|
957
|
+
if (visibility === "visible") {
|
|
958
|
+
events.push(event);
|
|
959
|
+
continue;
|
|
1044
960
|
}
|
|
961
|
+
if (visibility === "pending") {
|
|
962
|
+
return {
|
|
963
|
+
version: Math.max(since, event.version - 1),
|
|
964
|
+
events,
|
|
965
|
+
cursorLimited: true,
|
|
966
|
+
};
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
if (rows.length >= DURABLE_READ_LIMIT) {
|
|
971
|
+
if (overflowVersion === lastDurableVersion) {
|
|
972
|
+
const boundaryVersion = lastDurableVersion;
|
|
973
|
+
return {
|
|
974
|
+
version: Math.max(since, boundaryVersion - 1),
|
|
975
|
+
events: events.filter((event) => event.version < boundaryVersion),
|
|
976
|
+
cursorLimited: true,
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
return {
|
|
980
|
+
version: Math.max(since, lastDurableVersion),
|
|
981
|
+
events,
|
|
982
|
+
cursorLimited: true,
|
|
983
|
+
};
|
|
1045
984
|
}
|
|
1046
|
-
|
|
985
|
+
|
|
986
|
+
return { version, events };
|
|
987
|
+
} catch {
|
|
988
|
+
return { version: this.version, events: [] };
|
|
1047
989
|
}
|
|
990
|
+
}
|
|
1048
991
|
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
992
|
+
async getCombinedChangesSinceForUser(
|
|
993
|
+
since: number,
|
|
994
|
+
userEmail: string,
|
|
995
|
+
orgId: string | undefined,
|
|
996
|
+
useDurableEvents: boolean,
|
|
997
|
+
): Promise<{ version: number; events: ChangeEvent[] }> {
|
|
998
|
+
const memory = this.getChangesSinceForUser(since, userEmail, orgId);
|
|
999
|
+
if (!useDurableEvents) return memory;
|
|
1000
|
+
|
|
1001
|
+
const durable = await this.getDurableChangesSinceForUser(
|
|
1002
|
+
since,
|
|
1003
|
+
userEmail,
|
|
1004
|
+
orgId,
|
|
1005
|
+
);
|
|
1006
|
+
const byIdentity = new Map<string, ChangeEvent>();
|
|
1007
|
+
for (const event of [...durable.events, ...memory.events]) {
|
|
1008
|
+
byIdentity.set(
|
|
1009
|
+
JSON.stringify([
|
|
1010
|
+
event.version,
|
|
1011
|
+
event.source,
|
|
1012
|
+
event.type,
|
|
1013
|
+
event.key,
|
|
1014
|
+
event.owner,
|
|
1015
|
+
event.orgId,
|
|
1016
|
+
event.resourceType,
|
|
1017
|
+
event.resourceId,
|
|
1018
|
+
]),
|
|
1019
|
+
event,
|
|
1067
1020
|
);
|
|
1068
|
-
_lastActionMarkerTs = actionMarkerTs;
|
|
1069
1021
|
}
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
// tool writes to application_state under a well-known key; when its
|
|
1073
|
-
// updated_at bumps, emit a distinct event so the client invalidates
|
|
1074
|
-
// all queries (not just the ones matching its default queryKey prefix).
|
|
1075
|
-
// `refreshResult` was read above.
|
|
1076
|
-
const refreshTs = refreshResult.rows.reduce(
|
|
1077
|
-
(max, row) => Math.max(max, timestampValue(row.updated_at)),
|
|
1078
|
-
0,
|
|
1022
|
+
const events = Array.from(byIdentity.values()).sort(
|
|
1023
|
+
(a, b) => a.version - b.version,
|
|
1079
1024
|
);
|
|
1080
|
-
|
|
1081
|
-
|
|
1025
|
+
const limitedVersions = [memory, durable]
|
|
1026
|
+
.filter((result) => result.cursorLimited)
|
|
1027
|
+
.map((result) => result.version);
|
|
1028
|
+
return {
|
|
1029
|
+
version:
|
|
1030
|
+
limitedVersions.length > 0
|
|
1031
|
+
? Math.min(...limitedVersions)
|
|
1032
|
+
: Math.max(memory.version, durable.version, since),
|
|
1033
|
+
events:
|
|
1034
|
+
limitedVersions.length > 0
|
|
1035
|
+
? events.filter(
|
|
1036
|
+
(event) => event.version <= Math.min(...limitedVersions),
|
|
1037
|
+
)
|
|
1038
|
+
: events,
|
|
1039
|
+
};
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
/**
|
|
1043
|
+
* Seed `version` from DB timestamps on the first call so serverless cold
|
|
1044
|
+
* starts don't return version 0 and confuse polling clients.
|
|
1045
|
+
*/
|
|
1046
|
+
async seedVersionFromDb(): Promise<void> {
|
|
1047
|
+
if (this.versionSeeded) return;
|
|
1048
|
+
this.versionSeeded = true;
|
|
1049
|
+
|
|
1050
|
+
try {
|
|
1051
|
+
const db = this.getDb();
|
|
1052
|
+
|
|
1053
|
+
const [
|
|
1054
|
+
syncEventsTs,
|
|
1055
|
+
appTs,
|
|
1056
|
+
settingsTs,
|
|
1057
|
+
extensionsMaxUpdatedAt,
|
|
1058
|
+
extensionMarkerTs,
|
|
1059
|
+
actionMarkerTs,
|
|
1060
|
+
refreshResult,
|
|
1061
|
+
] = await Promise.all([
|
|
1062
|
+
this.readMaxSyncEventVersion(),
|
|
1063
|
+
readMaxUpdatedAt(db, "application_state"),
|
|
1064
|
+
readMaxUpdatedAt(db, "settings"),
|
|
1065
|
+
readMaxUpdatedAtRaw(db, "tools"),
|
|
1066
|
+
readExtensionMarkerMaxUpdatedAt(db),
|
|
1067
|
+
readActionMarkerMaxUpdatedAt(db),
|
|
1068
|
+
db
|
|
1069
|
+
.execute({
|
|
1070
|
+
sql: "SELECT session_id, updated_at FROM application_state WHERE key = ?",
|
|
1071
|
+
args: [SCREEN_REFRESH_KEY],
|
|
1072
|
+
})
|
|
1073
|
+
.catch(() => ({ rows: [] as Record<string, unknown>[] })),
|
|
1074
|
+
]);
|
|
1075
|
+
|
|
1076
|
+
const extensionsTs = timestampValue(extensionsMaxUpdatedAt);
|
|
1077
|
+
let refreshTs = 0;
|
|
1078
|
+
for (const row of refreshResult.rows) {
|
|
1079
|
+
refreshTs = Math.max(refreshTs, timestampValue(row.updated_at));
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
// Seed version — never decrease an already-set value
|
|
1083
|
+
this.version = Math.max(
|
|
1084
|
+
this.version,
|
|
1085
|
+
syncEventsTs,
|
|
1086
|
+
appTs,
|
|
1087
|
+
settingsTs,
|
|
1088
|
+
extensionsTs,
|
|
1089
|
+
extensionMarkerTs,
|
|
1090
|
+
actionMarkerTs,
|
|
1091
|
+
);
|
|
1092
|
+
|
|
1093
|
+
// Set baselines so checkExternalDbChanges detects future writes
|
|
1094
|
+
this.lastAppStateTs = appTs;
|
|
1095
|
+
this.lastSettingsTs = settingsTs;
|
|
1096
|
+
this.lastExtensionsTs = extensionsTs;
|
|
1097
|
+
this.lastExtensionsUpdatedAt = sqlWatermarkValue(extensionsMaxUpdatedAt);
|
|
1098
|
+
this.lastExtensionMarkerTs = extensionMarkerTs;
|
|
1099
|
+
// Action markers are durable specifically so a web server can observe work
|
|
1100
|
+
// performed by a separate action process. Do not baseline past an existing
|
|
1101
|
+
// marker on cold start, or the first poll after the action will miss it.
|
|
1102
|
+
this.lastActionMarkerTs = 0;
|
|
1103
|
+
this.lastScreenRefreshTs = refreshTs;
|
|
1104
|
+
this.lastScreenRefreshTsBySession.clear();
|
|
1082
1105
|
for (const row of refreshResult.rows) {
|
|
1083
1106
|
if (typeof row.session_id === "string") {
|
|
1084
|
-
|
|
1107
|
+
this.lastScreenRefreshTsBySession.set(
|
|
1085
1108
|
row.session_id,
|
|
1086
1109
|
timestampValue(row.updated_at),
|
|
1087
1110
|
);
|
|
1088
1111
|
}
|
|
1089
1112
|
}
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
//
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1113
|
+
this.screenRefreshInitialized = true;
|
|
1114
|
+
// Skip the redundant cold-start recheck unless there is an existing durable
|
|
1115
|
+
// action marker that the first poll still needs to emit.
|
|
1116
|
+
this.lastDbCheck = actionMarkerTs > 0 ? 0 : Date.now();
|
|
1117
|
+
} catch {
|
|
1118
|
+
// Tables may not exist yet — ignore
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
/**
|
|
1123
|
+
* Check for cross-process DB writes by comparing updated_at timestamps.
|
|
1124
|
+
* Throttled per instance.
|
|
1125
|
+
*/
|
|
1126
|
+
async checkExternalDbChanges(options: {
|
|
1127
|
+
durableEvents: boolean;
|
|
1128
|
+
}): Promise<void> {
|
|
1129
|
+
const now = Date.now();
|
|
1130
|
+
const interval = options.durableEvents
|
|
1131
|
+
? DURABLE_LEGACY_DB_CHECK_INTERVAL_MS
|
|
1132
|
+
: LEGACY_DB_CHECK_INTERVAL_MS;
|
|
1133
|
+
if (now - this.lastDbCheck < interval) return;
|
|
1134
|
+
// Coalesce: if a check is already running, await it instead of starting a
|
|
1135
|
+
// second overlapping run that would double-advance the watermarks.
|
|
1136
|
+
if (this.checkPromise) return this.checkPromise;
|
|
1137
|
+
this.lastDbCheck = now;
|
|
1138
|
+
this.checkPromise = this.doCheckExternalDbChanges().finally(() => {
|
|
1139
|
+
this.checkPromise = null;
|
|
1140
|
+
});
|
|
1141
|
+
return this.checkPromise;
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
private async doCheckExternalDbChanges(): Promise<void> {
|
|
1145
|
+
try {
|
|
1146
|
+
const db = this.getDb();
|
|
1147
|
+
|
|
1148
|
+
// These reads are independent — each compares the DB against instance
|
|
1149
|
+
// high-water marks rather than another query's result. Run them
|
|
1150
|
+
// concurrently to shave stacked latency; results are still processed in
|
|
1151
|
+
// the original sequential order, and conditional follow-up queries stay
|
|
1152
|
+
// sequential within their branch.
|
|
1153
|
+
const [
|
|
1154
|
+
appResult,
|
|
1155
|
+
actionMarkerTs,
|
|
1156
|
+
refreshResult,
|
|
1157
|
+
extensionMarkerTs,
|
|
1158
|
+
settingsTs,
|
|
1159
|
+
extensionsMaxUpdatedAt,
|
|
1160
|
+
] = await Promise.all([
|
|
1161
|
+
db.execute({
|
|
1162
|
+
sql: "SELECT session_id, key, updated_at FROM application_state WHERE updated_at > ? ORDER BY updated_at ASC",
|
|
1163
|
+
args: [this.lastAppStateTs],
|
|
1164
|
+
}),
|
|
1165
|
+
readActionMarkerMaxUpdatedAt(db),
|
|
1166
|
+
db.execute({
|
|
1167
|
+
sql: "SELECT session_id, updated_at, value FROM application_state WHERE key = ?",
|
|
1168
|
+
args: [SCREEN_REFRESH_KEY],
|
|
1169
|
+
}),
|
|
1170
|
+
readExtensionMarkerMaxUpdatedAt(db),
|
|
1171
|
+
readMaxUpdatedAt(db, "settings"),
|
|
1172
|
+
readMaxUpdatedAtRaw(db, "tools"),
|
|
1173
|
+
]);
|
|
1174
|
+
|
|
1175
|
+
// Check application_state for external writes. Preserve the changed key so
|
|
1176
|
+
// clients can invalidate one-shot command queries (`navigate`, `__set_url__`)
|
|
1177
|
+
// only when those command rows actually change; noisy keys such as
|
|
1178
|
+
// `slide-fit-check` should not wake navigation readers.
|
|
1179
|
+
if (appResult.rows.length > 0) {
|
|
1180
|
+
const appTs = appResult.rows.reduce(
|
|
1181
|
+
(max, row) => Math.max(max, timestampValue(row.updated_at)),
|
|
1182
|
+
this.lastAppStateTs,
|
|
1183
|
+
);
|
|
1184
|
+
if (this.lastAppStateTs > 0) {
|
|
1185
|
+
for (const row of appResult.rows) {
|
|
1186
|
+
const key = typeof row.key === "string" ? row.key : "*";
|
|
1187
|
+
if (
|
|
1188
|
+
key === EXTENSION_CHANGE_MARKER_KEY ||
|
|
1189
|
+
key === ACTION_CHANGE_MARKER_KEY
|
|
1190
|
+
) {
|
|
1191
|
+
continue;
|
|
1192
|
+
}
|
|
1193
|
+
const owner =
|
|
1194
|
+
typeof row.session_id === "string" ? row.session_id : undefined;
|
|
1195
|
+
this.recordChange(
|
|
1196
|
+
{
|
|
1197
|
+
source: "app-state",
|
|
1198
|
+
type: "change",
|
|
1199
|
+
key,
|
|
1200
|
+
...(owner ? { owner } : {}),
|
|
1201
|
+
},
|
|
1202
|
+
{ dedupeKey: `app-state|${timestampValue(row.updated_at)}` },
|
|
1203
|
+
);
|
|
1107
1204
|
}
|
|
1108
|
-
}
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1205
|
+
}
|
|
1206
|
+
this.lastAppStateTs = appTs;
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
// Mutating actions write a durable marker in addition to the in-process
|
|
1210
|
+
// event. This lets dev-mode `pnpm action ...` child processes and
|
|
1211
|
+
// serverless action invocations wake the web server's SSE/poll loop as a
|
|
1212
|
+
// first-class source:"action" event rather than a generic app-state bump.
|
|
1213
|
+
if (actionMarkerTs > this.lastActionMarkerTs) {
|
|
1214
|
+
const actionMarkerResult = await db.execute({
|
|
1215
|
+
sql: "SELECT session_id, value, updated_at FROM application_state WHERE key = ? ORDER BY updated_at ASC",
|
|
1216
|
+
args: [ACTION_CHANGE_MARKER_KEY],
|
|
1115
1217
|
});
|
|
1116
|
-
|
|
1218
|
+
const changedActionMarkers = actionMarkerResult.rows.filter(
|
|
1219
|
+
(row) => timestampValue(row.updated_at) > this.lastActionMarkerTs,
|
|
1220
|
+
);
|
|
1221
|
+
this.recordActionChanges(
|
|
1222
|
+
changedActionMarkers
|
|
1223
|
+
.map((row) => parseActionChangeMarker(row.session_id, row.value))
|
|
1224
|
+
.filter((target): target is ActionChangeTarget => !!target),
|
|
1225
|
+
`action|${actionMarkerTs}`,
|
|
1226
|
+
);
|
|
1227
|
+
this.lastActionMarkerTs = actionMarkerTs;
|
|
1117
1228
|
}
|
|
1118
|
-
_lastScreenRefreshTs = refreshTs;
|
|
1119
|
-
}
|
|
1120
1229
|
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
sql: "SELECT session_id, value, updated_at FROM application_state WHERE key = ? ORDER BY updated_at ASC",
|
|
1129
|
-
args: [EXTENSION_CHANGE_MARKER_KEY],
|
|
1130
|
-
});
|
|
1131
|
-
const changedExtensionMarkers = extensionMarkerResult.rows.filter(
|
|
1132
|
-
(row) => timestampValue(row.updated_at) > _lastExtensionMarkerTs,
|
|
1230
|
+
// Check for screen-refresh requests from the agent. The `refresh-screen`
|
|
1231
|
+
// tool writes to application_state under a well-known key; when its
|
|
1232
|
+
// updated_at bumps, emit a distinct event so the client invalidates
|
|
1233
|
+
// all queries (not just the ones matching its default queryKey prefix).
|
|
1234
|
+
const refreshTs = refreshResult.rows.reduce(
|
|
1235
|
+
(max, row) => Math.max(max, timestampValue(row.updated_at)),
|
|
1236
|
+
0,
|
|
1133
1237
|
);
|
|
1134
|
-
if (
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
.
|
|
1238
|
+
if (!this.screenRefreshInitialized) {
|
|
1239
|
+
this.lastScreenRefreshTs = refreshTs;
|
|
1240
|
+
for (const row of refreshResult.rows) {
|
|
1241
|
+
if (typeof row.session_id === "string") {
|
|
1242
|
+
this.lastScreenRefreshTsBySession.set(
|
|
1243
|
+
row.session_id,
|
|
1244
|
+
timestampValue(row.updated_at),
|
|
1245
|
+
);
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
this.screenRefreshInitialized = true;
|
|
1249
|
+
} else if (refreshTs > this.lastScreenRefreshTs) {
|
|
1250
|
+
// Emit a per-user event only for the session(s) whose row actually
|
|
1251
|
+
// advanced, scoped with `owner` so canSeeChangeForUser delivers it only
|
|
1252
|
+
// to that user — not every authenticated poller.
|
|
1253
|
+
for (const row of refreshResult.rows) {
|
|
1254
|
+
const owner =
|
|
1255
|
+
typeof row.session_id === "string" ? row.session_id : undefined;
|
|
1256
|
+
if (!owner) continue;
|
|
1257
|
+
const rowTs = timestampValue(row.updated_at);
|
|
1258
|
+
if (rowTs <= (this.lastScreenRefreshTsBySession.get(owner) ?? 0)) {
|
|
1259
|
+
continue;
|
|
1260
|
+
}
|
|
1261
|
+
let scope: string | undefined;
|
|
1262
|
+
try {
|
|
1263
|
+
const raw = row.value;
|
|
1264
|
+
if (typeof raw === "string") {
|
|
1265
|
+
const parsed = JSON.parse(raw);
|
|
1266
|
+
if (typeof parsed?.scope === "string") scope = parsed.scope;
|
|
1267
|
+
}
|
|
1268
|
+
} catch {}
|
|
1269
|
+
this.recordChange(
|
|
1270
|
+
{
|
|
1271
|
+
source: "screen-refresh",
|
|
1272
|
+
type: "change",
|
|
1273
|
+
key: SCREEN_REFRESH_KEY,
|
|
1274
|
+
owner,
|
|
1275
|
+
...(scope ? { scope } : {}),
|
|
1276
|
+
},
|
|
1277
|
+
{ dedupeKey: `screen-refresh|${rowTs}` },
|
|
1278
|
+
);
|
|
1279
|
+
this.lastScreenRefreshTsBySession.set(owner, rowTs);
|
|
1280
|
+
}
|
|
1281
|
+
this.lastScreenRefreshTs = refreshTs;
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
// Extension mutations write a durable marker row so delete and hide/unhide
|
|
1285
|
+
// operations are visible across serverless invocations. Translate those
|
|
1286
|
+
// marker rows back into extension-source events for targeted client
|
|
1287
|
+
// invalidation while preserving user/org scope.
|
|
1288
|
+
if (extensionMarkerTs > this.lastExtensionMarkerTs) {
|
|
1289
|
+
const extensionMarkerResult = await db.execute({
|
|
1290
|
+
sql: "SELECT session_id, value, updated_at FROM application_state WHERE key = ? ORDER BY updated_at ASC",
|
|
1291
|
+
args: [EXTENSION_CHANGE_MARKER_KEY],
|
|
1292
|
+
});
|
|
1293
|
+
const changedExtensionMarkers = extensionMarkerResult.rows.filter(
|
|
1294
|
+
(row) => timestampValue(row.updated_at) > this.lastExtensionMarkerTs,
|
|
1139
1295
|
);
|
|
1296
|
+
if (this.lastExtensionMarkerTs > 0) {
|
|
1297
|
+
this.recordExtensionChanges(
|
|
1298
|
+
changedExtensionMarkers
|
|
1299
|
+
.map((row) =>
|
|
1300
|
+
parseExtensionChangeMarker(row.session_id, row.value),
|
|
1301
|
+
)
|
|
1302
|
+
.filter((target): target is ExtensionChangeTarget => !!target),
|
|
1303
|
+
`ext-marker|${extensionMarkerTs}`,
|
|
1304
|
+
);
|
|
1305
|
+
}
|
|
1306
|
+
this.lastExtensionMarkerTs = extensionMarkerTs;
|
|
1140
1307
|
}
|
|
1141
|
-
_lastExtensionMarkerTs = extensionMarkerTs;
|
|
1142
|
-
}
|
|
1143
1308
|
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1309
|
+
// Check settings for external writes.
|
|
1310
|
+
if (settingsTs > this.lastSettingsTs) {
|
|
1311
|
+
if (this.lastSettingsTs > 0) {
|
|
1312
|
+
this.recordChange(
|
|
1313
|
+
{ source: "settings", type: "change", key: "*" },
|
|
1314
|
+
{ dedupeKey: `settings|${settingsTs}` },
|
|
1315
|
+
);
|
|
1316
|
+
}
|
|
1317
|
+
this.lastSettingsTs = settingsTs;
|
|
1148
1318
|
}
|
|
1149
|
-
_lastSettingsTs = settingsTs;
|
|
1150
|
-
}
|
|
1151
1319
|
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1320
|
+
// Extension rows live in the legacy physical `tools` table. Keep this as a
|
|
1321
|
+
// compatibility fallback for direct table writes, but scope events to the
|
|
1322
|
+
// resource owner/share targets instead of broadcasting deployment-wide.
|
|
1323
|
+
const extensionsTs = timestampValue(extensionsMaxUpdatedAt);
|
|
1324
|
+
if (extensionsTs > this.lastExtensionsTs) {
|
|
1325
|
+
const since = this.lastExtensionsUpdatedAt;
|
|
1326
|
+
const extensionResult =
|
|
1327
|
+
since === undefined
|
|
1328
|
+
? await db.execute({
|
|
1329
|
+
sql: "SELECT id, owner_email, org_id, visibility, updated_at FROM tools ORDER BY updated_at ASC",
|
|
1330
|
+
args: [],
|
|
1331
|
+
})
|
|
1332
|
+
: await db.execute({
|
|
1333
|
+
sql: "SELECT id, owner_email, org_id, visibility, updated_at FROM tools WHERE updated_at > ? ORDER BY updated_at ASC",
|
|
1334
|
+
args: [since],
|
|
1335
|
+
});
|
|
1336
|
+
const changedExtensionRows = extensionResult.rows.filter(
|
|
1337
|
+
(row) => timestampValue(row.updated_at) > this.lastExtensionsTs,
|
|
1338
|
+
);
|
|
1339
|
+
if (this.lastExtensionsTs > 0) {
|
|
1340
|
+
const targetsByRow = await readExtensionTargetsForRows(
|
|
1341
|
+
db,
|
|
1342
|
+
changedExtensionRows,
|
|
1343
|
+
);
|
|
1344
|
+
targetsByRow.forEach((targets, i) => {
|
|
1345
|
+
this.recordExtensionChanges(
|
|
1346
|
+
targets,
|
|
1347
|
+
`ext-tools|${timestampValue(changedExtensionRows[i]?.updated_at)}`,
|
|
1348
|
+
);
|
|
1349
|
+
});
|
|
1350
|
+
}
|
|
1351
|
+
this.lastExtensionsTs = extensionsTs;
|
|
1352
|
+
this.lastExtensionsUpdatedAt = sqlWatermarkValue(
|
|
1353
|
+
extensionsMaxUpdatedAt,
|
|
1177
1354
|
);
|
|
1178
|
-
for (const targets of targetsByRow) recordExtensionChanges(targets);
|
|
1179
1355
|
}
|
|
1180
|
-
|
|
1181
|
-
|
|
1356
|
+
} catch {
|
|
1357
|
+
// Tables may not exist yet — ignore
|
|
1182
1358
|
}
|
|
1183
|
-
} catch {
|
|
1184
|
-
// Tables may not exist yet — ignore
|
|
1185
1359
|
}
|
|
1186
1360
|
}
|
|
1187
1361
|
|
|
1362
|
+
let _defaultState: AppSyncState | undefined;
|
|
1363
|
+
|
|
1364
|
+
/**
|
|
1365
|
+
* The process-wide default instance, bound to the global DB. All module-level
|
|
1366
|
+
* exports delegate here so self-hosted apps run exactly one code path.
|
|
1367
|
+
*/
|
|
1368
|
+
export function getDefaultAppSyncState(): AppSyncState {
|
|
1369
|
+
if (!_defaultState) _defaultState = new AppSyncState();
|
|
1370
|
+
return _defaultState;
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
/** Get the current global version counter. */
|
|
1374
|
+
export function getVersion(): number {
|
|
1375
|
+
return getDefaultAppSyncState().getVersion();
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
export function getPollEmitter(): EventEmitter {
|
|
1379
|
+
return getDefaultAppSyncState().getPollEmitter();
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
export function invalidateCollabAccessCache(
|
|
1383
|
+
resourceType: string,
|
|
1384
|
+
resourceId: string,
|
|
1385
|
+
): void {
|
|
1386
|
+
getDefaultAppSyncState().invalidateCollabAccessCache(
|
|
1387
|
+
resourceType,
|
|
1388
|
+
resourceId,
|
|
1389
|
+
);
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
/**
|
|
1393
|
+
* Test-only: clear the default instance's access cache. Underscore-prefixed and
|
|
1394
|
+
* intentionally NOT part of the public API — do not rely on it outside tests.
|
|
1395
|
+
*/
|
|
1396
|
+
export function __resetCollabAccessCacheForTests(): void {
|
|
1397
|
+
getDefaultAppSyncState().__resetAccessCacheForTests();
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
export function canSeeChangeForUser(
|
|
1401
|
+
event: Pick<ChangeEvent, "owner" | "orgId" | "resourceType" | "resourceId">,
|
|
1402
|
+
userEmail: string,
|
|
1403
|
+
orgId: string | undefined,
|
|
1404
|
+
): boolean {
|
|
1405
|
+
return getDefaultAppSyncState().canSeeChangeForUser(event, userEmail, orgId);
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
/** Record a change event. Called by emitter listeners. */
|
|
1409
|
+
export function recordChange(event: {
|
|
1410
|
+
source: string;
|
|
1411
|
+
type: string;
|
|
1412
|
+
key?: string;
|
|
1413
|
+
[k: string]: unknown;
|
|
1414
|
+
}): void {
|
|
1415
|
+
getDefaultAppSyncState().recordChange(event);
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
/** Get all changes after a given version. */
|
|
1419
|
+
export function getChangesSince(since: number): {
|
|
1420
|
+
version: number;
|
|
1421
|
+
events: ChangeEvent[];
|
|
1422
|
+
} {
|
|
1423
|
+
return getDefaultAppSyncState().getChangesSince(since);
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
/**
|
|
1427
|
+
* Get changes after a given version, filtered to events the caller is
|
|
1428
|
+
* allowed to see.
|
|
1429
|
+
*/
|
|
1430
|
+
export function getChangesSinceForUser(
|
|
1431
|
+
since: number,
|
|
1432
|
+
userEmail: string,
|
|
1433
|
+
orgId: string | undefined,
|
|
1434
|
+
): ChangeReadResult {
|
|
1435
|
+
return getDefaultAppSyncState().getChangesSinceForUser(
|
|
1436
|
+
since,
|
|
1437
|
+
userEmail,
|
|
1438
|
+
orgId,
|
|
1439
|
+
);
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1188
1442
|
/**
|
|
1189
1443
|
* Create an H3 handler for the poll endpoint.
|
|
1190
1444
|
*
|
|
@@ -1196,25 +1450,29 @@ async function doCheckExternalDbChanges(): Promise<void> {
|
|
|
1196
1450
|
* Without auth + filtering, an anonymous attacker could poll the deployment
|
|
1197
1451
|
* and infer cross-tenant activity from the global event stream.
|
|
1198
1452
|
*/
|
|
1199
|
-
export function createPollHandler(
|
|
1200
|
-
|
|
1453
|
+
export function createPollHandler(
|
|
1454
|
+
state: AppSyncState = getDefaultAppSyncState(),
|
|
1455
|
+
) {
|
|
1456
|
+
// Only the default (in-process) instance wires the local emitters; a gateway
|
|
1457
|
+
// per-app instance learns of changes by tailing its own DB.
|
|
1458
|
+
if (state === getDefaultAppSyncState()) state.wireLocalEmitters();
|
|
1201
1459
|
return defineEventHandler(async (event) => {
|
|
1202
1460
|
const session = await getSession(event).catch(() => null);
|
|
1203
1461
|
if (!session?.email) {
|
|
1204
1462
|
setResponseStatus(event, 401);
|
|
1205
1463
|
return { error: "Unauthenticated" };
|
|
1206
1464
|
}
|
|
1207
|
-
// On cold start, seed
|
|
1208
|
-
await seedVersionFromDb();
|
|
1209
|
-
const durableEvents = await ensureSyncEventsTable();
|
|
1465
|
+
// On cold start, seed version from DB so we don't return version: 0
|
|
1466
|
+
await state.seedVersionFromDb();
|
|
1467
|
+
const durableEvents = await state.ensureSyncEventsTable();
|
|
1210
1468
|
// Durable sync_events rows are the cheap cross-process path. Keep the
|
|
1211
1469
|
// legacy watermark scan as a slower safety net for direct SQL writes and
|
|
1212
1470
|
// older processes that have not started writing durable events yet.
|
|
1213
|
-
await checkExternalDbChanges({ durableEvents });
|
|
1471
|
+
await state.checkExternalDbChanges({ durableEvents });
|
|
1214
1472
|
|
|
1215
1473
|
const query = getQuery(event);
|
|
1216
1474
|
const since = parseInt(String(query.since ?? "0"), 10) || 0;
|
|
1217
|
-
return getCombinedChangesSinceForUser(
|
|
1475
|
+
return state.getCombinedChangesSinceForUser(
|
|
1218
1476
|
since,
|
|
1219
1477
|
session.email,
|
|
1220
1478
|
session.orgId,
|