@agent-native/core 0.92.6 → 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.
- package/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +18 -0
- package/corpus/core/docs/content/a2a-protocol.mdx +12 -0
- package/corpus/core/docs/content/locales/ar-SA/a2a-protocol.mdx +12 -0
- package/corpus/core/docs/content/locales/ar-SA/pr-visual-recap.mdx +3 -3
- package/corpus/core/docs/content/locales/ar-SA/pure-agent-apps.mdx +4 -0
- package/corpus/core/docs/content/locales/de-DE/a2a-protocol.mdx +12 -0
- package/corpus/core/docs/content/locales/de-DE/pr-visual-recap.mdx +3 -3
- package/corpus/core/docs/content/locales/de-DE/pure-agent-apps.mdx +4 -0
- package/corpus/core/docs/content/locales/es-ES/a2a-protocol.mdx +12 -0
- package/corpus/core/docs/content/locales/es-ES/pr-visual-recap.mdx +3 -3
- package/corpus/core/docs/content/locales/es-ES/pure-agent-apps.mdx +4 -0
- package/corpus/core/docs/content/locales/fr-FR/a2a-protocol.mdx +12 -0
- package/corpus/core/docs/content/locales/fr-FR/pr-visual-recap.mdx +3 -3
- package/corpus/core/docs/content/locales/fr-FR/pure-agent-apps.mdx +4 -0
- package/corpus/core/docs/content/locales/hi-IN/a2a-protocol.mdx +12 -0
- package/corpus/core/docs/content/locales/hi-IN/pr-visual-recap.mdx +3 -3
- package/corpus/core/docs/content/locales/hi-IN/pure-agent-apps.mdx +4 -0
- package/corpus/core/docs/content/locales/ja-JP/a2a-protocol.mdx +12 -0
- package/corpus/core/docs/content/locales/ja-JP/pr-visual-recap.mdx +3 -3
- package/corpus/core/docs/content/locales/ja-JP/pure-agent-apps.mdx +4 -0
- package/corpus/core/docs/content/locales/ko-KR/a2a-protocol.mdx +12 -0
- package/corpus/core/docs/content/locales/ko-KR/pr-visual-recap.mdx +3 -3
- package/corpus/core/docs/content/locales/ko-KR/pure-agent-apps.mdx +4 -0
- package/corpus/core/docs/content/locales/pt-BR/a2a-protocol.mdx +12 -0
- package/corpus/core/docs/content/locales/pt-BR/pr-visual-recap.mdx +3 -3
- package/corpus/core/docs/content/locales/pt-BR/pure-agent-apps.mdx +4 -0
- package/corpus/core/docs/content/locales/zh-CN/a2a-protocol.mdx +12 -0
- package/corpus/core/docs/content/locales/zh-CN/pr-visual-recap.mdx +3 -3
- package/corpus/core/docs/content/locales/zh-CN/pure-agent-apps.mdx +4 -0
- package/corpus/core/docs/content/locales/zh-TW/a2a-protocol.mdx +12 -0
- package/corpus/core/docs/content/locales/zh-TW/pr-visual-recap.mdx +3 -3
- package/corpus/core/docs/content/locales/zh-TW/pure-agent-apps.mdx +4 -0
- package/corpus/core/docs/content/pr-visual-recap.mdx +3 -3
- package/corpus/core/docs/content/pure-agent-apps.mdx +22 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/agent/engine/builtin.ts +1 -1
- package/corpus/core/src/agent/model-config.ts +30 -30
- package/corpus/core/src/agent/production-agent.ts +129 -7
- package/corpus/core/src/cli/design-connect.ts +438 -83
- package/corpus/core/src/cli/recap.ts +2 -2
- package/corpus/core/src/cli/sync-builder-starter-manifest.ts +1 -1
- package/corpus/core/src/client/AgentPanel.tsx +8 -1
- package/corpus/core/src/client/agent-chat-adapter.ts +12 -7
- package/corpus/core/src/client/composer/TiptapComposer.tsx +1 -1
- package/corpus/core/src/client/index.ts +4 -0
- package/corpus/core/src/client/org/TeamPage.tsx +41 -26
- package/corpus/core/src/client/use-action.ts +113 -1
- package/corpus/core/src/db/client.ts +12 -2
- package/corpus/core/src/db/create-get-db.ts +4 -1
- package/corpus/core/src/scripts/agent-engines/manage-agent-engine.ts +1 -1
- package/corpus/core/src/scripts/agent-engines/set-agent-engine.ts +1 -1
- package/corpus/core/src/server/transcribe-voice.ts +1 -1
- package/corpus/core/src/usage/store.ts +13 -4
- package/corpus/core/src/vite/client.ts +38 -1
- package/corpus/templates/analytics/AGENTS.md +3 -0
- package/corpus/templates/analytics/actions/get-data-program.ts +78 -0
- package/corpus/templates/analytics/actions/list-data-programs.ts +49 -0
- package/corpus/templates/analytics/app/global.css +11 -0
- package/corpus/templates/analytics/app/hooks/use-dashboard-chat-context.ts +163 -17
- package/corpus/templates/analytics/app/i18n/zh-TW.ts +2 -0
- package/corpus/templates/analytics/app/i18n-data.ts +20 -0
- package/corpus/templates/analytics/app/pages/adhoc/explorer-dashboard/ChartCard.tsx +96 -3
- package/corpus/templates/analytics/app/pages/adhoc/explorer-dashboard/index.tsx +3 -1
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/SqlChartCard.tsx +61 -4
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/index.tsx +40 -2
- package/corpus/templates/analytics/changelog/2026-07-09-analytics-no-longer-forgets-successful-data-queries-when-a-l.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-09-dashboard-charts-keep-consistent-spacing-when-they-stack-ver.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-09-dashboard-charts-tables-and-extensions-can-be-selected-and-d.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-09-long-running-analytics-requests-now-recover-automatically-wh.md +6 -0
- package/corpus/templates/analytics/server/db/index.ts +6 -0
- package/corpus/templates/clips/desktop/src/hooks/useMeetingTranscription.ts +1 -7
- package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +102 -15
- package/corpus/templates/content/app/hooks/use-content-database.ts +52 -0
- package/corpus/templates/content/app/hooks/use-document-properties.ts +50 -6
- package/corpus/templates/content/app/i18n/zh-TW.ts +2 -0
- package/corpus/templates/content/app/i18n-data.ts +28 -0
- package/corpus/templates/content/changelog/2026-07-09-notion-conflict-warnings-no-longer-flash-during-normal-synce.md +6 -0
- package/corpus/templates/content/changelog/2026-07-09-property-menus-and-delete-confirmations-now-open-visibly-and.md +6 -0
- package/corpus/templates/content/server/lib/notion-sync.ts +130 -28
- package/corpus/templates/design/actions/add-breakpoint.ts +48 -68
- package/corpus/templates/design/actions/add-localhost-screens.ts +496 -61
- package/corpus/templates/design/actions/apply-component-prop-edit.ts +2 -18
- package/corpus/templates/design/actions/apply-design-token-edit.ts +51 -82
- package/corpus/templates/design/actions/apply-tweaks.ts +63 -41
- package/corpus/templates/design/actions/capture-design-state.ts +2 -2
- package/corpus/templates/design/actions/connect-localhost.ts +58 -12
- package/corpus/templates/design/actions/create-component.ts +2 -18
- package/corpus/templates/design/actions/create-design-branch.ts +38 -18
- package/corpus/templates/design/actions/create-fusion-app.ts +15 -11
- package/corpus/templates/design/actions/delete-file.ts +19 -34
- package/corpus/templates/design/actions/deploy-design-preview.ts +59 -27
- package/corpus/templates/design/actions/deploy-fusion-app.ts +22 -15
- package/corpus/templates/design/actions/export-pdf.ts +2 -1
- package/corpus/templates/design/actions/export-zip.ts +9 -3
- package/corpus/templates/design/actions/generate-design.ts +107 -70
- package/corpus/templates/design/actions/get-component-details.ts +2 -18
- package/corpus/templates/design/actions/get-design-branch-diff.ts +2 -3
- package/corpus/templates/design/actions/get-design-surface-index.ts +2 -18
- package/corpus/templates/design/actions/get-design.ts +2 -1
- package/corpus/templates/design/actions/grant-localhost-write-consent.ts +7 -1
- package/corpus/templates/design/actions/import-design-tokens.ts +114 -76
- package/corpus/templates/design/actions/index-components.ts +2 -18
- package/corpus/templates/design/actions/list-design-source-capabilities.ts +23 -28
- package/corpus/templates/design/actions/list-local-files.ts +9 -2
- package/corpus/templates/design/actions/migrate-board-objects-to-file.ts +82 -98
- package/corpus/templates/design/actions/open-component-source.ts +2 -18
- package/corpus/templates/design/actions/open-visual-edit.ts +19 -1
- package/corpus/templates/design/actions/present-design-variants.ts +70 -70
- package/corpus/templates/design/actions/preview-component-prop-edit.ts +4 -14
- package/corpus/templates/design/actions/read-local-file.ts +9 -2
- package/corpus/templates/design/actions/remove-breakpoint.ts +40 -61
- package/corpus/templates/design/actions/rename-screen.ts +425 -0
- package/corpus/templates/design/actions/request-localhost-write-consent.ts +12 -2
- package/corpus/templates/design/actions/revoke-localhost-write-consent.ts +9 -2
- package/corpus/templates/design/actions/sync-fusion-app.ts +56 -39
- package/corpus/templates/design/actions/update-design.ts +376 -61
- package/corpus/templates/design/actions/update-file.ts +455 -188
- package/corpus/templates/design/actions/write-local-file.ts +10 -2
- package/corpus/templates/design/app/components/design/DesignCanvas.tsx +685 -608
- package/corpus/templates/design/app/components/design/LayersPanel.tsx +43 -15
- package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +576 -2015
- package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +62 -14
- package/corpus/templates/design/app/components/design/bridge/embedded-wheel.bridge.ts +263 -25
- package/corpus/templates/design/app/components/design/design-canvas/annotation-submit.ts +34 -0
- package/corpus/templates/design/app/components/design/design-canvas/coordinate-transforms.ts +40 -0
- package/corpus/templates/design/app/components/design/design-canvas/creation.ts +47 -0
- package/corpus/templates/design/app/components/design/design-canvas/element-payload.ts +27 -0
- package/corpus/templates/design/app/components/design/design-canvas/embedded-frame.ts +62 -0
- package/corpus/templates/design/app/components/design/design-canvas/external-preview.ts +137 -0
- package/corpus/templates/design/app/components/design/design-canvas/file-drop.ts +10 -0
- package/corpus/templates/design/app/components/design/design-canvas/hit-test.ts +23 -0
- package/corpus/templates/design/app/components/design/design-canvas/iframe-events.ts +23 -0
- package/corpus/templates/design/app/components/design/design-canvas/iframe-pan.ts +177 -0
- package/corpus/templates/design/app/components/design/design-canvas/motion-types.ts +7 -0
- package/corpus/templates/design/app/components/design/design-canvas/pending-text-edit.ts +62 -0
- package/corpus/templates/design/app/components/design/index.ts +2 -2
- package/corpus/templates/design/app/components/design/multi-screen/canvas-tools.ts +100 -0
- package/corpus/templates/design/app/components/design/multi-screen/coordinate-transforms.ts +81 -0
- package/corpus/templates/design/app/components/design/multi-screen/cross-screen-drop.ts +23 -13
- package/corpus/templates/design/app/components/design/multi-screen/culling.ts +238 -19
- package/corpus/templates/design/app/components/design/multi-screen/draft-primitives.ts +424 -0
- package/corpus/templates/design/app/components/design/multi-screen/frame-geometry.ts +341 -0
- package/corpus/templates/design/app/components/design/multi-screen/iframe-targeting.ts +52 -0
- package/corpus/templates/design/app/components/design/multi-screen/overview-layout.ts +62 -0
- package/corpus/templates/design/app/components/design/multi-screen/primitive-drop-target.ts +283 -0
- package/corpus/templates/design/app/components/design/multi-screen/screen-content-cache.ts +272 -0
- package/corpus/templates/design/app/components/design/multi-screen/types.ts +5 -1
- package/corpus/templates/design/app/components/design/multi-screen/wheel-gesture-state.ts +12 -0
- package/corpus/templates/design/app/components/visual-editor/DrawOverlay.tsx +325 -84
- package/corpus/templates/design/app/hooks/use-navigation-state.ts +15 -1
- package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +119 -47
- package/corpus/templates/design/app/i18n/zh-TW.ts +9 -0
- package/corpus/templates/design/app/i18n-data.ts +223 -0
- package/corpus/templates/design/app/lib/design-save-outbox.ts +379 -0
- package/corpus/templates/design/app/lib/png-clipboard.ts +113 -0
- package/corpus/templates/design/app/pages/design-editor/canvas-primitives.ts +229 -0
- package/corpus/templates/design/app/pages/design-editor/code-layer-state.ts +918 -0
- package/corpus/templates/design/app/pages/design-editor/collab-sync.ts +43 -0
- package/corpus/templates/design/app/pages/design-editor/cross-screen-text-color.ts +336 -0
- package/corpus/templates/design/app/pages/design-editor/data-operations.ts +270 -0
- package/corpus/templates/design/app/pages/design-editor/dom-guards.ts +24 -0
- package/corpus/templates/design/app/pages/design-editor/dom-utils.ts +11 -0
- package/corpus/templates/design/app/pages/design-editor/editor-session.ts +20 -0
- package/corpus/templates/design/app/pages/design-editor/editor-state.ts +408 -0
- package/corpus/templates/design/app/pages/design-editor/export-capture.ts +115 -0
- package/corpus/templates/design/app/pages/design-editor/geometry-persistence.ts +93 -0
- package/corpus/templates/design/app/pages/design-editor/history.ts +169 -0
- package/corpus/templates/design/app/pages/design-editor/layout-operations.ts +185 -0
- package/corpus/templates/design/app/pages/design-editor/motion-state.ts +309 -0
- package/corpus/templates/design/app/pages/design-editor/overview-camera.ts +438 -0
- package/corpus/templates/design/app/pages/design-editor/pending-edits.ts +510 -0
- package/corpus/templates/design/app/pages/design-editor/portable-style.ts +115 -0
- package/corpus/templates/design/app/pages/design-editor/selection-state.ts +337 -0
- package/corpus/templates/design/app/pages/design-editor/style-utils.ts +5 -0
- package/corpus/templates/design/app/pages/design-editor/tool-state.ts +92 -0
- package/corpus/templates/design/app/pages/design-editor/tweak-save.ts +87 -0
- package/corpus/templates/design/app/pages/design-editor/types.ts +36 -0
- package/corpus/templates/design/app/routes/design.$id.tsx +11 -1
- package/corpus/templates/design/changelog/2026-07-09-design-edits-canvas-positions-tools-and-tab-context-now-pers.md +6 -0
- package/corpus/templates/design/changelog/2026-07-09-figma-style-shortcuts-drawing-annotations-and-cross-screen-d.md +6 -0
- package/corpus/templates/design/changelog/2026-07-09-hand-and-scale-tools-now-keep-their-active-toolbar-identity-.md +6 -0
- package/corpus/templates/design/changelog/2026-07-09-large-multi-screen-designs-now-stay-responsive-during-long-c.md +6 -0
- package/corpus/templates/design/changelog/2026-07-09-layer-drags-now-land-exactly-where-the-insertion-indicator-s.md +6 -0
- package/corpus/templates/design/server/db/index.ts +2 -52
- package/corpus/templates/design/server/db/schema.ts +16 -0
- package/corpus/templates/design/server/lib/design-data-access.ts +70 -0
- package/corpus/templates/design/server/lib/design-data-mutation.ts +249 -0
- package/corpus/templates/design/server/lib/fusion-screens.ts +89 -76
- package/corpus/templates/design/server/lib/import-design-files.ts +76 -35
- package/corpus/templates/design/server/lib/verify-write-grant.ts +7 -2
- package/corpus/templates/design/server/plugins/db.ts +19 -0
- package/corpus/templates/design/server/source-workspace.ts +12 -13
- package/corpus/templates/design/shared/resolve-tweaks.ts +15 -0
- package/corpus/templates/design/shared/screen-rename.ts +42 -0
- package/corpus/templates/design/shared/source-mode.ts +84 -4
- package/dist/agent/engine/builder-engine.d.ts +1 -1
- package/dist/agent/engine/builder-engine.d.ts.map +1 -1
- package/dist/agent/engine/builtin.js +1 -1
- package/dist/agent/engine/builtin.js.map +1 -1
- package/dist/agent/model-config.d.ts +9 -9
- package/dist/agent/model-config.d.ts.map +1 -1
- package/dist/agent/model-config.js +30 -30
- package/dist/agent/model-config.js.map +1 -1
- package/dist/agent/production-agent.d.ts +60 -1
- package/dist/agent/production-agent.d.ts.map +1 -1
- package/dist/agent/production-agent.js +106 -7
- package/dist/agent/production-agent.js.map +1 -1
- package/dist/cli/design-connect.d.ts +30 -1
- package/dist/cli/design-connect.d.ts.map +1 -1
- package/dist/cli/design-connect.js +359 -75
- package/dist/cli/design-connect.js.map +1 -1
- package/dist/cli/recap.js +2 -2
- package/dist/cli/recap.js.map +1 -1
- package/dist/cli/sync-builder-starter-manifest.js +1 -1
- package/dist/cli/sync-builder-starter-manifest.js.map +1 -1
- package/dist/client/AgentPanel.d.ts.map +1 -1
- package/dist/client/AgentPanel.js +6 -1
- package/dist/client/AgentPanel.js.map +1 -1
- package/dist/client/agent-chat-adapter.d.ts.map +1 -1
- package/dist/client/agent-chat-adapter.js +10 -5
- package/dist/client/agent-chat-adapter.js.map +1 -1
- package/dist/client/composer/TiptapComposer.js +1 -1
- package/dist/client/composer/TiptapComposer.js.map +1 -1
- package/dist/client/index.d.ts +1 -1
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +1 -1
- package/dist/client/index.js.map +1 -1
- package/dist/client/org/TeamPage.d.ts.map +1 -1
- package/dist/client/org/TeamPage.js +6 -5
- package/dist/client/org/TeamPage.js.map +1 -1
- package/dist/client/use-action.d.ts +32 -0
- package/dist/client/use-action.d.ts.map +1 -1
- package/dist/client/use-action.js +68 -1
- package/dist/client/use-action.js.map +1 -1
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/awareness.d.ts.map +1 -1
- package/dist/collab/routes.d.ts +1 -1
- package/dist/db/client.d.ts +3 -0
- package/dist/db/client.d.ts.map +1 -1
- package/dist/db/client.js +7 -2
- package/dist/db/client.js.map +1 -1
- package/dist/db/create-get-db.d.ts.map +1 -1
- package/dist/db/create-get-db.js +2 -2
- package/dist/db/create-get-db.js.map +1 -1
- package/dist/file-upload/actions/upload-image.d.ts +1 -1
- package/dist/notifications/routes.d.ts +1 -1
- package/dist/observability/routes.d.ts +5 -5
- package/dist/progress/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +1 -1
- package/dist/scripts/agent-engines/manage-agent-engine.js +1 -1
- package/dist/scripts/agent-engines/manage-agent-engine.js.map +1 -1
- package/dist/scripts/agent-engines/set-agent-engine.js +1 -1
- package/dist/scripts/agent-engines/set-agent-engine.js.map +1 -1
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/transcribe-voice.js +1 -1
- package/dist/server/transcribe-voice.js.map +1 -1
- package/dist/usage/store.d.ts.map +1 -1
- package/dist/usage/store.js +13 -4
- package/dist/usage/store.js.map +1 -1
- package/dist/vite/client.d.ts.map +1 -1
- package/dist/vite/client.js +36 -1
- package/dist/vite/client.js.map +1 -1
- package/docs/content/a2a-protocol.mdx +12 -0
- package/docs/content/locales/ar-SA/a2a-protocol.mdx +12 -0
- package/docs/content/locales/ar-SA/pr-visual-recap.mdx +3 -3
- package/docs/content/locales/ar-SA/pure-agent-apps.mdx +4 -0
- package/docs/content/locales/de-DE/a2a-protocol.mdx +12 -0
- package/docs/content/locales/de-DE/pr-visual-recap.mdx +3 -3
- package/docs/content/locales/de-DE/pure-agent-apps.mdx +4 -0
- package/docs/content/locales/es-ES/a2a-protocol.mdx +12 -0
- package/docs/content/locales/es-ES/pr-visual-recap.mdx +3 -3
- package/docs/content/locales/es-ES/pure-agent-apps.mdx +4 -0
- package/docs/content/locales/fr-FR/a2a-protocol.mdx +12 -0
- package/docs/content/locales/fr-FR/pr-visual-recap.mdx +3 -3
- package/docs/content/locales/fr-FR/pure-agent-apps.mdx +4 -0
- package/docs/content/locales/hi-IN/a2a-protocol.mdx +12 -0
- package/docs/content/locales/hi-IN/pr-visual-recap.mdx +3 -3
- package/docs/content/locales/hi-IN/pure-agent-apps.mdx +4 -0
- package/docs/content/locales/ja-JP/a2a-protocol.mdx +12 -0
- package/docs/content/locales/ja-JP/pr-visual-recap.mdx +3 -3
- package/docs/content/locales/ja-JP/pure-agent-apps.mdx +4 -0
- package/docs/content/locales/ko-KR/a2a-protocol.mdx +12 -0
- package/docs/content/locales/ko-KR/pr-visual-recap.mdx +3 -3
- package/docs/content/locales/ko-KR/pure-agent-apps.mdx +4 -0
- package/docs/content/locales/pt-BR/a2a-protocol.mdx +12 -0
- package/docs/content/locales/pt-BR/pr-visual-recap.mdx +3 -3
- package/docs/content/locales/pt-BR/pure-agent-apps.mdx +4 -0
- package/docs/content/locales/zh-CN/a2a-protocol.mdx +12 -0
- package/docs/content/locales/zh-CN/pr-visual-recap.mdx +3 -3
- package/docs/content/locales/zh-CN/pure-agent-apps.mdx +4 -0
- package/docs/content/locales/zh-TW/a2a-protocol.mdx +12 -0
- package/docs/content/locales/zh-TW/pr-visual-recap.mdx +3 -3
- package/docs/content/locales/zh-TW/pure-agent-apps.mdx +4 -0
- package/docs/content/pr-visual-recap.mdx +3 -3
- package/docs/content/pure-agent-apps.mdx +22 -0
- 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
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
"
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
205
|
-
|
|
240
|
+
sameOperationSource &&
|
|
241
|
+
requestedOperationRevision !== null &&
|
|
242
|
+
requestedOperationRevision <= persistedFile.contentOperationRevision!
|
|
206
243
|
) {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
247
|
-
|
|
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
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
eq(
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
.
|
|
287
|
-
.
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
.where(updateWhere);
|
|
505
|
+
if (requiresContentCas && rowsAffected(updateResult) === 0) {
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
311
508
|
|
|
312
|
-
if (
|
|
313
|
-
const [
|
|
314
|
-
.select({
|
|
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 (
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
if (
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
});
|