@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.
Files changed (487) hide show
  1. package/agent-native.eject.json +1 -0
  2. package/corpus/README.md +3 -3
  3. package/corpus/core/CHANGELOG.md +43 -0
  4. package/corpus/core/agent-native.eject.json +1 -0
  5. package/corpus/core/docs/AGENTS.md +1 -1
  6. package/corpus/core/docs/content/agent-mentions.mdx +2 -2
  7. package/corpus/core/docs/content/{workspace.mdx → agent-resources.mdx} +1 -7
  8. package/corpus/core/docs/content/agent-teams.mdx +4 -4
  9. package/corpus/core/docs/content/dispatch.mdx +10 -10
  10. package/corpus/core/docs/content/drop-in-agent.mdx +4 -4
  11. package/corpus/core/docs/content/external-agents.mdx +1 -1
  12. package/corpus/core/docs/content/frames.mdx +1 -1
  13. package/corpus/core/docs/content/integrations.mdx +13 -2
  14. package/corpus/core/docs/content/key-concepts.mdx +2 -2
  15. package/corpus/core/docs/content/mcp-clients.mdx +7 -1
  16. package/corpus/core/docs/content/multi-app-workspace.mdx +3 -3
  17. package/corpus/core/docs/content/real-time-collaboration.mdx +33 -15
  18. package/corpus/core/docs/content/recurring-jobs.mdx +7 -7
  19. package/corpus/core/docs/content/skills-guide.mdx +4 -4
  20. package/corpus/core/docs/content/template-brain.mdx +1 -1
  21. package/corpus/core/docs/content/toolkit-collaboration.mdx +1 -1
  22. package/corpus/core/docs/content/using-your-agent.mdx +1 -1
  23. package/corpus/core/docs/content/what-is-agent-native.mdx +4 -4
  24. package/corpus/core/docs/content/workspace-management.mdx +4 -4
  25. package/corpus/core/package.json +4 -1
  26. package/corpus/core/src/agent/production-agent.ts +13 -3
  27. package/corpus/core/src/cli/doctor.ts +6 -1
  28. package/corpus/core/src/client/AgentPanel.tsx +79 -41
  29. package/corpus/core/src/client/AssistantChat.tsx +71 -25
  30. package/corpus/core/src/client/FeedbackButton.tsx +33 -7
  31. package/corpus/core/src/client/MultiTabAssistantChat.tsx +6 -8
  32. package/corpus/core/src/client/agent-chat-adapter.ts +11 -2
  33. package/corpus/core/src/client/agent-page/AgentTabFrame.tsx +22 -3
  34. package/corpus/core/src/client/agent-page/AgentTabsPage.tsx +34 -19
  35. package/corpus/core/src/client/analytics.ts +7 -0
  36. package/corpus/core/src/client/chat/message-components.tsx +43 -16
  37. package/corpus/core/src/client/chat/tool-call-display.tsx +30 -18
  38. package/corpus/core/src/client/chat/tool-render-registry.tsx +2 -0
  39. package/corpus/core/src/client/resources/McpConnectionSuggestion.tsx +19 -2
  40. package/corpus/core/src/client/resources/ResourceTree.tsx +7 -10
  41. package/corpus/core/src/client/resources/ResourcesPanel.tsx +33 -70
  42. package/corpus/core/src/client/resources/mcp-connection-resume.ts +107 -0
  43. package/corpus/core/src/client/resources/mcp-integration-catalog.ts +65 -12
  44. package/corpus/core/src/client/resources/mcp-integration-logos.ts +4 -0
  45. package/corpus/core/src/client/sse-event-processor.ts +50 -14
  46. package/corpus/core/src/client/use-db-sync.ts +370 -21
  47. package/corpus/core/src/collab/client.ts +26 -2
  48. package/corpus/core/src/connections/catalog.ts +7 -1
  49. package/corpus/core/src/db/ddl-guard.ts +49 -10
  50. package/corpus/core/src/deploy/build.ts +25 -2
  51. package/corpus/core/src/eject/remote-mcp-presets.ts +37 -5
  52. package/corpus/core/src/guards/explicit-collab-access.ts +229 -0
  53. package/corpus/core/src/guards/index.ts +1 -0
  54. package/corpus/core/src/localization/default-messages.ts +54 -5
  55. package/corpus/core/src/mcp/connect-route.ts +34 -51
  56. package/corpus/core/src/provider-api/index.ts +1 -1
  57. package/corpus/core/src/realtime-protocol.ts +92 -0
  58. package/corpus/core/src/server/collab-plugin.ts +137 -18
  59. package/corpus/core/src/server/core-routes-plugin.ts +7 -0
  60. package/corpus/core/src/server/index.ts +2 -0
  61. package/corpus/core/src/server/poll-events.ts +29 -8
  62. package/corpus/core/src/server/poll.ts +1123 -865
  63. package/corpus/core/src/server/realtime-token.ts +106 -0
  64. package/corpus/core/src/server/sentry-config.ts +40 -0
  65. package/corpus/core/src/server/short-lived-token.ts +156 -0
  66. package/corpus/core/src/server/sse.ts +15 -0
  67. package/corpus/core/src/server/ssr-handler.ts +9 -3
  68. package/corpus/core/src/styles/agent-native.css +40 -0
  69. package/corpus/core/src/templates/default/.agents/skills/agent-native-toolkit/SKILL.md +4 -1
  70. package/corpus/core/src/templates/default/.agents/skills/real-time-collab/SKILL.md +35 -13
  71. package/corpus/core/src/templates/default/.agents/skills/real-time-sync/SKILL.md +1 -1
  72. package/corpus/core/src/templates/headless/.agents/skills/agent-native-toolkit/SKILL.md +4 -1
  73. package/corpus/core/src/templates/workspace-core/.agents/skills/agent-native-toolkit/SKILL.md +4 -1
  74. package/corpus/core/src/templates/workspace-core/.agents/skills/real-time-collab/SKILL.md +35 -13
  75. package/corpus/core/src/templates/workspace-core/.agents/skills/real-time-sync/SKILL.md +1 -1
  76. package/corpus/core/src/templates/workspace-core/.agents/skills/voice-transcription/SKILL.md +12 -8
  77. package/corpus/templates/analytics/.agents/skills/agent-native-toolkit/SKILL.md +4 -1
  78. package/corpus/templates/analytics/.agents/skills/real-time-sync/SKILL.md +1 -1
  79. package/corpus/templates/analytics/AGENTS.md +7 -2
  80. package/corpus/templates/analytics/actions/export-dashboard-panel-to-google-sheet.ts +144 -0
  81. package/corpus/templates/analytics/app/components/layout/Layout.tsx +7 -88
  82. package/corpus/templates/analytics/app/components/layout/Sidebar.tsx +34 -37
  83. package/corpus/templates/analytics/app/global.css +0 -4
  84. package/corpus/templates/analytics/app/hooks/use-navigation-state.ts +2 -7
  85. package/corpus/templates/analytics/app/i18n-data.ts +107 -0
  86. package/corpus/templates/analytics/app/lib/data-source-status.ts +9 -0
  87. package/corpus/templates/analytics/app/lib/sql-query.ts +1 -2
  88. package/corpus/templates/analytics/app/pages/Ask.tsx +2 -7
  89. package/corpus/templates/analytics/app/pages/DataSources.tsx +48 -0
  90. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/SqlChartCard.tsx +59 -0
  91. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/index.tsx +2 -0
  92. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/report-panel-window.ts +3 -1
  93. package/corpus/templates/analytics/changelog/2026-07-22-clear-background-chat-progress.md +6 -0
  94. package/corpus/templates/analytics/changelog/2026-07-22-daily-dashboard-email-query-batches.md +6 -0
  95. package/corpus/templates/analytics/changelog/2026-07-22-export-table-panels-directly-to-google-sheets.md +6 -0
  96. package/corpus/templates/analytics/changelog/2026-07-22-full-page-chat-keeps-the-active-conversation-when-moving-to-.md +6 -0
  97. package/corpus/templates/analytics/changelog/2026-07-22-recent-chats-can-expand-in-the-sidebar.md +6 -0
  98. package/corpus/templates/analytics/seeds/dashboards/agent-native-templates-first-party.json +8 -8
  99. package/corpus/templates/analytics/server/lib/canonical-first-party-dashboard-repair.ts +31 -0
  100. package/corpus/templates/analytics/server/lib/dashboard-report-subscriptions.ts +3 -5
  101. package/corpus/templates/analytics/server/lib/dashboard-report.ts +12 -0
  102. package/corpus/templates/analytics/server/lib/first-party-dashboard-repair.ts +3 -5
  103. package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +150 -21
  104. package/corpus/templates/analytics/server/lib/google-sheets-export.ts +188 -0
  105. package/corpus/templates/analytics/server/lib/provider-api.ts +7 -2
  106. package/corpus/templates/analytics/server/plugins/collab.ts +6 -3
  107. package/corpus/templates/analytics/shared/sql-query-limits.ts +1 -0
  108. package/corpus/templates/assets/.agents/skills/agent-native-toolkit/SKILL.md +4 -1
  109. package/corpus/templates/assets/.agents/skills/real-time-collab/SKILL.md +35 -13
  110. package/corpus/templates/assets/.agents/skills/real-time-sync/SKILL.md +1 -1
  111. package/corpus/templates/assets/app/components/layout/Layout.tsx +1 -1
  112. package/corpus/templates/assets/app/components/layout/Sidebar.tsx +54 -62
  113. package/corpus/templates/assets/app/hooks/use-navigation-state.ts +2 -6
  114. package/corpus/templates/assets/app/routes/_index.tsx +8 -5
  115. package/corpus/templates/assets/changelog/2026-07-22-full-page-chat-keeps-the-active-conversation-when-moving-to-.md +6 -0
  116. package/corpus/templates/assets/changelog/2026-07-22-recent-chats-expand-from-a-compact-list.md +6 -0
  117. package/corpus/templates/brain/.agents/skills/agent-native-toolkit/SKILL.md +4 -1
  118. package/corpus/templates/brain/.agents/skills/real-time-sync/SKILL.md +1 -1
  119. package/corpus/templates/brain/actions/rotate-source-ingest-token.ts +77 -0
  120. package/corpus/templates/brain/app/components/layout/Layout.tsx +5 -1
  121. package/corpus/templates/brain/app/components/layout/Sidebar.tsx +38 -51
  122. package/corpus/templates/brain/app/hooks/use-navigation-state.ts +2 -9
  123. package/corpus/templates/brain/app/i18n-data.ts +120 -0
  124. package/corpus/templates/brain/app/routes/sources.tsx +156 -5
  125. package/corpus/templates/brain/changelog/2026-07-22-chat-runs-always-end-with-an-answer.md +6 -0
  126. package/corpus/templates/brain/changelog/2026-07-22-clear-background-chat-progress.md +6 -0
  127. package/corpus/templates/brain/changelog/2026-07-22-compact-sidebar-footer.md +6 -0
  128. package/corpus/templates/brain/changelog/2026-07-22-full-page-chat-keeps-the-active-conversation-when-moving-to-.md +6 -0
  129. package/corpus/templates/brain/changelog/2026-07-22-long-chat-titles-stay-inside-the-sidebar.md +6 -0
  130. package/corpus/templates/brain/changelog/2026-07-22-new-clips-and-webhook-sources-now-show-their-secure-connecti.md +6 -0
  131. package/corpus/templates/brain/jobs/emit-netlify-brain-queue-cron.ts +125 -0
  132. package/corpus/templates/brain/netlify.toml +5 -1
  133. package/corpus/templates/brain/scripts/configure-prod-clips-source.ts +50 -0
  134. package/corpus/templates/brain/server/lib/capture-sanitization.ts +64 -6
  135. package/corpus/templates/brain/server/plugins/agent-chat.ts +4 -0
  136. package/corpus/templates/brain/server/plugins/brain-jobs.ts +1 -0
  137. package/corpus/templates/brain/server/routes/api/brain/queue/run.post.ts +55 -0
  138. package/corpus/templates/calendar/.agents/skills/agent-native-toolkit/SKILL.md +4 -1
  139. package/corpus/templates/calendar/.agents/skills/real-time-sync/SKILL.md +1 -1
  140. package/corpus/templates/chat/.agents/skills/agent-native-toolkit/SKILL.md +4 -1
  141. package/corpus/templates/chat/.agents/skills/real-time-sync/SKILL.md +1 -1
  142. package/corpus/templates/chat/app/components/layout/Layout.tsx +1 -0
  143. package/corpus/templates/chat/app/components/layout/Sidebar.tsx +43 -67
  144. package/corpus/templates/chat/app/hooks/use-navigation-state.ts +2 -7
  145. package/corpus/templates/chat/app/routes/_index.tsx +8 -5
  146. package/corpus/templates/chat/changelog/2026-07-22-compact-sidebar-footer.md +6 -0
  147. package/corpus/templates/chat/changelog/2026-07-22-full-page-chat-keeps-the-active-conversation-when-moving-to-.md +6 -0
  148. package/corpus/templates/chat/changelog/2026-07-22-recent-chats-are-easier-to-scan-and-expand.md +6 -0
  149. package/corpus/templates/clips/.agents/skills/agent-native-toolkit/SKILL.md +4 -1
  150. package/corpus/templates/clips/AGENTS.md +9 -1
  151. package/corpus/templates/clips/actions/export-to-brain.ts +82 -6
  152. package/corpus/templates/clips/actions/request-transcript.ts +25 -14
  153. package/corpus/templates/clips/app/components/sharing/share-ui.tsx +12 -0
  154. package/corpus/templates/clips/app/i18n/en-US.ts +1 -0
  155. package/corpus/templates/clips/changelog/2026-07-22-clips-transcripts-now-retry-brain-export.md +5 -0
  156. package/corpus/templates/clips/changelog/2026-07-22-german-umlauts-and-other-non-ascii-characters-now-paste-corr.md +6 -0
  157. package/corpus/templates/clips/desktop/src-tauri/src/clips/mod.rs +26 -3
  158. package/corpus/templates/clips/jobs/emit-netlify-brain-export-cron.ts +118 -0
  159. package/corpus/templates/clips/netlify.toml +4 -1
  160. package/corpus/templates/clips/scripts/configure-prod-brain-export.ts +92 -0
  161. package/corpus/templates/clips/server/jobs/brain-export.ts +182 -0
  162. package/corpus/templates/clips/server/lib/brain-export-state.ts +42 -0
  163. package/corpus/templates/clips/server/lib/post-finalize-dispatch.ts +5 -1
  164. package/corpus/templates/clips/server/plugins/calendar-jobs.ts +2 -0
  165. package/corpus/templates/clips/server/register-secrets.ts +42 -0
  166. package/corpus/templates/clips/server/routes/api/_agent-native-background/post-finalize-worker.post.ts +10 -1
  167. package/corpus/templates/clips/server/routes/api/clips/brain-export/run.post.ts +45 -0
  168. package/corpus/templates/content/.agents/skills/agent-native-toolkit/SKILL.md +4 -1
  169. package/corpus/templates/content/.agents/skills/real-time-sync/SKILL.md +1 -1
  170. package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +566 -299
  171. package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +14 -0
  172. package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +1 -1
  173. package/corpus/templates/content/app/i18n-data.ts +90 -0
  174. package/corpus/templates/content/server/plugins/collab.ts +1 -1
  175. package/corpus/templates/crm/.agents/skills/agent-native-toolkit/SKILL.md +4 -1
  176. package/corpus/templates/crm/actions/_crm-data-program-actions.ts +15 -0
  177. package/corpus/templates/crm/actions/get-crm-dashboard-panel.ts +2 -0
  178. package/corpus/templates/crm/actions/run.ts +2 -6
  179. package/corpus/templates/crm/actions/update-crm-record.ts +4 -2
  180. package/corpus/templates/crm/app/components/layout/CrmLayout.tsx +6 -0
  181. package/corpus/templates/crm/changelog/2026-07-22-crm-field-validation-now-explains-which-fields-cannot-be-edi.md +6 -0
  182. package/corpus/templates/crm/changelog/2026-07-22-fixed-hubspot-contact-syncs-missing-records-updated-after-th.md +6 -0
  183. package/corpus/templates/crm/changelog/2026-07-22-fixed-pipeline-dashboard-panels-so-their-opportunity-data-lo.md +6 -0
  184. package/corpus/templates/crm/changelog/2026-07-22-full-page-chat-keeps-the-active-conversation-when-moving-to-.md +6 -0
  185. package/corpus/templates/crm/server/crm/hubspot-adapter.ts +4 -1
  186. package/corpus/templates/design/.agents/skills/agent-native-toolkit/SKILL.md +4 -1
  187. package/corpus/templates/design/app/components/layout/Sidebar.tsx +1 -1
  188. package/corpus/templates/design/server/plugins/collab.ts +11 -8
  189. package/corpus/templates/dispatch/.agents/skills/agent-native-toolkit/SKILL.md +4 -1
  190. package/corpus/templates/forms/.agents/skills/agent-native-toolkit/SKILL.md +4 -1
  191. package/corpus/templates/forms/.agents/skills/real-time-sync/SKILL.md +1 -1
  192. package/corpus/templates/forms/app/components/layout/Layout.tsx +1 -0
  193. package/corpus/templates/forms/app/components/layout/Sidebar.tsx +1 -1
  194. package/corpus/templates/forms/app/hooks/use-navigation-state.ts +2 -9
  195. package/corpus/templates/forms/app/root.tsx +1 -6
  196. package/corpus/templates/forms/changelog/2026-07-22-full-page-chat-keeps-the-active-conversation-when-moving-to-.md +6 -0
  197. package/corpus/templates/macros/.agents/skills/agent-native-toolkit/SKILL.md +4 -1
  198. package/corpus/templates/mail/.agents/skills/agent-native-toolkit/SKILL.md +4 -1
  199. package/corpus/templates/mail/.agents/skills/real-time-sync/SKILL.md +1 -1
  200. package/corpus/templates/mail/app/components/layout/AppLayout.tsx +2 -2
  201. package/corpus/templates/plan/.agents/skills/agent-native-toolkit/SKILL.md +4 -1
  202. package/corpus/templates/plan/.agents/skills/real-time-sync/SKILL.md +1 -1
  203. package/corpus/templates/plan/app/components/layout/Layout.tsx +5 -1
  204. package/corpus/templates/plan/app/components/layout/Sidebar.tsx +57 -72
  205. package/corpus/templates/plan/app/hooks/use-navigation-state.ts +2 -9
  206. package/corpus/templates/plan/app/lib/create-plan-routing.ts +2 -2
  207. package/corpus/templates/plan/app/pages/PlanChatPage.tsx +0 -1
  208. package/corpus/templates/plan/changelog/2026-07-22-compact-sidebar-footer.md +6 -0
  209. package/corpus/templates/plan/changelog/2026-07-22-full-page-chat-keeps-the-active-conversation-when-moving-to-.md +6 -0
  210. package/corpus/templates/plan/changelog/2026-07-22-recent-chats-and-sidebar-controls-are-more-compact.md +6 -0
  211. package/corpus/templates/plan/server/plugins/collab.ts +5 -2
  212. package/corpus/templates/slides/.agents/skills/agent-native-toolkit/SKILL.md +4 -1
  213. package/corpus/templates/slides/app/components/layout/Sidebar.tsx +1 -1
  214. package/corpus/templates/slides/server/plugins/collab.ts +11 -8
  215. package/corpus/templates/tasks/.agents/skills/agent-native-toolkit/SKILL.md +4 -1
  216. package/corpus/templates/tasks/.agents/skills/real-time-sync/SKILL.md +1 -1
  217. package/corpus/templates/tasks/app/components/layout/Sidebar.tsx +5 -22
  218. package/corpus/templates/tasks/changelog/2026-07-22-compact-sidebar-footer.md +6 -0
  219. package/corpus/toolkit/CHANGELOG.md +7 -0
  220. package/corpus/toolkit/README.md +12 -3
  221. package/corpus/toolkit/package.json +1 -1
  222. package/corpus/toolkit/src/chat-history/ChatHistoryRail.tsx +89 -0
  223. package/corpus/toolkit/src/chat-history/index.ts +5 -0
  224. package/corpus/toolkit/src/chat-history.css +59 -0
  225. package/corpus/toolkit/src/composer/RealtimeVoiceMode.tsx +106 -36
  226. package/corpus/toolkit/src/composer/VoiceButton.tsx +49 -0
  227. package/corpus/toolkit/src/composer/realtime-voice-audio-level.ts +2 -1
  228. package/corpus/toolkit/src/composer/useRealtimeVoiceMode.tsx +5 -3
  229. package/corpus/toolkit/src/styles.css +35 -0
  230. package/dist/agent/production-agent.d.ts.map +1 -1
  231. package/dist/agent/production-agent.js +13 -3
  232. package/dist/agent/production-agent.js.map +1 -1
  233. package/dist/cli/doctor.d.ts +1 -1
  234. package/dist/cli/doctor.d.ts.map +1 -1
  235. package/dist/cli/doctor.js +5 -2
  236. package/dist/cli/doctor.js.map +1 -1
  237. package/dist/client/AgentPanel.d.ts +1 -0
  238. package/dist/client/AgentPanel.d.ts.map +1 -1
  239. package/dist/client/AgentPanel.js +38 -18
  240. package/dist/client/AgentPanel.js.map +1 -1
  241. package/dist/client/AssistantChat.d.ts.map +1 -1
  242. package/dist/client/AssistantChat.js +55 -20
  243. package/dist/client/AssistantChat.js.map +1 -1
  244. package/dist/client/FeedbackButton.d.ts +4 -2
  245. package/dist/client/FeedbackButton.d.ts.map +1 -1
  246. package/dist/client/FeedbackButton.js +18 -2
  247. package/dist/client/FeedbackButton.js.map +1 -1
  248. package/dist/client/MultiTabAssistantChat.d.ts +1 -1
  249. package/dist/client/MultiTabAssistantChat.d.ts.map +1 -1
  250. package/dist/client/MultiTabAssistantChat.js +2 -3
  251. package/dist/client/MultiTabAssistantChat.js.map +1 -1
  252. package/dist/client/agent-chat-adapter.d.ts.map +1 -1
  253. package/dist/client/agent-chat-adapter.js +11 -3
  254. package/dist/client/agent-chat-adapter.js.map +1 -1
  255. package/dist/client/agent-page/AgentTabFrame.d.ts +3 -1
  256. package/dist/client/agent-page/AgentTabFrame.d.ts.map +1 -1
  257. package/dist/client/agent-page/AgentTabFrame.js +3 -2
  258. package/dist/client/agent-page/AgentTabFrame.js.map +1 -1
  259. package/dist/client/agent-page/AgentTabsPage.d.ts +2 -0
  260. package/dist/client/agent-page/AgentTabsPage.d.ts.map +1 -1
  261. package/dist/client/agent-page/AgentTabsPage.js +26 -15
  262. package/dist/client/agent-page/AgentTabsPage.js.map +1 -1
  263. package/dist/client/analytics.d.ts +10 -0
  264. package/dist/client/analytics.d.ts.map +1 -1
  265. package/dist/client/analytics.js.map +1 -1
  266. package/dist/client/chat/message-components.d.ts +6 -1
  267. package/dist/client/chat/message-components.d.ts.map +1 -1
  268. package/dist/client/chat/message-components.js +17 -11
  269. package/dist/client/chat/message-components.js.map +1 -1
  270. package/dist/client/chat/tool-call-display.d.ts +11 -1
  271. package/dist/client/chat/tool-call-display.d.ts.map +1 -1
  272. package/dist/client/chat/tool-call-display.js +16 -17
  273. package/dist/client/chat/tool-call-display.js.map +1 -1
  274. package/dist/client/chat/tool-render-registry.d.ts +2 -0
  275. package/dist/client/chat/tool-render-registry.d.ts.map +1 -1
  276. package/dist/client/chat/tool-render-registry.js.map +1 -1
  277. package/dist/client/resources/McpConnectionSuggestion.d.ts.map +1 -1
  278. package/dist/client/resources/McpConnectionSuggestion.js +15 -1
  279. package/dist/client/resources/McpConnectionSuggestion.js.map +1 -1
  280. package/dist/client/resources/ResourceTree.d.ts.map +1 -1
  281. package/dist/client/resources/ResourceTree.js +3 -3
  282. package/dist/client/resources/ResourceTree.js.map +1 -1
  283. package/dist/client/resources/ResourcesPanel.d.ts +1 -0
  284. package/dist/client/resources/ResourcesPanel.d.ts.map +1 -1
  285. package/dist/client/resources/ResourcesPanel.js +34 -20
  286. package/dist/client/resources/ResourcesPanel.js.map +1 -1
  287. package/dist/client/resources/mcp-connection-resume.d.ts +11 -0
  288. package/dist/client/resources/mcp-connection-resume.d.ts.map +1 -0
  289. package/dist/client/resources/mcp-connection-resume.js +95 -0
  290. package/dist/client/resources/mcp-connection-resume.js.map +1 -0
  291. package/dist/client/resources/mcp-integration-catalog.d.ts +1 -0
  292. package/dist/client/resources/mcp-integration-catalog.d.ts.map +1 -1
  293. package/dist/client/resources/mcp-integration-catalog.js +63 -8
  294. package/dist/client/resources/mcp-integration-catalog.js.map +1 -1
  295. package/dist/client/resources/mcp-integration-logos.d.ts.map +1 -1
  296. package/dist/client/resources/mcp-integration-logos.js +4 -0
  297. package/dist/client/resources/mcp-integration-logos.js.map +1 -1
  298. package/dist/client/sse-event-processor.d.ts +5 -0
  299. package/dist/client/sse-event-processor.d.ts.map +1 -1
  300. package/dist/client/sse-event-processor.js +39 -13
  301. package/dist/client/sse-event-processor.js.map +1 -1
  302. package/dist/client/use-db-sync.d.ts +4 -3
  303. package/dist/client/use-db-sync.d.ts.map +1 -1
  304. package/dist/client/use-db-sync.js +332 -21
  305. package/dist/client/use-db-sync.js.map +1 -1
  306. package/dist/collab/client.d.ts.map +1 -1
  307. package/dist/collab/client.js +26 -2
  308. package/dist/collab/client.js.map +1 -1
  309. package/dist/collab/routes.d.ts +1 -1
  310. package/dist/connections/catalog.d.ts +1 -1
  311. package/dist/connections/catalog.d.ts.map +1 -1
  312. package/dist/connections/catalog.js +7 -1
  313. package/dist/connections/catalog.js.map +1 -1
  314. package/dist/db/ddl-guard.d.ts +7 -2
  315. package/dist/db/ddl-guard.d.ts.map +1 -1
  316. package/dist/db/ddl-guard.js +21 -9
  317. package/dist/db/ddl-guard.js.map +1 -1
  318. package/dist/deploy/build.d.ts.map +1 -1
  319. package/dist/deploy/build.js +25 -2
  320. package/dist/deploy/build.js.map +1 -1
  321. package/dist/eject/remote-mcp-presets.d.ts.map +1 -1
  322. package/dist/eject/remote-mcp-presets.js +33 -3
  323. package/dist/eject/remote-mcp-presets.js.map +1 -1
  324. package/dist/guards/explicit-collab-access.d.ts +8 -0
  325. package/dist/guards/explicit-collab-access.d.ts.map +1 -0
  326. package/dist/guards/explicit-collab-access.js +214 -0
  327. package/dist/guards/explicit-collab-access.js.map +1 -0
  328. package/dist/guards/index.d.ts +1 -0
  329. package/dist/guards/index.d.ts.map +1 -1
  330. package/dist/guards/index.js +1 -0
  331. package/dist/guards/index.js.map +1 -1
  332. package/dist/localization/default-messages.d.ts +43 -0
  333. package/dist/localization/default-messages.d.ts.map +1 -1
  334. package/dist/localization/default-messages.js +48 -5
  335. package/dist/localization/default-messages.js.map +1 -1
  336. package/dist/mcp/connect-route.d.ts.map +1 -1
  337. package/dist/mcp/connect-route.js +34 -48
  338. package/dist/mcp/connect-route.js.map +1 -1
  339. package/dist/observability/routes.d.ts +5 -5
  340. package/dist/provider-api/actions/custom-provider-registration.d.ts +7 -7
  341. package/dist/provider-api/actions/provider-api.d.ts +7 -7
  342. package/dist/provider-api/corpus-jobs.d.ts +2 -2
  343. package/dist/provider-api/index.js +1 -1
  344. package/dist/provider-api/index.js.map +1 -1
  345. package/dist/realtime-protocol.d.ts +40 -0
  346. package/dist/realtime-protocol.d.ts.map +1 -0
  347. package/dist/realtime-protocol.js +69 -0
  348. package/dist/realtime-protocol.js.map +1 -0
  349. package/dist/resources/handlers.d.ts +1 -1
  350. package/dist/secrets/routes.d.ts +3 -3
  351. package/dist/server/collab-plugin.d.ts +34 -3
  352. package/dist/server/collab-plugin.d.ts.map +1 -1
  353. package/dist/server/collab-plugin.js +65 -12
  354. package/dist/server/collab-plugin.js.map +1 -1
  355. package/dist/server/core-routes-plugin.d.ts.map +1 -1
  356. package/dist/server/core-routes-plugin.js +3 -0
  357. package/dist/server/core-routes-plugin.js.map +1 -1
  358. package/dist/server/index.d.ts +1 -1
  359. package/dist/server/index.d.ts.map +1 -1
  360. package/dist/server/index.js.map +1 -1
  361. package/dist/server/poll-events.d.ts +7 -1
  362. package/dist/server/poll-events.d.ts.map +1 -1
  363. package/dist/server/poll-events.js +24 -7
  364. package/dist/server/poll-events.js.map +1 -1
  365. package/dist/server/poll.d.ts +223 -42
  366. package/dist/server/poll.d.ts.map +1 -1
  367. package/dist/server/poll.js +849 -737
  368. package/dist/server/poll.js.map +1 -1
  369. package/dist/server/realtime-token.d.ts +34 -0
  370. package/dist/server/realtime-token.d.ts.map +1 -0
  371. package/dist/server/realtime-token.js +85 -0
  372. package/dist/server/realtime-token.js.map +1 -0
  373. package/dist/server/sentry-config.d.ts +12 -0
  374. package/dist/server/sentry-config.d.ts.map +1 -1
  375. package/dist/server/sentry-config.js +34 -0
  376. package/dist/server/sentry-config.js.map +1 -1
  377. package/dist/server/short-lived-token.d.ts +46 -0
  378. package/dist/server/short-lived-token.d.ts.map +1 -1
  379. package/dist/server/short-lived-token.js +104 -0
  380. package/dist/server/short-lived-token.js.map +1 -1
  381. package/dist/server/sse.d.ts +1 -1
  382. package/dist/server/sse.d.ts.map +1 -1
  383. package/dist/server/sse.js +4 -0
  384. package/dist/server/sse.js.map +1 -1
  385. package/dist/server/ssr-handler.d.ts.map +1 -1
  386. package/dist/server/ssr-handler.js +5 -3
  387. package/dist/server/ssr-handler.js.map +1 -1
  388. package/dist/server/transcribe-voice.d.ts +1 -1
  389. package/dist/styles/agent-native.css +40 -0
  390. package/dist/templates/chat/.agents/skills/agent-native-toolkit/SKILL.md +4 -1
  391. package/dist/templates/chat/.agents/skills/real-time-sync/SKILL.md +1 -1
  392. package/dist/templates/chat/app/components/layout/Layout.tsx +1 -0
  393. package/dist/templates/chat/app/components/layout/Sidebar.tsx +43 -67
  394. package/dist/templates/chat/app/hooks/use-navigation-state.ts +2 -7
  395. package/dist/templates/chat/app/routes/_index.tsx +8 -5
  396. package/dist/templates/chat/changelog/2026-07-22-compact-sidebar-footer.md +6 -0
  397. package/dist/templates/chat/changelog/2026-07-22-full-page-chat-keeps-the-active-conversation-when-moving-to-.md +6 -0
  398. package/dist/templates/chat/changelog/2026-07-22-recent-chats-are-easier-to-scan-and-expand.md +6 -0
  399. package/dist/templates/default/.agents/skills/agent-native-toolkit/SKILL.md +4 -1
  400. package/dist/templates/default/.agents/skills/real-time-collab/SKILL.md +35 -13
  401. package/dist/templates/default/.agents/skills/real-time-sync/SKILL.md +1 -1
  402. package/dist/templates/headless/.agents/skills/agent-native-toolkit/SKILL.md +4 -1
  403. package/dist/templates/workspace-core/.agents/skills/agent-native-toolkit/SKILL.md +4 -1
  404. package/dist/templates/workspace-core/.agents/skills/real-time-collab/SKILL.md +35 -13
  405. package/dist/templates/workspace-core/.agents/skills/real-time-sync/SKILL.md +1 -1
  406. package/dist/templates/workspace-core/.agents/skills/voice-transcription/SKILL.md +12 -8
  407. package/docs/AGENTS.md +1 -1
  408. package/docs/content/agent-mentions.mdx +2 -2
  409. package/docs/content/{workspace.mdx → agent-resources.mdx} +1 -7
  410. package/docs/content/agent-teams.mdx +4 -4
  411. package/docs/content/dispatch.mdx +10 -10
  412. package/docs/content/drop-in-agent.mdx +4 -4
  413. package/docs/content/external-agents.mdx +1 -1
  414. package/docs/content/frames.mdx +1 -1
  415. package/docs/content/integrations.mdx +13 -2
  416. package/docs/content/key-concepts.mdx +2 -2
  417. package/docs/content/mcp-clients.mdx +7 -1
  418. package/docs/content/multi-app-workspace.mdx +3 -3
  419. package/docs/content/real-time-collaboration.mdx +33 -15
  420. package/docs/content/recurring-jobs.mdx +7 -7
  421. package/docs/content/skills-guide.mdx +4 -4
  422. package/docs/content/template-brain.mdx +1 -1
  423. package/docs/content/toolkit-collaboration.mdx +1 -1
  424. package/docs/content/using-your-agent.mdx +1 -1
  425. package/docs/content/what-is-agent-native.mdx +4 -4
  426. package/docs/content/workspace-management.mdx +4 -4
  427. package/package.json +6 -3
  428. package/src/agent/production-agent.ts +13 -3
  429. package/src/cli/doctor.ts +6 -1
  430. package/src/client/AgentPanel.tsx +79 -41
  431. package/src/client/AssistantChat.tsx +71 -25
  432. package/src/client/FeedbackButton.tsx +33 -7
  433. package/src/client/MultiTabAssistantChat.tsx +6 -8
  434. package/src/client/agent-chat-adapter.ts +11 -2
  435. package/src/client/agent-page/AgentTabFrame.tsx +22 -3
  436. package/src/client/agent-page/AgentTabsPage.tsx +34 -19
  437. package/src/client/analytics.ts +7 -0
  438. package/src/client/chat/message-components.tsx +43 -16
  439. package/src/client/chat/tool-call-display.tsx +30 -18
  440. package/src/client/chat/tool-render-registry.tsx +2 -0
  441. package/src/client/resources/McpConnectionSuggestion.tsx +19 -2
  442. package/src/client/resources/ResourceTree.tsx +7 -10
  443. package/src/client/resources/ResourcesPanel.tsx +33 -70
  444. package/src/client/resources/mcp-connection-resume.ts +107 -0
  445. package/src/client/resources/mcp-integration-catalog.ts +65 -12
  446. package/src/client/resources/mcp-integration-logos.ts +4 -0
  447. package/src/client/sse-event-processor.ts +50 -14
  448. package/src/client/use-db-sync.ts +370 -21
  449. package/src/collab/client.ts +26 -2
  450. package/src/connections/catalog.ts +7 -1
  451. package/src/db/ddl-guard.ts +49 -10
  452. package/src/deploy/build.ts +25 -2
  453. package/src/eject/remote-mcp-presets.ts +37 -5
  454. package/src/guards/explicit-collab-access.ts +229 -0
  455. package/src/guards/index.ts +1 -0
  456. package/src/localization/default-messages.ts +54 -5
  457. package/src/mcp/connect-route.ts +34 -51
  458. package/src/provider-api/index.ts +1 -1
  459. package/src/realtime-protocol.ts +92 -0
  460. package/src/server/collab-plugin.ts +137 -18
  461. package/src/server/core-routes-plugin.ts +7 -0
  462. package/src/server/index.ts +2 -0
  463. package/src/server/poll-events.ts +29 -8
  464. package/src/server/poll.ts +1123 -865
  465. package/src/server/realtime-token.ts +106 -0
  466. package/src/server/sentry-config.ts +40 -0
  467. package/src/server/short-lived-token.ts +156 -0
  468. package/src/server/sse.ts +15 -0
  469. package/src/server/ssr-handler.ts +9 -3
  470. package/src/styles/agent-native.css +40 -0
  471. package/src/templates/chat/.agents/skills/agent-native-toolkit/SKILL.md +4 -1
  472. package/src/templates/chat/.agents/skills/real-time-sync/SKILL.md +1 -1
  473. package/src/templates/chat/app/components/layout/Layout.tsx +1 -0
  474. package/src/templates/chat/app/components/layout/Sidebar.tsx +43 -67
  475. package/src/templates/chat/app/hooks/use-navigation-state.ts +2 -7
  476. package/src/templates/chat/app/routes/_index.tsx +8 -5
  477. package/src/templates/chat/changelog/2026-07-22-compact-sidebar-footer.md +6 -0
  478. package/src/templates/chat/changelog/2026-07-22-full-page-chat-keeps-the-active-conversation-when-moving-to-.md +6 -0
  479. package/src/templates/chat/changelog/2026-07-22-recent-chats-are-easier-to-scan-and-expand.md +6 -0
  480. package/src/templates/default/.agents/skills/agent-native-toolkit/SKILL.md +4 -1
  481. package/src/templates/default/.agents/skills/real-time-collab/SKILL.md +35 -13
  482. package/src/templates/default/.agents/skills/real-time-sync/SKILL.md +1 -1
  483. package/src/templates/headless/.agents/skills/agent-native-toolkit/SKILL.md +4 -1
  484. package/src/templates/workspace-core/.agents/skills/agent-native-toolkit/SKILL.md +4 -1
  485. package/src/templates/workspace-core/.agents/skills/real-time-collab/SKILL.md +35 -13
  486. package/src/templates/workspace-core/.agents/skills/real-time-sync/SKILL.md +1 -1
  487. 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
- * Tracks the latest updated_at seen on the `__screen_refresh__` key in
105
- * application_state. Bumped when the agent calls the `refresh-screen` tool,
106
- * and surfaced as a distinct `screen-refresh` event so clients can remount
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
- let _lastScreenRefreshTs = 0;
114
- let _screenRefreshInitialized = false;
115
- // Per-session high-water marks for `__screen_refresh__`. Each user's row is
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
- return `${userEmail}|${resourceType}|${resourceId}`;
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
- /** Get all changes after a given version. */
682
- export function getChangesSince(since: number): {
285
+ type ChangeVisibility = "visible" | "hidden" | "pending";
286
+
287
+ export type ChangeReadResult = {
683
288
  version: number;
684
289
  events: ChangeEvent[];
685
- } {
686
- if (since >= _version) {
687
- return { version: _version, events: [] };
688
- }
689
- const events = _buffer.filter((e) => e.version > since);
690
- return { version: _version, events };
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
- * Get changes after a given version, filtered to events the caller is
695
- * allowed to see.
696
- *
697
- * Filtering rules:
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 function getChangesSinceForUser(
708
- since: number,
709
- userEmail: string,
710
- orgId: string | undefined,
711
- ): ChangeReadResult {
712
- if (since >= _version) {
713
- return { version: _version, events: [] };
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
- const events: ChangeEvent[] = [];
716
- let version = _version;
717
- for (const event of _buffer) {
718
- if (event.version <= since) continue;
719
- const visibility = getChangeVisibilityForUser(event, userEmail, orgId);
720
- if (visibility === "visible") {
721
- events.push(event);
722
- continue;
723
- }
724
- if (visibility === "pending") {
725
- version = Math.max(since, event.version - 1);
726
- return { version, events, cursorLimited: true };
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
- async function getDurableChangesSinceForUser(
733
- since: number,
734
- userEmail: string,
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
- try {
742
- // Scope the fetch to rows that could ever be visible to this caller
743
- // before paying to JSON.parse and visibility-check every deployment-wide
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
- for (const row of rows) {
777
- const rawVersion = timestampValue(row.version);
778
- if (rawVersion > lastDurableVersion) lastDurableVersion = rawVersion;
779
- if (rawVersion > version) version = rawVersion;
780
- let event: ChangeEvent | null = null;
781
- try {
782
- const parsed = JSON.parse(String(row.event_json));
783
- if (
784
- parsed &&
785
- typeof parsed === "object" &&
786
- typeof parsed.source === "string" &&
787
- typeof parsed.type === "string"
788
- ) {
789
- event = {
790
- ...(parsed as ChangeEvent),
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
- if (!event) continue;
471
+ this.recordChange(event);
472
+ });
473
+ getSettingsEmitter().on("settings", (event) => {
474
+ this.recordChange(event);
475
+ });
476
+ }
798
477
 
799
- const visibility = getChangeVisibilityForUser(event, userEmail, orgId);
800
- if (visibility === "visible") {
801
- events.push(event);
802
- continue;
803
- }
804
- if (visibility === "pending") {
805
- return {
806
- version: Math.max(since, event.version - 1),
807
- events,
808
- cursorLimited: true,
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
- if (rows.length >= DURABLE_READ_LIMIT) {
814
- if (overflowVersion === lastDurableVersion) {
815
- const boundaryVersion = lastDurableVersion;
816
- return {
817
- version: Math.max(since, boundaryVersion - 1),
818
- events: events.filter((event) => event.version < boundaryVersion),
819
- cursorLimited: true,
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
- return { version, events };
830
- } catch {
831
- return { version: _version, events: [] };
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
- async function getCombinedChangesSinceForUser(
836
- since: number,
837
- userEmail: string,
838
- orgId: string | undefined,
839
- useDurableEvents: boolean,
840
- ): Promise<{ version: number; events: ChangeEvent[] }> {
841
- const memory = getChangesSinceForUser(since, userEmail, orgId);
842
- if (!useDurableEvents) return memory;
843
-
844
- const durable = await getDurableChangesSinceForUser(since, userEmail, orgId);
845
- const byIdentity = new Map<string, ChangeEvent>();
846
- for (const event of [...durable.events, ...memory.events]) {
847
- byIdentity.set(
848
- JSON.stringify([
849
- event.version,
850
- event.source,
851
- event.type,
852
- event.key,
853
- event.owner,
854
- event.orgId,
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
- event,
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
- * Seed _version from DB timestamps on the first call so serverless
883
- * cold starts don't return version 0 and confuse polling clients.
884
- */
885
- async function seedVersionFromDb(): Promise<void> {
886
- if (_versionSeeded) return;
887
- _versionSeeded = true;
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
- try {
890
- const db = getDbExec();
891
-
892
- const [
893
- syncEventsTs,
894
- appTs,
895
- settingsTs,
896
- extensionsMaxUpdatedAt,
897
- extensionMarkerTs,
898
- actionMarkerTs,
899
- refreshResult,
900
- ] = await Promise.all([
901
- readMaxSyncEventVersion(),
902
- readMaxUpdatedAt(db, "application_state"),
903
- readMaxUpdatedAt(db, "settings"),
904
- readMaxUpdatedAtRaw(db, "tools"),
905
- readExtensionMarkerMaxUpdatedAt(db),
906
- readActionMarkerMaxUpdatedAt(db),
907
- db
908
- .execute({
909
- sql: "SELECT session_id, updated_at FROM application_state WHERE key = ?",
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
- // Seed version — never decrease an already-set value
922
- _version = Math.max(
923
- _version,
924
- syncEventsTs,
925
- appTs,
926
- settingsTs,
927
- extensionsTs,
928
- extensionMarkerTs,
929
- actionMarkerTs,
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
- // Set baselines so checkExternalDbChanges detects future writes
933
- _lastAppStateTs = appTs;
934
- _lastSettingsTs = settingsTs;
935
- _lastExtensionsTs = extensionsTs;
936
- _lastExtensionsUpdatedAt = sqlWatermarkValue(extensionsMaxUpdatedAt);
937
- _lastExtensionMarkerTs = extensionMarkerTs;
938
- // Action markers are durable specifically so a web server can observe work
939
- // performed by a separate action process. Do not baseline past an existing
940
- // marker on cold start, or the first poll after the action will miss it.
941
- _lastActionMarkerTs = 0;
942
- _lastScreenRefreshTs = refreshTs;
943
- _lastScreenRefreshTsBySession.clear();
944
- for (const row of refreshResult.rows) {
945
- if (typeof row.session_id === "string") {
946
- _lastScreenRefreshTsBySession.set(
947
- row.session_id,
948
- timestampValue(row.updated_at),
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
- _screenRefreshInitialized = true;
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
- * Check for cross-process DB writes by comparing updated_at timestamps.
963
- * Runs at most once per second to avoid excessive queries.
964
- */
965
- async function checkExternalDbChanges(options: {
966
- durableEvents: boolean;
967
- }): Promise<void> {
968
- const now = Date.now();
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
- async function doCheckExternalDbChanges(): Promise<void> {
985
- try {
986
- const db = getDbExec();
987
-
988
- // These reads are independent each compares the DB against module-level
989
- // high-water marks (`_lastAppStateTs`, etc.) rather than another query's
990
- // result, and none of them mutate state before processing below. On a
991
- // serverless SQL backend every `await` is a network round-trip, so running
992
- // them concurrently shaves stacked latency off every poll cycle. Results
993
- // are still processed in the original sequential order, and conditional
994
- // follow-up queries (action/extension marker detail rows, tool-shares) stay
995
- // sequential within their branch where they depend on these results.
996
- const [
997
- appResult,
998
- actionMarkerTs,
999
- refreshResult,
1000
- extensionMarkerTs,
1001
- settingsTs,
1002
- extensionsMaxUpdatedAt,
1003
- ] = await Promise.all([
1004
- db.execute({
1005
- sql: "SELECT session_id, key, updated_at FROM application_state WHERE updated_at > ? ORDER BY updated_at ASC",
1006
- args: [_lastAppStateTs],
1007
- }),
1008
- readActionMarkerMaxUpdatedAt(db),
1009
- db.execute({
1010
- sql: "SELECT session_id, updated_at, value FROM application_state WHERE key = ?",
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
- if (_lastAppStateTs > 0) {
1028
- for (const row of appResult.rows) {
1029
- const key = typeof row.key === "string" ? row.key : "*";
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
- key === EXTENSION_CHANGE_MARKER_KEY ||
1032
- key === ACTION_CHANGE_MARKER_KEY
937
+ parsed &&
938
+ typeof parsed === "object" &&
939
+ typeof parsed.source === "string" &&
940
+ typeof parsed.type === "string"
1033
941
  ) {
1034
- continue;
942
+ event = {
943
+ ...(parsed as ChangeEvent),
944
+ version: rawVersion || (parsed as ChangeEvent).version,
945
+ };
1035
946
  }
1036
- const owner =
1037
- typeof row.session_id === "string" ? row.session_id : undefined;
1038
- recordChange({
1039
- source: "app-state",
1040
- type: "change",
1041
- key,
1042
- ...(owner ? { owner } : {}),
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
- _lastAppStateTs = appTs;
985
+
986
+ return { version, events };
987
+ } catch {
988
+ return { version: this.version, events: [] };
1047
989
  }
990
+ }
1048
991
 
1049
- // Mutating actions write a durable marker in addition to the in-process
1050
- // event. This lets dev-mode `pnpm action ...` child processes and
1051
- // serverless action invocations wake the web server's SSE/poll loop as a
1052
- // first-class source:"action" event rather than a generic app-state bump.
1053
- // `actionMarkerTs` was read above; the detail-row query below is conditional
1054
- // on it and depends on its result, so it stays sequential.
1055
- if (actionMarkerTs > _lastActionMarkerTs) {
1056
- const actionMarkerResult = await db.execute({
1057
- sql: "SELECT session_id, value, updated_at FROM application_state WHERE key = ? ORDER BY updated_at ASC",
1058
- args: [ACTION_CHANGE_MARKER_KEY],
1059
- });
1060
- const changedActionMarkers = actionMarkerResult.rows.filter(
1061
- (row) => timestampValue(row.updated_at) > _lastActionMarkerTs,
1062
- );
1063
- recordActionChanges(
1064
- changedActionMarkers
1065
- .map((row) => parseActionChangeMarker(row.session_id, row.value))
1066
- .filter((target): target is ActionChangeTarget => !!target),
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
- // Check for screen-refresh requests from the agent. The `refresh-screen`
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
- if (!_screenRefreshInitialized) {
1081
- _lastScreenRefreshTs = refreshTs;
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
- _lastScreenRefreshTsBySession.set(
1107
+ this.lastScreenRefreshTsBySession.set(
1085
1108
  row.session_id,
1086
1109
  timestampValue(row.updated_at),
1087
1110
  );
1088
1111
  }
1089
1112
  }
1090
- _screenRefreshInitialized = true;
1091
- } else if (refreshTs > _lastScreenRefreshTs) {
1092
- // Emit a per-user event only for the session(s) whose row actually
1093
- // advanced, scoped with `owner` so canSeeChangeForUser delivers it only
1094
- // to that user — not every authenticated poller.
1095
- for (const row of refreshResult.rows) {
1096
- const owner =
1097
- typeof row.session_id === "string" ? row.session_id : undefined;
1098
- if (!owner) continue;
1099
- const rowTs = timestampValue(row.updated_at);
1100
- if (rowTs <= (_lastScreenRefreshTsBySession.get(owner) ?? 0)) continue;
1101
- let scope: string | undefined;
1102
- try {
1103
- const raw = row.value;
1104
- if (typeof raw === "string") {
1105
- const parsed = JSON.parse(raw);
1106
- if (typeof parsed?.scope === "string") scope = parsed.scope;
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
- } catch {}
1109
- recordChange({
1110
- source: "screen-refresh",
1111
- type: "change",
1112
- key: SCREEN_REFRESH_KEY,
1113
- owner,
1114
- ...(scope ? { scope } : {}),
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
- _lastScreenRefreshTsBySession.set(owner, rowTs);
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
- // Extension mutations write a durable marker row so delete and hide/unhide
1122
- // operations are visible across serverless invocations. Translate those
1123
- // marker rows back into extension-source events for targeted client
1124
- // invalidation while preserving user/org scope. `extensionMarkerTs` was read
1125
- // above; the detail-row query below depends on it and stays sequential.
1126
- if (extensionMarkerTs > _lastExtensionMarkerTs) {
1127
- const extensionMarkerResult = await db.execute({
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 (_lastExtensionMarkerTs > 0) {
1135
- recordExtensionChanges(
1136
- changedExtensionMarkers
1137
- .map((row) => parseExtensionChangeMarker(row.session_id, row.value))
1138
- .filter((target): target is ExtensionChangeTarget => !!target),
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
- // Check settings for external writes. `settingsTs` was read above.
1145
- if (settingsTs > _lastSettingsTs) {
1146
- if (_lastSettingsTs > 0) {
1147
- recordChange({ source: "settings", type: "change", key: "*" });
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
- // Extension rows live in the legacy physical `tools` table. Keep this as a
1153
- // compatibility fallback for direct table writes, but scope events to the
1154
- // resource owner/share targets instead of broadcasting deployment-wide.
1155
- // `extensionsMaxUpdatedAt` was read above; the per-row query below is
1156
- // conditional on `extensionsTs` and stays sequential.
1157
- const extensionsTs = timestampValue(extensionsMaxUpdatedAt);
1158
- if (extensionsTs > _lastExtensionsTs) {
1159
- const since = _lastExtensionsUpdatedAt;
1160
- const extensionResult =
1161
- since === undefined
1162
- ? await db.execute({
1163
- sql: "SELECT id, owner_email, org_id, visibility, updated_at FROM tools ORDER BY updated_at ASC",
1164
- args: [],
1165
- })
1166
- : await db.execute({
1167
- sql: "SELECT id, owner_email, org_id, visibility, updated_at FROM tools WHERE updated_at > ? ORDER BY updated_at ASC",
1168
- args: [since],
1169
- });
1170
- const changedExtensionRows = extensionResult.rows.filter(
1171
- (row) => timestampValue(row.updated_at) > _lastExtensionsTs,
1172
- );
1173
- if (_lastExtensionsTs > 0) {
1174
- const targetsByRow = await readExtensionTargetsForRows(
1175
- db,
1176
- changedExtensionRows,
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
- _lastExtensionsTs = extensionsTs;
1181
- _lastExtensionsUpdatedAt = sqlWatermarkValue(extensionsMaxUpdatedAt);
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
- wireLocalEmitters();
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 _version from DB so we don't return version: 0
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,