@agent-native/core 0.92.7 → 0.92.8

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 (289) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +12 -0
  3. package/corpus/core/docs/content/a2a-protocol.mdx +12 -0
  4. package/corpus/core/docs/content/locales/ar-SA/a2a-protocol.mdx +12 -0
  5. package/corpus/core/docs/content/locales/ar-SA/pr-visual-recap.mdx +3 -3
  6. package/corpus/core/docs/content/locales/ar-SA/pure-agent-apps.mdx +4 -0
  7. package/corpus/core/docs/content/locales/de-DE/a2a-protocol.mdx +12 -0
  8. package/corpus/core/docs/content/locales/de-DE/pr-visual-recap.mdx +3 -3
  9. package/corpus/core/docs/content/locales/de-DE/pure-agent-apps.mdx +4 -0
  10. package/corpus/core/docs/content/locales/es-ES/a2a-protocol.mdx +12 -0
  11. package/corpus/core/docs/content/locales/es-ES/pr-visual-recap.mdx +3 -3
  12. package/corpus/core/docs/content/locales/es-ES/pure-agent-apps.mdx +4 -0
  13. package/corpus/core/docs/content/locales/fr-FR/a2a-protocol.mdx +12 -0
  14. package/corpus/core/docs/content/locales/fr-FR/pr-visual-recap.mdx +3 -3
  15. package/corpus/core/docs/content/locales/fr-FR/pure-agent-apps.mdx +4 -0
  16. package/corpus/core/docs/content/locales/hi-IN/a2a-protocol.mdx +12 -0
  17. package/corpus/core/docs/content/locales/hi-IN/pr-visual-recap.mdx +3 -3
  18. package/corpus/core/docs/content/locales/hi-IN/pure-agent-apps.mdx +4 -0
  19. package/corpus/core/docs/content/locales/ja-JP/a2a-protocol.mdx +12 -0
  20. package/corpus/core/docs/content/locales/ja-JP/pr-visual-recap.mdx +3 -3
  21. package/corpus/core/docs/content/locales/ja-JP/pure-agent-apps.mdx +4 -0
  22. package/corpus/core/docs/content/locales/ko-KR/a2a-protocol.mdx +12 -0
  23. package/corpus/core/docs/content/locales/ko-KR/pr-visual-recap.mdx +3 -3
  24. package/corpus/core/docs/content/locales/ko-KR/pure-agent-apps.mdx +4 -0
  25. package/corpus/core/docs/content/locales/pt-BR/a2a-protocol.mdx +12 -0
  26. package/corpus/core/docs/content/locales/pt-BR/pr-visual-recap.mdx +3 -3
  27. package/corpus/core/docs/content/locales/pt-BR/pure-agent-apps.mdx +4 -0
  28. package/corpus/core/docs/content/locales/zh-CN/a2a-protocol.mdx +12 -0
  29. package/corpus/core/docs/content/locales/zh-CN/pr-visual-recap.mdx +3 -3
  30. package/corpus/core/docs/content/locales/zh-CN/pure-agent-apps.mdx +4 -0
  31. package/corpus/core/docs/content/locales/zh-TW/a2a-protocol.mdx +12 -0
  32. package/corpus/core/docs/content/locales/zh-TW/pr-visual-recap.mdx +3 -3
  33. package/corpus/core/docs/content/locales/zh-TW/pure-agent-apps.mdx +4 -0
  34. package/corpus/core/docs/content/pr-visual-recap.mdx +3 -3
  35. package/corpus/core/docs/content/pure-agent-apps.mdx +22 -0
  36. package/corpus/core/package.json +1 -1
  37. package/corpus/core/src/agent/engine/builtin.ts +1 -1
  38. package/corpus/core/src/agent/model-config.ts +30 -30
  39. package/corpus/core/src/agent/production-agent.ts +129 -7
  40. package/corpus/core/src/cli/design-connect.ts +438 -83
  41. package/corpus/core/src/cli/recap.ts +2 -2
  42. package/corpus/core/src/client/AgentPanel.tsx +8 -1
  43. package/corpus/core/src/client/agent-chat-adapter.ts +12 -7
  44. package/corpus/core/src/client/composer/TiptapComposer.tsx +1 -1
  45. package/corpus/core/src/client/index.ts +4 -0
  46. package/corpus/core/src/client/org/TeamPage.tsx +41 -26
  47. package/corpus/core/src/client/use-action.ts +113 -1
  48. package/corpus/core/src/db/client.ts +12 -2
  49. package/corpus/core/src/db/create-get-db.ts +4 -1
  50. package/corpus/core/src/scripts/agent-engines/manage-agent-engine.ts +1 -1
  51. package/corpus/core/src/scripts/agent-engines/set-agent-engine.ts +1 -1
  52. package/corpus/core/src/server/transcribe-voice.ts +1 -1
  53. package/corpus/core/src/usage/store.ts +13 -4
  54. package/corpus/core/src/vite/client.ts +38 -1
  55. package/corpus/templates/analytics/AGENTS.md +3 -0
  56. package/corpus/templates/analytics/actions/get-data-program.ts +78 -0
  57. package/corpus/templates/analytics/actions/list-data-programs.ts +49 -0
  58. package/corpus/templates/analytics/app/global.css +11 -0
  59. package/corpus/templates/analytics/app/hooks/use-dashboard-chat-context.ts +163 -17
  60. package/corpus/templates/analytics/app/i18n/zh-TW.ts +2 -0
  61. package/corpus/templates/analytics/app/i18n-data.ts +20 -0
  62. package/corpus/templates/analytics/app/pages/adhoc/explorer-dashboard/ChartCard.tsx +96 -3
  63. package/corpus/templates/analytics/app/pages/adhoc/explorer-dashboard/index.tsx +3 -1
  64. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/SqlChartCard.tsx +61 -4
  65. package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/index.tsx +40 -2
  66. package/corpus/templates/analytics/changelog/2026-07-09-analytics-no-longer-forgets-successful-data-queries-when-a-l.md +6 -0
  67. package/corpus/templates/analytics/changelog/2026-07-09-dashboard-charts-keep-consistent-spacing-when-they-stack-ver.md +6 -0
  68. package/corpus/templates/analytics/changelog/2026-07-09-dashboard-charts-tables-and-extensions-can-be-selected-and-d.md +6 -0
  69. package/corpus/templates/analytics/changelog/2026-07-09-long-running-analytics-requests-now-recover-automatically-wh.md +6 -0
  70. package/corpus/templates/analytics/server/db/index.ts +6 -0
  71. package/corpus/templates/clips/desktop/src/hooks/useMeetingTranscription.ts +1 -7
  72. package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +102 -15
  73. package/corpus/templates/content/app/hooks/use-content-database.ts +52 -0
  74. package/corpus/templates/content/app/hooks/use-document-properties.ts +50 -6
  75. package/corpus/templates/content/app/i18n/zh-TW.ts +2 -0
  76. package/corpus/templates/content/app/i18n-data.ts +28 -0
  77. package/corpus/templates/content/changelog/2026-07-09-notion-conflict-warnings-no-longer-flash-during-normal-synce.md +6 -0
  78. package/corpus/templates/content/changelog/2026-07-09-property-menus-and-delete-confirmations-now-open-visibly-and.md +6 -0
  79. package/corpus/templates/content/server/lib/notion-sync.ts +130 -28
  80. package/corpus/templates/design/actions/add-breakpoint.ts +48 -68
  81. package/corpus/templates/design/actions/add-localhost-screens.ts +496 -61
  82. package/corpus/templates/design/actions/apply-component-prop-edit.ts +2 -18
  83. package/corpus/templates/design/actions/apply-design-token-edit.ts +51 -82
  84. package/corpus/templates/design/actions/apply-tweaks.ts +63 -41
  85. package/corpus/templates/design/actions/capture-design-state.ts +2 -2
  86. package/corpus/templates/design/actions/connect-localhost.ts +58 -12
  87. package/corpus/templates/design/actions/create-component.ts +2 -18
  88. package/corpus/templates/design/actions/create-design-branch.ts +38 -18
  89. package/corpus/templates/design/actions/create-fusion-app.ts +15 -11
  90. package/corpus/templates/design/actions/delete-file.ts +19 -34
  91. package/corpus/templates/design/actions/deploy-design-preview.ts +59 -27
  92. package/corpus/templates/design/actions/deploy-fusion-app.ts +22 -15
  93. package/corpus/templates/design/actions/export-pdf.ts +2 -1
  94. package/corpus/templates/design/actions/export-zip.ts +9 -3
  95. package/corpus/templates/design/actions/generate-design.ts +107 -70
  96. package/corpus/templates/design/actions/get-component-details.ts +2 -18
  97. package/corpus/templates/design/actions/get-design-branch-diff.ts +2 -3
  98. package/corpus/templates/design/actions/get-design-surface-index.ts +2 -18
  99. package/corpus/templates/design/actions/get-design.ts +2 -1
  100. package/corpus/templates/design/actions/grant-localhost-write-consent.ts +7 -1
  101. package/corpus/templates/design/actions/import-design-tokens.ts +114 -76
  102. package/corpus/templates/design/actions/index-components.ts +2 -18
  103. package/corpus/templates/design/actions/list-design-source-capabilities.ts +23 -28
  104. package/corpus/templates/design/actions/list-local-files.ts +9 -2
  105. package/corpus/templates/design/actions/migrate-board-objects-to-file.ts +82 -98
  106. package/corpus/templates/design/actions/open-component-source.ts +2 -18
  107. package/corpus/templates/design/actions/open-visual-edit.ts +19 -1
  108. package/corpus/templates/design/actions/present-design-variants.ts +70 -70
  109. package/corpus/templates/design/actions/preview-component-prop-edit.ts +4 -14
  110. package/corpus/templates/design/actions/read-local-file.ts +9 -2
  111. package/corpus/templates/design/actions/remove-breakpoint.ts +40 -61
  112. package/corpus/templates/design/actions/rename-screen.ts +425 -0
  113. package/corpus/templates/design/actions/request-localhost-write-consent.ts +12 -2
  114. package/corpus/templates/design/actions/revoke-localhost-write-consent.ts +9 -2
  115. package/corpus/templates/design/actions/sync-fusion-app.ts +56 -39
  116. package/corpus/templates/design/actions/update-design.ts +376 -61
  117. package/corpus/templates/design/actions/update-file.ts +455 -188
  118. package/corpus/templates/design/actions/write-local-file.ts +10 -2
  119. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +685 -608
  120. package/corpus/templates/design/app/components/design/LayersPanel.tsx +43 -15
  121. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +576 -2015
  122. package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +62 -14
  123. package/corpus/templates/design/app/components/design/bridge/embedded-wheel.bridge.ts +263 -25
  124. package/corpus/templates/design/app/components/design/design-canvas/annotation-submit.ts +34 -0
  125. package/corpus/templates/design/app/components/design/design-canvas/coordinate-transforms.ts +40 -0
  126. package/corpus/templates/design/app/components/design/design-canvas/creation.ts +47 -0
  127. package/corpus/templates/design/app/components/design/design-canvas/element-payload.ts +27 -0
  128. package/corpus/templates/design/app/components/design/design-canvas/embedded-frame.ts +62 -0
  129. package/corpus/templates/design/app/components/design/design-canvas/external-preview.ts +137 -0
  130. package/corpus/templates/design/app/components/design/design-canvas/file-drop.ts +10 -0
  131. package/corpus/templates/design/app/components/design/design-canvas/hit-test.ts +23 -0
  132. package/corpus/templates/design/app/components/design/design-canvas/iframe-events.ts +23 -0
  133. package/corpus/templates/design/app/components/design/design-canvas/iframe-pan.ts +177 -0
  134. package/corpus/templates/design/app/components/design/design-canvas/motion-types.ts +7 -0
  135. package/corpus/templates/design/app/components/design/design-canvas/pending-text-edit.ts +62 -0
  136. package/corpus/templates/design/app/components/design/index.ts +2 -2
  137. package/corpus/templates/design/app/components/design/multi-screen/canvas-tools.ts +100 -0
  138. package/corpus/templates/design/app/components/design/multi-screen/coordinate-transforms.ts +81 -0
  139. package/corpus/templates/design/app/components/design/multi-screen/cross-screen-drop.ts +23 -13
  140. package/corpus/templates/design/app/components/design/multi-screen/culling.ts +238 -19
  141. package/corpus/templates/design/app/components/design/multi-screen/draft-primitives.ts +424 -0
  142. package/corpus/templates/design/app/components/design/multi-screen/frame-geometry.ts +341 -0
  143. package/corpus/templates/design/app/components/design/multi-screen/iframe-targeting.ts +52 -0
  144. package/corpus/templates/design/app/components/design/multi-screen/overview-layout.ts +62 -0
  145. package/corpus/templates/design/app/components/design/multi-screen/primitive-drop-target.ts +283 -0
  146. package/corpus/templates/design/app/components/design/multi-screen/screen-content-cache.ts +272 -0
  147. package/corpus/templates/design/app/components/design/multi-screen/types.ts +5 -1
  148. package/corpus/templates/design/app/components/design/multi-screen/wheel-gesture-state.ts +12 -0
  149. package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +325 -84
  150. package/corpus/templates/design/app/hooks/use-navigation-state.ts +15 -1
  151. package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +119 -47
  152. package/corpus/templates/design/app/i18n/zh-TW.ts +9 -0
  153. package/corpus/templates/design/app/i18n-data.ts +223 -0
  154. package/corpus/templates/design/app/lib/design-save-outbox.ts +379 -0
  155. package/corpus/templates/design/app/lib/png-clipboard.ts +113 -0
  156. package/corpus/templates/design/app/pages/design-editor/canvas-primitives.ts +229 -0
  157. package/corpus/templates/design/app/pages/design-editor/code-layer-state.ts +918 -0
  158. package/corpus/templates/design/app/pages/design-editor/collab-sync.ts +43 -0
  159. package/corpus/templates/design/app/pages/design-editor/cross-screen-text-color.ts +336 -0
  160. package/corpus/templates/design/app/pages/design-editor/data-operations.ts +270 -0
  161. package/corpus/templates/design/app/pages/design-editor/dom-guards.ts +24 -0
  162. package/corpus/templates/design/app/pages/design-editor/dom-utils.ts +11 -0
  163. package/corpus/templates/design/app/pages/design-editor/editor-session.ts +20 -0
  164. package/corpus/templates/design/app/pages/design-editor/editor-state.ts +408 -0
  165. package/corpus/templates/design/app/pages/design-editor/export-capture.ts +115 -0
  166. package/corpus/templates/design/app/pages/design-editor/geometry-persistence.ts +93 -0
  167. package/corpus/templates/design/app/pages/design-editor/history.ts +169 -0
  168. package/corpus/templates/design/app/pages/design-editor/layout-operations.ts +185 -0
  169. package/corpus/templates/design/app/pages/design-editor/motion-state.ts +309 -0
  170. package/corpus/templates/design/app/pages/design-editor/overview-camera.ts +438 -0
  171. package/corpus/templates/design/app/pages/design-editor/pending-edits.ts +510 -0
  172. package/corpus/templates/design/app/pages/design-editor/portable-style.ts +115 -0
  173. package/corpus/templates/design/app/pages/design-editor/selection-state.ts +337 -0
  174. package/corpus/templates/design/app/pages/design-editor/style-utils.ts +5 -0
  175. package/corpus/templates/design/app/pages/design-editor/tool-state.ts +92 -0
  176. package/corpus/templates/design/app/pages/design-editor/tweak-save.ts +87 -0
  177. package/corpus/templates/design/app/pages/design-editor/types.ts +36 -0
  178. package/corpus/templates/design/app/routes/design.$id.tsx +11 -1
  179. package/corpus/templates/design/changelog/2026-07-09-design-edits-canvas-positions-tools-and-tab-context-now-pers.md +6 -0
  180. package/corpus/templates/design/changelog/2026-07-09-figma-style-shortcuts-drawing-annotations-and-cross-screen-d.md +6 -0
  181. package/corpus/templates/design/changelog/2026-07-09-hand-and-scale-tools-now-keep-their-active-toolbar-identity-.md +6 -0
  182. package/corpus/templates/design/changelog/2026-07-09-large-multi-screen-designs-now-stay-responsive-during-long-c.md +6 -0
  183. package/corpus/templates/design/changelog/2026-07-09-layer-drags-now-land-exactly-where-the-insertion-indicator-s.md +6 -0
  184. package/corpus/templates/design/server/db/index.ts +2 -52
  185. package/corpus/templates/design/server/db/schema.ts +16 -0
  186. package/corpus/templates/design/server/lib/design-data-access.ts +70 -0
  187. package/corpus/templates/design/server/lib/design-data-mutation.ts +249 -0
  188. package/corpus/templates/design/server/lib/fusion-screens.ts +89 -76
  189. package/corpus/templates/design/server/lib/import-design-files.ts +76 -35
  190. package/corpus/templates/design/server/lib/verify-write-grant.ts +7 -2
  191. package/corpus/templates/design/server/plugins/db.ts +19 -0
  192. package/corpus/templates/design/server/source-workspace.ts +12 -13
  193. package/corpus/templates/design/shared/resolve-tweaks.ts +15 -0
  194. package/corpus/templates/design/shared/screen-rename.ts +42 -0
  195. package/corpus/templates/design/shared/source-mode.ts +84 -4
  196. package/dist/agent/engine/builder-engine.d.ts +1 -1
  197. package/dist/agent/engine/builder-engine.d.ts.map +1 -1
  198. package/dist/agent/engine/builtin.js +1 -1
  199. package/dist/agent/engine/builtin.js.map +1 -1
  200. package/dist/agent/model-config.d.ts +9 -9
  201. package/dist/agent/model-config.d.ts.map +1 -1
  202. package/dist/agent/model-config.js +30 -30
  203. package/dist/agent/model-config.js.map +1 -1
  204. package/dist/agent/production-agent.d.ts +60 -1
  205. package/dist/agent/production-agent.d.ts.map +1 -1
  206. package/dist/agent/production-agent.js +106 -7
  207. package/dist/agent/production-agent.js.map +1 -1
  208. package/dist/cli/design-connect.d.ts +30 -1
  209. package/dist/cli/design-connect.d.ts.map +1 -1
  210. package/dist/cli/design-connect.js +359 -75
  211. package/dist/cli/design-connect.js.map +1 -1
  212. package/dist/cli/recap.js +2 -2
  213. package/dist/cli/recap.js.map +1 -1
  214. package/dist/client/AgentPanel.d.ts.map +1 -1
  215. package/dist/client/AgentPanel.js +6 -1
  216. package/dist/client/AgentPanel.js.map +1 -1
  217. package/dist/client/agent-chat-adapter.d.ts.map +1 -1
  218. package/dist/client/agent-chat-adapter.js +10 -5
  219. package/dist/client/agent-chat-adapter.js.map +1 -1
  220. package/dist/client/composer/TiptapComposer.js +1 -1
  221. package/dist/client/composer/TiptapComposer.js.map +1 -1
  222. package/dist/client/index.d.ts +1 -1
  223. package/dist/client/index.d.ts.map +1 -1
  224. package/dist/client/index.js +1 -1
  225. package/dist/client/index.js.map +1 -1
  226. package/dist/client/org/TeamPage.d.ts.map +1 -1
  227. package/dist/client/org/TeamPage.js +6 -5
  228. package/dist/client/org/TeamPage.js.map +1 -1
  229. package/dist/client/use-action.d.ts +32 -0
  230. package/dist/client/use-action.d.ts.map +1 -1
  231. package/dist/client/use-action.js +68 -1
  232. package/dist/client/use-action.js.map +1 -1
  233. package/dist/collab/struct-routes.d.ts +1 -1
  234. package/dist/db/client.d.ts +3 -0
  235. package/dist/db/client.d.ts.map +1 -1
  236. package/dist/db/client.js +7 -2
  237. package/dist/db/client.js.map +1 -1
  238. package/dist/db/create-get-db.d.ts.map +1 -1
  239. package/dist/db/create-get-db.js +2 -2
  240. package/dist/db/create-get-db.js.map +1 -1
  241. package/dist/notifications/routes.d.ts +3 -3
  242. package/dist/resources/handlers.d.ts +2 -2
  243. package/dist/scripts/agent-engines/manage-agent-engine.js +1 -1
  244. package/dist/scripts/agent-engines/manage-agent-engine.js.map +1 -1
  245. package/dist/scripts/agent-engines/set-agent-engine.js +1 -1
  246. package/dist/scripts/agent-engines/set-agent-engine.js.map +1 -1
  247. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  248. package/dist/server/transcribe-voice.js +1 -1
  249. package/dist/server/transcribe-voice.js.map +1 -1
  250. package/dist/usage/store.d.ts.map +1 -1
  251. package/dist/usage/store.js +13 -4
  252. package/dist/usage/store.js.map +1 -1
  253. package/dist/vite/client.d.ts.map +1 -1
  254. package/dist/vite/client.js +36 -1
  255. package/dist/vite/client.js.map +1 -1
  256. package/docs/content/a2a-protocol.mdx +12 -0
  257. package/docs/content/locales/ar-SA/a2a-protocol.mdx +12 -0
  258. package/docs/content/locales/ar-SA/pr-visual-recap.mdx +3 -3
  259. package/docs/content/locales/ar-SA/pure-agent-apps.mdx +4 -0
  260. package/docs/content/locales/de-DE/a2a-protocol.mdx +12 -0
  261. package/docs/content/locales/de-DE/pr-visual-recap.mdx +3 -3
  262. package/docs/content/locales/de-DE/pure-agent-apps.mdx +4 -0
  263. package/docs/content/locales/es-ES/a2a-protocol.mdx +12 -0
  264. package/docs/content/locales/es-ES/pr-visual-recap.mdx +3 -3
  265. package/docs/content/locales/es-ES/pure-agent-apps.mdx +4 -0
  266. package/docs/content/locales/fr-FR/a2a-protocol.mdx +12 -0
  267. package/docs/content/locales/fr-FR/pr-visual-recap.mdx +3 -3
  268. package/docs/content/locales/fr-FR/pure-agent-apps.mdx +4 -0
  269. package/docs/content/locales/hi-IN/a2a-protocol.mdx +12 -0
  270. package/docs/content/locales/hi-IN/pr-visual-recap.mdx +3 -3
  271. package/docs/content/locales/hi-IN/pure-agent-apps.mdx +4 -0
  272. package/docs/content/locales/ja-JP/a2a-protocol.mdx +12 -0
  273. package/docs/content/locales/ja-JP/pr-visual-recap.mdx +3 -3
  274. package/docs/content/locales/ja-JP/pure-agent-apps.mdx +4 -0
  275. package/docs/content/locales/ko-KR/a2a-protocol.mdx +12 -0
  276. package/docs/content/locales/ko-KR/pr-visual-recap.mdx +3 -3
  277. package/docs/content/locales/ko-KR/pure-agent-apps.mdx +4 -0
  278. package/docs/content/locales/pt-BR/a2a-protocol.mdx +12 -0
  279. package/docs/content/locales/pt-BR/pr-visual-recap.mdx +3 -3
  280. package/docs/content/locales/pt-BR/pure-agent-apps.mdx +4 -0
  281. package/docs/content/locales/zh-CN/a2a-protocol.mdx +12 -0
  282. package/docs/content/locales/zh-CN/pr-visual-recap.mdx +3 -3
  283. package/docs/content/locales/zh-CN/pure-agent-apps.mdx +4 -0
  284. package/docs/content/locales/zh-TW/a2a-protocol.mdx +12 -0
  285. package/docs/content/locales/zh-TW/pr-visual-recap.mdx +3 -3
  286. package/docs/content/locales/zh-TW/pure-agent-apps.mdx +4 -0
  287. package/docs/content/pr-visual-recap.mdx +3 -3
  288. package/docs/content/pure-agent-apps.mdx +22 -0
  289. package/package.json +2 -2
@@ -7,7 +7,7 @@ import {
7
7
  } from "@agent-native/core/collab";
8
8
  import { isPostgres } from "@agent-native/core/db";
9
9
  import { accessFilter, assertAccess } from "@agent-native/core/sharing";
10
- import { and, eq, sql } from "drizzle-orm";
10
+ import { and, eq, isNull, sql } from "drizzle-orm";
11
11
  import { z } from "zod";
12
12
 
13
13
  import { getDb, schema } from "../server/db/index.js";
@@ -48,39 +48,78 @@ function rowsAffected(result: unknown): number | undefined {
48
48
  * difference. When `expectedVersionHash` is omitted entirely, or the hash
49
49
  * matches, or `syncCollab` is left at its default `true`, or no collab state
50
50
  * exists yet for the file, this skip path never triggers.
51
+ *
52
+ * Versioned browser saves additionally return `versionHash` for the content
53
+ * that remains persisted. `skippedStaleOperation?: true` means an equal or
54
+ * newer revision from that same browser tab was already accepted, so this
55
+ * late request was treated as an idempotent content no-op.
51
56
  */
52
57
  export default defineAction({
53
58
  description:
54
59
  "Update an existing file in a design project. " +
55
60
  "Only provided fields are updated; omitted fields are left unchanged. " +
56
61
  "Also updates the parent design's updatedAt timestamp.",
57
- schema: z.object({
58
- id: z.string().describe("File ID to update"),
59
- content: z.string().optional().describe("Updated file content"),
60
- filename: z.string().optional().describe("New filename"),
61
- fileType: z
62
- .enum(["html", "css", "jsx", "asset"])
63
- .optional()
64
- .describe("Updated file type"),
65
- syncCollab: z
66
- .boolean()
67
- .optional()
68
- .default(true)
69
- .describe(
70
- "Whether to mirror content updates into the live collaboration document.",
71
- ),
72
- expectedVersionHash: z
73
- .string()
74
- .optional()
75
- .describe(
76
- "Optional optimistic-concurrency guard for content updates: the " +
77
- "sourceContentHash of the live content this write was computed " +
78
- "from (same semantics as apply-source-edit / read-source-file). " +
79
- "When provided and the file changed since that read, the write " +
80
- "fails loud instead of silently merging a stale full document " +
81
- "into the collaboration state.",
82
- ),
83
- }),
62
+ schema: z
63
+ .object({
64
+ id: z.string().describe("File ID to update"),
65
+ content: z.string().optional().describe("Updated file content"),
66
+ filename: z.string().optional().describe("New filename"),
67
+ fileType: z
68
+ .enum(["html", "css", "jsx", "asset"])
69
+ .optional()
70
+ .describe("Updated file type"),
71
+ syncCollab: z
72
+ .boolean()
73
+ .optional()
74
+ .default(true)
75
+ .describe(
76
+ "Whether to mirror content updates into the live collaboration document.",
77
+ ),
78
+ expectedVersionHash: z
79
+ .string()
80
+ .optional()
81
+ .describe(
82
+ "Optional optimistic-concurrency guard for content updates: the " +
83
+ "sourceContentHash of the live content this write was computed " +
84
+ "from (same semantics as apply-source-edit / read-source-file). " +
85
+ "When provided and the file changed since that read, the write " +
86
+ "fails loud instead of silently merging a stale full document " +
87
+ "into the collaboration state.",
88
+ ),
89
+ operationSource: z
90
+ .string()
91
+ .min(1)
92
+ .max(256)
93
+ .optional()
94
+ .describe(
95
+ "Stable client/tab id for monotonic content-save ordering. Must be paired with operationRevision.",
96
+ ),
97
+ operationRevision: z
98
+ .number()
99
+ .int()
100
+ .positive()
101
+ .max(Number.MAX_SAFE_INTEGER)
102
+ .optional()
103
+ .describe(
104
+ "Monotonic per-file revision allocated when the client queues the save. Must be paired with operationSource.",
105
+ ),
106
+ })
107
+ .superRefine((value, ctx) => {
108
+ if (
109
+ (value.operationSource === undefined) !==
110
+ (value.operationRevision === undefined)
111
+ ) {
112
+ ctx.addIssue({
113
+ code: "custom",
114
+ message:
115
+ "operationSource and operationRevision must be provided together.",
116
+ path:
117
+ value.operationSource === undefined
118
+ ? ["operationSource"]
119
+ : ["operationRevision"],
120
+ });
121
+ }
122
+ }),
84
123
  run: async ({
85
124
  id,
86
125
  content,
@@ -88,6 +127,8 @@ export default defineAction({
88
127
  fileType,
89
128
  syncCollab,
90
129
  expectedVersionHash,
130
+ operationSource,
131
+ operationRevision,
91
132
  }) => {
92
133
  // Path traversal guard on filename
93
134
  if (
@@ -153,194 +194,411 @@ export default defineAction({
153
194
  // same lock so it can't interleave with a concurrent guarded writer's own
154
195
  // read-check-write.
155
196
  let skippedStaleMirror = false;
197
+ let skippedStaleOperation = false;
198
+ let exactOperationAlreadyPersisted = false;
199
+ let persistedVersionHash: string | undefined;
156
200
 
157
201
  await withSourceFileWriteLock(id, async () => {
158
- // SQL-mirror-only skip path: when the caller explicitly opted OUT of
159
- // collab sync (syncCollab: false) and supplied an expectedVersionHash
160
- // that no longer matches the LIVE collab text, and a live collab doc
161
- // actually exists for this file, the caller's `content` was computed
162
- // from a base that a live editor has since moved past. Overwriting the
163
- // SQL mirror column with that stale content here would silently regress
164
- // it out from under the live document (which stays the source of
165
- // truth) the next time it's read back out of SQL. Skip the content
166
- // write instead of throwing: filename/fileType updates in the same call
167
- // still proceed, and the caller gets `skippedStaleMirror: true` back
168
- // instead of a thrown error, because they explicitly said they weren't
169
- // trying to sync into collab in the first place. Every other
170
- // expectedVersionHash combination (syncCollab true/default, or no live
171
- // collab state, or a matching hash, or no hash at all) is UNCHANGED.
172
- //
173
- // Own-edit false-positive fix: a single client's own edit reaches the
174
- // live collab doc via TWO independent, unordered paths — the Yjs
175
- // update (~80ms client debounce, applied to the server's in-memory doc
176
- // as soon as its POST lands) and this guarded update-file call (~400ms
177
- // client debounce). The Yjs path usually wins the race, so by the time
178
- // this call's hash check runs, `liveContent` often already equals the
179
- // very `content` this call is trying to write — that is NOT a
180
- // divergent concurrent edit, it's the same edit having arrived early
181
- // by a different transport. Comparing hashes first would reject that
182
- // as "stale" and permanently skip the SQL mirror write (there is no
183
- // background job that later reconciles design_files.content from the
184
- // live collab doc — see hasCollabState below), silently losing writes
185
- // on every edit after the first in a session. Check content equality
186
- // BEFORE the hash comparison so this exact-match case always proceeds
187
- // as a normal write instead of hitting either the skip or throw path.
188
- let skipContentWrite = false;
189
- let mirrorLineageCollabSync = false;
190
- if (expectedVersionHash !== undefined && content !== undefined) {
191
- const collabExists = await hasCollabState(id);
192
- let liveContent: string;
193
- if (collabExists) {
194
- liveContent = await getText(id, "content");
195
- } else {
196
- const [current] = await db
197
- .select({ content: schema.designFiles.content })
198
- .from(schema.designFiles)
199
- .where(eq(schema.designFiles.id, id))
200
- .limit(1);
201
- liveContent = current?.content ?? "";
202
+ for (let attempt = 0; attempt < 4; attempt += 1) {
203
+ skippedStaleMirror = false;
204
+ skippedStaleOperation = false;
205
+ exactOperationAlreadyPersisted = false;
206
+ const [persistedFile] = await db
207
+ .select({
208
+ content: schema.designFiles.content,
209
+ contentOperationSource: schema.designFiles.contentOperationSource,
210
+ contentOperationRevision:
211
+ schema.designFiles.contentOperationRevision,
212
+ contentOperationResultHash:
213
+ schema.designFiles.contentOperationResultHash,
214
+ })
215
+ .from(schema.designFiles)
216
+ .where(eq(schema.designFiles.id, id))
217
+ .limit(1);
218
+ if (!persistedFile) {
219
+ throw new Error(`File not found: ${id}`);
202
220
  }
221
+
222
+ const persistedContentHash = sourceContentHash(persistedFile.content);
223
+ persistedVersionHash = persistedContentHash;
224
+ const hasVersionedContentOperation =
225
+ content !== undefined &&
226
+ operationSource !== undefined &&
227
+ operationRevision !== undefined;
228
+ const requestedOperationRevision = operationRevision ?? null;
229
+ const sameOperationSource =
230
+ hasVersionedContentOperation &&
231
+ persistedFile.contentOperationSource === operationSource &&
232
+ typeof persistedFile.contentOperationRevision === "number";
233
+
234
+ // A pagehide keepalive can overtake the older normal fetch for this
235
+ // same tab. Once the newer revision has committed, the late request is
236
+ // an idempotent no-op regardless of its stale expectedVersionHash. Do
237
+ // this before the content hash guard so request arrival order cannot
238
+ // turn a correct latest save into a conflict or overwrite.
203
239
  if (
204
- liveContent !== content &&
205
- sourceContentHash(liveContent) !== expectedVersionHash
240
+ sameOperationSource &&
241
+ requestedOperationRevision !== null &&
242
+ requestedOperationRevision <= persistedFile.contentOperationRevision!
206
243
  ) {
207
- if (syncCollab === false && collabExists) {
208
- // Mirror-lineage rescue (sequential-edit data-loss fix, verified
209
- // live): the client's Yjs transact and its guarded update-file
210
- // call ride two independent transports, and the Yjs pipe can lag
211
- // or silently die reproduced with a live collab doc that never
212
- // received EITHER of two sequential scrub edits while the HTTP
213
- // saves advanced the SQL mirror normally. Comparing the caller
214
- // only against that stale live text mis-classified the SECOND
215
- // save as a divergent writer and silently dropped it. When the
216
- // caller's expectedVersionHash matches the CURRENT SQL mirror,
217
- // the caller is the mirror's own uninterrupted lineage (a plain
218
- // CAS success against the column this write updates) while the
219
- // live doc is the diverging party — a dead/lagging client Yjs
220
- // pipe or a concurrent live-only editor. Do NOT skip, and do NOT
221
- // silently drop either side: proceed with the mirror write AND
222
- // push `content` through the collab layer exactly like
223
- // syncCollab:true does (mirrorLineageCollabSync below). The
224
- // applyText char-diff merge folds the caller's change into the
225
- // live doc as a CRDT diff, so no one's edits are dropped: the
226
- // mirror advances with the caller, and the live doc receives the
227
- // caller's change as a diff-merge that preserves any other
228
- // editor's live edits. Only callers matching NEITHER the live
229
- // text NOR the mirror are genuinely stale and still hit the skip
230
- // below.
231
- const [mirrorRow] = await db
244
+ skippedStaleOperation = true;
245
+ // The SQL CAS may have committed before the separate collab apply
246
+ // failed. A retry of that exact operation must be allowed to finish
247
+ // convergence; an older revision must remain a strict no-op. Require
248
+ // both persisted hashes to prove the requested content is exactly
249
+ // what this operation committed before re-running any side effect.
250
+ exactOperationAlreadyPersisted =
251
+ requestedOperationRevision ===
252
+ persistedFile.contentOperationRevision &&
253
+ content !== undefined &&
254
+ persistedContentHash === sourceContentHash(content) &&
255
+ persistedFile.contentOperationResultHash === persistedContentHash;
256
+ }
257
+
258
+ // Several rapid saves may all have been queued from the same acked
259
+ // base. A successor from the SAME tab may advance from the previous
260
+ // accepted result even when its queue-time expected hash predates that
261
+ // result. Only trust this lineage if the current SQL mirror still
262
+ // equals the result hash recorded with the prior operation; an
263
+ // intervening writer breaks the chain and keeps the ordinary hash
264
+ // conflict guard fully active.
265
+ const sameSourceSuccessorHash =
266
+ sameOperationSource &&
267
+ !skippedStaleOperation &&
268
+ requestedOperationRevision !== null &&
269
+ requestedOperationRevision >
270
+ persistedFile.contentOperationRevision! &&
271
+ persistedFile.contentOperationResultHash === persistedContentHash
272
+ ? persistedContentHash
273
+ : undefined;
274
+
275
+ // SQL-mirror-only skip path: when the caller explicitly opted OUT of
276
+ // collab sync (syncCollab: false) and supplied an expectedVersionHash
277
+ // that no longer matches the LIVE collab text, and a live collab doc
278
+ // actually exists for this file, the caller's `content` was computed
279
+ // from a base that a live editor has since moved past. Overwriting the
280
+ // SQL mirror column with that stale content here would silently regress
281
+ // it out from under the live document (which stays the source of
282
+ // truth) the next time it's read back out of SQL. Skip the content
283
+ // write instead of throwing: filename/fileType updates in the same call
284
+ // still proceed, and the caller gets `skippedStaleMirror: true` back
285
+ // instead of a thrown error, because they explicitly said they weren't
286
+ // trying to sync into collab in the first place. Every other
287
+ // expectedVersionHash combination (syncCollab true/default, or no live
288
+ // collab state, or a matching hash, or no hash at all) is UNCHANGED.
289
+ //
290
+ // Own-edit false-positive fix: a single client's own edit reaches the
291
+ // live collab doc via TWO independent, unordered paths — the Yjs
292
+ // update (~80ms client debounce, applied to the server's in-memory doc
293
+ // as soon as its POST lands) and this guarded update-file call (~400ms
294
+ // client debounce). The Yjs path usually wins the race, so by the time
295
+ // this call's hash check runs, `liveContent` often already equals the
296
+ // very `content` this call is trying to write — that is NOT a
297
+ // divergent concurrent edit, it's the same edit having arrived early
298
+ // by a different transport. Comparing hashes first would reject that
299
+ // as "stale" and permanently skip the SQL mirror write (there is no
300
+ // background job that later reconciles design_files.content from the
301
+ // live collab doc — see hasCollabState below), silently losing writes
302
+ // on every edit after the first in a session. Check content equality
303
+ // BEFORE the hash comparison so this exact-match case always proceeds
304
+ // as a normal write instead of hitting either the skip or throw path.
305
+ let skipContentWrite = skippedStaleOperation;
306
+ let mirrorLineageCollabSync = false;
307
+ if (
308
+ !skippedStaleOperation &&
309
+ expectedVersionHash !== undefined &&
310
+ content !== undefined
311
+ ) {
312
+ const collabExists = await hasCollabState(id);
313
+ let liveContent: string;
314
+ if (collabExists) {
315
+ liveContent = await getText(id, "content");
316
+ } else {
317
+ const [current] = await db
232
318
  .select({ content: schema.designFiles.content })
233
319
  .from(schema.designFiles)
234
320
  .where(eq(schema.designFiles.id, id))
235
321
  .limit(1);
236
- if (
237
- sourceContentHash(mirrorRow?.content ?? "") ===
238
- expectedVersionHash
239
- ) {
240
- // Caller is exactly at the persisted mirror's tip — the live
241
- // collab doc is the lagging party, not the caller. Write the
242
- // mirror normally and also sync the caller's content into the
243
- // live doc via the collab diff-merge below.
244
- mirrorLineageCollabSync = true;
322
+ liveContent = current?.content ?? "";
323
+ }
324
+ const acceptedBaseHashes = new Set([
325
+ expectedVersionHash,
326
+ ...(sameSourceSuccessorHash ? [sameSourceSuccessorHash] : []),
327
+ ]);
328
+ if (
329
+ liveContent !== content &&
330
+ !acceptedBaseHashes.has(sourceContentHash(liveContent))
331
+ ) {
332
+ if (syncCollab === false && collabExists) {
333
+ // Mirror-lineage rescue (sequential-edit data-loss fix, verified
334
+ // live): the client's Yjs transact and its guarded update-file
335
+ // call ride two independent transports, and the Yjs pipe can lag
336
+ // or silently die — reproduced with a live collab doc that never
337
+ // received EITHER of two sequential scrub edits while the HTTP
338
+ // saves advanced the SQL mirror normally. Comparing the caller
339
+ // only against that stale live text mis-classified the SECOND
340
+ // save as a divergent writer and silently dropped it. When the
341
+ // caller's expectedVersionHash matches the CURRENT SQL mirror,
342
+ // the caller is the mirror's own uninterrupted lineage (a plain
343
+ // CAS success against the column this write updates) while the
344
+ // live doc is the diverging party — a dead/lagging client Yjs
345
+ // pipe or a concurrent live-only editor. Do NOT skip, and do NOT
346
+ // silently drop either side: proceed with the mirror write AND
347
+ // push `content` through the collab layer exactly like
348
+ // syncCollab:true does (mirrorLineageCollabSync below). The
349
+ // applyText char-diff merge folds the caller's change into the
350
+ // live doc as a CRDT diff, so no one's edits are dropped: the
351
+ // mirror advances with the caller, and the live doc receives the
352
+ // caller's change as a diff-merge that preserves any other
353
+ // editor's live edits. Only callers matching NEITHER the live
354
+ // text NOR the mirror are genuinely stale and still hit the skip
355
+ // below.
356
+ if (acceptedBaseHashes.has(persistedContentHash)) {
357
+ // Caller is exactly at the persisted mirror's tip — the live
358
+ // collab doc is the lagging party, not the caller. Write the
359
+ // mirror normally and also sync the caller's content into the
360
+ // live doc via the collab diff-merge below.
361
+ mirrorLineageCollabSync = true;
362
+ } else {
363
+ skipContentWrite = true;
364
+ skippedStaleMirror = true;
365
+ }
245
366
  } else {
246
- skipContentWrite = true;
247
- skippedStaleMirror = true;
367
+ throw new Error(
368
+ "File changed since it was read. Re-read the file and retry.",
369
+ );
248
370
  }
249
- } else {
250
- throw new Error(
251
- "File changed since it was read. Re-read the file and retry.",
252
- );
253
371
  }
254
372
  }
255
- }
256
373
 
257
- const updates: Record<string, unknown> = { updatedAt: now };
258
- if (content !== undefined && !skipContentWrite) updates.content = content;
259
- if (filename !== undefined) updates.filename = filename;
260
- if (fileType !== undefined) updates.fileType = fileType;
374
+ const updates: Record<string, unknown> = { updatedAt: now };
375
+ if (content !== undefined && !skipContentWrite) {
376
+ updates.content = content;
377
+ persistedVersionHash = sourceContentHash(content);
378
+ if (
379
+ operationSource !== undefined &&
380
+ operationRevision !== undefined
381
+ ) {
382
+ updates.contentOperationSource = operationSource;
383
+ updates.contentOperationRevision = operationRevision;
384
+ updates.contentOperationResultHash = persistedVersionHash;
385
+ } else {
386
+ // An unversioned writer starts a different lineage. Clearing the
387
+ // browser operation marker prevents a later request from treating
388
+ // stale transport metadata as proof that no writer intervened.
389
+ updates.contentOperationSource = null;
390
+ updates.contentOperationRevision = null;
391
+ updates.contentOperationResultHash = null;
392
+ }
393
+ }
394
+ if (filename !== undefined) updates.filename = filename;
395
+ if (fileType !== undefined) updates.fileType = fileType;
261
396
 
262
- if (filename !== undefined && isPostgres()) {
263
- await db.transaction(async (tx) => {
264
- // Postgres evaluates concurrent NOT EXISTS updates under MVCC, so a
265
- // guarded UPDATE alone can still race. Serialize design-file renames in
266
- // this rare path without using SQLite's fragile async savepoint wrapper.
267
- await (
268
- tx as unknown as { execute: (query: unknown) => Promise<unknown> }
269
- ).execute(sql`LOCK TABLE design_files IN SHARE ROW EXCLUSIVE MODE`);
270
- const [collision] = await tx
271
- .select({ id: schema.designFiles.id })
272
- .from(schema.designFiles)
273
- .where(
274
- and(
275
- eq(schema.designFiles.designId, file.designId),
276
- eq(schema.designFiles.filename, filename),
277
- ),
397
+ // The JS lock above is intentionally fast but process-local. Versioned
398
+ // browser writes also need a database CAS so two serverless instances
399
+ // cannot both validate the same snapshot and let the later SQL update
400
+ // clobber the winner. If another instance moves any part of the content
401
+ // lineage first, rowsAffected is zero and this loop re-reads the row;
402
+ // the next pass then classifies the request as a stale no-op, a valid
403
+ // same-source successor, or a real cross-writer hash conflict.
404
+ const requiresContentCas =
405
+ hasVersionedContentOperation && !skipContentWrite;
406
+ const contentCasWhere = requiresContentCas
407
+ ? and(
408
+ eq(schema.designFiles.content, persistedFile.content),
409
+ persistedFile.contentOperationSource == null
410
+ ? isNull(schema.designFiles.contentOperationSource)
411
+ : eq(
412
+ schema.designFiles.contentOperationSource,
413
+ persistedFile.contentOperationSource,
414
+ ),
415
+ persistedFile.contentOperationRevision == null
416
+ ? isNull(schema.designFiles.contentOperationRevision)
417
+ : eq(
418
+ schema.designFiles.contentOperationRevision,
419
+ persistedFile.contentOperationRevision,
420
+ ),
421
+ persistedFile.contentOperationResultHash == null
422
+ ? isNull(schema.designFiles.contentOperationResultHash)
423
+ : eq(
424
+ schema.designFiles.contentOperationResultHash,
425
+ persistedFile.contentOperationResultHash,
426
+ ),
278
427
  )
279
- .limit(1);
280
- if (collision && collision.id !== id) {
281
- throw new Error(
282
- `File "${filename}" already exists in design ${file.designId}`,
283
- );
284
- }
285
- await tx
286
- .update(schema.designFiles)
287
- .set(updates)
288
- .where(eq(schema.designFiles.id, id));
289
- });
290
- } else {
291
- // Reject colliding SQLite renames as part of the write. SQLite's local
292
- // async transaction wrapper can fail under concurrent editor/collab writes,
293
- // so keep this to one guarded UPDATE instead of a SELECT-then-UPDATE window.
294
- const updateWhere =
295
- filename === undefined
296
- ? eq(schema.designFiles.id, id)
297
- : and(
298
- eq(schema.designFiles.id, id),
299
- sql`NOT EXISTS (
428
+ : undefined;
429
+
430
+ let updateResult: unknown;
431
+
432
+ if (filename !== undefined && isPostgres()) {
433
+ updateResult = await db.transaction(async (tx) => {
434
+ // Postgres evaluates concurrent NOT EXISTS updates under MVCC, so a
435
+ // guarded UPDATE alone can still race. Serialize design-file renames in
436
+ // this rare path without using SQLite's fragile async savepoint wrapper.
437
+ await (
438
+ tx as unknown as { execute: (query: unknown) => Promise<unknown> }
439
+ ).execute(sql`LOCK TABLE design_files IN SHARE ROW EXCLUSIVE MODE`);
440
+ const [collision] = await tx
441
+ .select({ id: schema.designFiles.id })
442
+ .from(schema.designFiles)
443
+ .where(
444
+ and(
445
+ eq(schema.designFiles.designId, file.designId),
446
+ eq(schema.designFiles.filename, filename),
447
+ ),
448
+ )
449
+ .limit(1);
450
+ if (collision && collision.id !== id) {
451
+ throw new Error(
452
+ `File "${filename}" already exists in design ${file.designId}`,
453
+ );
454
+ }
455
+ return tx
456
+ .update(schema.designFiles)
457
+ .set(updates)
458
+ .where(and(eq(schema.designFiles.id, id), contentCasWhere));
459
+ });
460
+ } else {
461
+ // Reject colliding SQLite renames as part of the write. SQLite's local
462
+ // async transaction wrapper can fail under concurrent editor/collab writes,
463
+ // so keep this to one guarded UPDATE instead of a SELECT-then-UPDATE window.
464
+ const renameWhere =
465
+ filename === undefined
466
+ ? undefined
467
+ : and(
468
+ sql`NOT EXISTS (
300
469
  SELECT 1 FROM design_files AS sibling
301
470
  WHERE sibling.design_id = ${file.designId}
302
471
  AND sibling.filename = ${filename}
303
472
  AND sibling.id <> ${id}
304
473
  )`,
474
+ );
475
+ const updateWhere = and(
476
+ eq(schema.designFiles.id, id),
477
+ renameWhere,
478
+ contentCasWhere,
479
+ );
480
+
481
+ updateResult = await db
482
+ .update(schema.designFiles)
483
+ .set(updates)
484
+ .where(updateWhere);
485
+
486
+ if (filename !== undefined && rowsAffected(updateResult) === 0) {
487
+ const [collision] = await db
488
+ .select({ id: schema.designFiles.id })
489
+ .from(schema.designFiles)
490
+ .where(
491
+ and(
492
+ eq(schema.designFiles.designId, file.designId),
493
+ eq(schema.designFiles.filename, filename),
494
+ ),
495
+ )
496
+ .limit(1);
497
+ if (collision && collision.id !== id) {
498
+ throw new Error(
499
+ `File "${filename}" already exists in design ${file.designId}`,
305
500
  );
501
+ }
502
+ }
503
+ }
306
504
 
307
- const updateResult = await db
308
- .update(schema.designFiles)
309
- .set(updates)
310
- .where(updateWhere);
505
+ if (requiresContentCas && rowsAffected(updateResult) === 0) {
506
+ continue;
507
+ }
311
508
 
312
- if (filename !== undefined && rowsAffected(updateResult) === 0) {
313
- const [collision] = await db
314
- .select({ id: schema.designFiles.id })
509
+ if (requiresContentCas && rowsAffected(updateResult) === undefined) {
510
+ const [confirmed] = await db
511
+ .select({
512
+ content: schema.designFiles.content,
513
+ contentOperationSource: schema.designFiles.contentOperationSource,
514
+ contentOperationRevision:
515
+ schema.designFiles.contentOperationRevision,
516
+ contentOperationResultHash:
517
+ schema.designFiles.contentOperationResultHash,
518
+ })
315
519
  .from(schema.designFiles)
316
- .where(
317
- and(
318
- eq(schema.designFiles.designId, file.designId),
319
- eq(schema.designFiles.filename, filename),
320
- ),
321
- )
520
+ .where(eq(schema.designFiles.id, id))
322
521
  .limit(1);
323
- if (collision && collision.id !== id) {
324
- throw new Error(
325
- `File "${filename}" already exists in design ${file.designId}`,
326
- );
522
+ if (!confirmed) throw new Error(`File not found: ${id}`);
523
+ const confirmedHash = sourceContentHash(confirmed.content);
524
+ const exactOperationPersisted =
525
+ confirmed.contentOperationSource === operationSource &&
526
+ confirmed.contentOperationRevision === operationRevision &&
527
+ confirmed.contentOperationResultHash === persistedVersionHash &&
528
+ confirmedHash === persistedVersionHash;
529
+ if (!exactOperationPersisted) {
530
+ if (
531
+ confirmed.contentOperationSource === operationSource &&
532
+ typeof confirmed.contentOperationRevision === "number" &&
533
+ operationRevision !== undefined &&
534
+ confirmed.contentOperationRevision >= operationRevision
535
+ ) {
536
+ skippedStaleOperation = true;
537
+ persistedVersionHash = confirmedHash;
538
+ return;
539
+ }
540
+ continue;
327
541
  }
328
542
  }
329
- }
330
543
 
331
- // Push content through the collab layer so live editors see the change.
332
- // mirrorLineageCollabSync: a syncCollab:false caller whose hash matched
333
- // the current SQL mirror (mirror-lineage rescue) also syncs here, so a
334
- // dead/lagging live doc receives the caller's change as a CRDT
335
- // diff-merge instead of silently diverging from the mirror.
336
- if (content !== undefined && (syncCollab || mirrorLineageCollabSync)) {
337
- const collabExists = await hasCollabState(id);
338
- if (collabExists) {
339
- await applyText(id, content, "content", "agent");
340
- } else {
341
- await seedFromText(id, content);
544
+ // Push content through the collab layer so live editors see the change.
545
+ // mirrorLineageCollabSync: a syncCollab:false caller whose hash matched
546
+ // the current SQL mirror (mirror-lineage rescue) also syncs here, so a
547
+ // dead/lagging live doc receives the caller's change as a CRDT
548
+ // diff-merge instead of silently diverging from the mirror.
549
+ const shouldConvergePersistedRetry =
550
+ exactOperationAlreadyPersisted && syncCollab;
551
+ if (
552
+ content !== undefined &&
553
+ (!skipContentWrite || shouldConvergePersistedRetry) &&
554
+ (syncCollab || mirrorLineageCollabSync)
555
+ ) {
556
+ const collabExists = await hasCollabState(id);
557
+ if (collabExists) {
558
+ await applyText(id, content, "content", "agent");
559
+ } else {
560
+ await seedFromText(id, content);
561
+ }
562
+
563
+ // SQL CAS is cross-instance, while the collab document uses a
564
+ // separate transport. If another instance committed a newer SQL
565
+ // revision while this request was applying its text diff, converge
566
+ // the live document back to the current SQL winner before returning.
567
+ // Whichever writer finishes last performs this same check, so a late
568
+ // older collab apply cannot leave Yjs behind the monotonic mirror.
569
+ const [latestPersisted] = await db
570
+ .select({
571
+ content: schema.designFiles.content,
572
+ contentOperationSource: schema.designFiles.contentOperationSource,
573
+ contentOperationRevision:
574
+ schema.designFiles.contentOperationRevision,
575
+ })
576
+ .from(schema.designFiles)
577
+ .where(eq(schema.designFiles.id, id))
578
+ .limit(1);
579
+ if (latestPersisted && latestPersisted.content !== content) {
580
+ const collabStillExists = await hasCollabState(id);
581
+ if (collabStillExists) {
582
+ await applyText(id, latestPersisted.content, "content", "agent");
583
+ } else {
584
+ await seedFromText(id, latestPersisted.content);
585
+ }
586
+ persistedVersionHash = sourceContentHash(latestPersisted.content);
587
+ if (
588
+ latestPersisted.contentOperationSource === operationSource &&
589
+ typeof latestPersisted.contentOperationRevision === "number" &&
590
+ operationRevision !== undefined &&
591
+ latestPersisted.contentOperationRevision >= operationRevision
592
+ ) {
593
+ skippedStaleOperation = true;
594
+ }
595
+ }
342
596
  }
597
+ return;
343
598
  }
599
+ throw new Error(
600
+ "File changed repeatedly while it was being saved. Re-read the file and retry.",
601
+ );
344
602
  });
345
603
 
346
604
  // Update the parent design's updatedAt timestamp. This still runs even
@@ -354,8 +612,17 @@ export default defineAction({
354
612
  .set({ updatedAt: now })
355
613
  .where(eq(schema.designs.id, file.designId));
356
614
 
357
- return skippedStaleMirror
358
- ? { id, updated: true, skippedStaleMirror: true }
359
- : { id, updated: true };
615
+ if (skippedStaleMirror) {
616
+ return { id, updated: true, skippedStaleMirror: true };
617
+ }
618
+ if (operationSource !== undefined && operationRevision !== undefined) {
619
+ return {
620
+ id,
621
+ updated: true,
622
+ ...(skippedStaleOperation ? { skippedStaleOperation: true } : {}),
623
+ versionHash: persistedVersionHash,
624
+ };
625
+ }
626
+ return { id, updated: true };
360
627
  },
361
628
  });