@vgai/editor 0.2.0 → 0.4.0-canary.20260715.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +24 -0
- package/dist/assets/axe-C7YBtOuM.js +30 -0
- package/dist/assets/browserAll-DDtGGE48.js +1 -0
- package/dist/assets/create-runtime-DbTlBlKG.js +1 -0
- package/dist/assets/{game-BI111o5f.js → game-BE0r7DMI.js} +3 -3
- package/dist/assets/game-BdTHR2_X.js +1 -0
- package/dist/assets/{game-pt_1lQLv.js → game-C3iQGSqo.js} +1 -1
- package/dist/assets/game-CcHfYxjP.js +1 -0
- package/dist/assets/game-Cqzj_lpD.js +1 -0
- package/dist/assets/game-DSZdeCHu.js +1 -0
- package/dist/assets/game-Dch9sAPc.js +1 -0
- package/dist/assets/{game-xsOho_wg.js → game-SAFn90pA.js} +3 -3
- package/dist/assets/game-loop-DMIgSMV-.js +1 -0
- package/dist/assets/{game-source-CvidN7AB.js → game-source-Dk83w26t.js} +1 -1
- package/dist/assets/{index-Bp5cecD6.js → index-4idIsHHJ.js} +2 -2
- package/dist/assets/{index-DL7RcQ0n.js → index-C-LhOTBN.js} +1 -1
- package/dist/assets/{index-Cs8xwv3e.js → index-CDkxceqE.js} +1 -1
- package/dist/assets/index-CQEfi217.js +15412 -0
- package/dist/assets/index-Dvi-V0NE.css +1 -0
- package/dist/assets/ingest-siblings-s0P5I1Ub.js +1 -0
- package/dist/assets/mount-manifest-DXMOlF79.js +1 -0
- package/dist/assets/react-18-C9eHn8x1.js +1 -0
- package/dist/assets/webworkerAll-Fn0e-ouF.js +1 -0
- package/dist/assets/world-documents-GM0a9Kv7.js +1 -0
- package/dist/index.html +15 -13
- package/dist/ingest/racing-game/vgai.game.json +5 -4
- package/dist/ingest/simcity/vgai.game.json +5 -4
- package/dist/ingest/tanks/vgai.game.json +5 -4
- package/dist/project-scripts.bundle.js +3917 -0
- package/dist/scenes/untitled.vscn.json +2 -0
- package/dist-server/packaged.mjs +7697 -3435
- package/package.json +31 -4
- package/scripts/probe-reporter.ts +349 -0
- package/scripts/run-probe.ts +2260 -0
- package/server/asset-catalog-audit.ts +268 -0
- package/server/asset-catalog-v2.ts +146 -0
- package/server/asset-library-routes.ts +276 -58
- package/server/canonical-path.ts +1 -1
- package/server/dev.ts +148 -44
- package/server/editor-server.ts +888 -61
- package/server/editor-sse.ts +115 -4
- package/server/harness-chat-service.ts +796 -0
- package/server/local-asset-catalog.ts +157 -22
- package/server/packaged.ts +51 -21
- package/server/process-shutdown.ts +136 -0
- package/server/prod.ts +9 -6
- package/server/project-hmr-files.ts +41 -0
- package/server/project-operations.ts +343 -0
- package/server/project-optimize-deps-entries.ts +4 -4
- package/server/project-output-writer.ts +156 -0
- package/server/project-validation.ts +140 -0
- package/server/project-view.ts +8 -50
- package/server/server-utils.ts +75 -10
- package/server/session-registry.ts +12 -0
- package/src/EditorContext.tsx +107 -3
- package/src/action-registry.ts +115 -17
- package/src/adapter-resolver.ts +213 -478
- package/src/asset-events.ts +64 -1
- package/src/asset-preview.ts +906 -0
- package/src/asset-selection.ts +121 -0
- package/src/asset-workflow/asset-capabilities.ts +259 -0
- package/src/asset-workflow/asset-editor-errors.ts +46 -0
- package/src/asset-workflow/asset-import-pipeline.ts +176 -0
- package/src/asset-workflow/asset-library-query.ts +107 -0
- package/src/asset-workflow/asset-types.ts +192 -0
- package/src/asset-workflow/asset-workflow-quality.ts +65 -0
- package/src/asset-workflow/import-contract.ts +113 -0
- package/src/asset-workflow/model-conversion.ts +126 -0
- package/src/asset-workflow/model-inspection.ts +185 -0
- package/src/asset-workflow/project-asset-commands.ts +23 -0
- package/src/asset-workflow/project-asset-health.ts +153 -0
- package/src/asset-workflow/project-asset-operations.ts +119 -0
- package/src/asset-workflow/thumbnail-system.ts +172 -0
- package/src/authoring/active-systems.ts +15 -0
- package/src/authoring/boundary-authoring-adapter.ts +16 -5
- package/src/authoring/composite-authoring-adapter.ts +305 -124
- package/src/authoring/design-time-layers.ts +300 -117
- package/src/authoring/edit-mode-authoring.ts +278 -293
- package/src/authoring/embed-only-adapter.ts +4 -2
- package/src/authoring/first-party-hierarchy-menu.ts +7 -2
- package/src/authoring/first-party-inspector/AddSectionMenu.tsx +5 -12
- package/src/authoring/first-party-inspector/EnvironmentSection.tsx +53 -126
- package/src/authoring/first-party-inspector/HeaderSection.tsx +54 -65
- package/src/authoring/first-party-inspector/is-first-party-selection.ts +30 -11
- package/src/authoring/first-party-inspector/register.tsx +13 -2
- package/src/authoring/ingest-authoring-adapter.ts +142 -45
- package/src/authoring/mount-failure-report.ts +5 -5
- package/src/authoring/provenance.ts +41 -0
- package/src/authoring/react-dom-authoring-adapter.ts +92 -92
- package/src/authoring/react-inspector-section.tsx +489 -51
- package/src/authoring/react-world-authoring-adapter.ts +541 -306
- package/src/authoring/story-overlay.ts +1 -1
- package/src/authoring/story-types.ts +11 -0
- package/src/authoring/vgai-scene-authoring-adapter.ts +54 -25
- package/src/authoring/viewport-tool-context.ts +78 -0
- package/src/authoring/world-canvas-viewport-state.ts +35 -0
- package/src/authoring/world-hidden-viewport.ts +186 -0
- package/src/authoring/world-pan-state.ts +33 -17
- package/src/authoring/world-session-state.ts +1 -1
- package/src/authoring/world2d-authoring-adapter.ts +109 -52
- package/src/availability-tick.ts +56 -0
- package/src/browser-play-entry.ts +5 -5
- package/src/browser-project-scripts.ts +1 -0
- package/src/build-identity.ts +15 -0
- package/src/command-listener.ts +893 -19
- package/src/components/AlignToolbar.tsx +74 -63
- package/src/components/AppRoot.tsx +193 -26
- package/src/components/ApplicationMenus.tsx +272 -0
- package/src/components/AssetBrowser.tsx +1450 -293
- package/src/components/AssetEditorShell.tsx +141 -0
- package/src/components/AssetImportDetails.tsx +218 -0
- package/src/components/AssetLibraryPanel.tsx +14 -0
- package/src/components/CameraInfo.tsx +11 -20
- package/src/components/CenterDocuments.tsx +585 -0
- package/src/components/CommandPalette.tsx +75 -30
- package/src/components/ConsolePanel.css +55 -0
- package/src/components/ConsolePanel.tsx +57 -112
- package/src/components/DebugConsolePanel.tsx +461 -0
- package/src/components/DebuggerPanel.tsx +165 -0
- package/src/components/DefaultEditorLayout.tsx +167 -231
- package/src/components/GameHierarchy.tsx +873 -290
- package/src/components/GamePerformanceOverlay.tsx +46 -0
- package/src/components/HarnessChatPanel.tsx +2352 -0
- package/src/components/HistoryPanel.tsx +226 -0
- package/src/components/Inspector.tsx +153 -99
- package/src/components/InspectorToolSection.tsx +6 -4
- package/src/components/MountFailureBanner.tsx +31 -48
- package/src/components/NetworkSection.tsx +25 -14
- package/src/components/OnlineAssetBrowser.tsx +794 -343
- package/src/components/OverlayReportBanner.tsx +38 -64
- package/src/components/PerformancePanel.tsx +206 -0
- package/src/components/PlayBar.tsx +93 -263
- package/src/components/PrefabSaveDialog.tsx +55 -127
- package/src/components/ProjectHeader.css +60 -0
- package/src/components/ProjectHeader.tsx +170 -105
- package/src/components/ProjectScreen.tsx +246 -184
- package/src/components/ReactCanvasControls.tsx +185 -0
- package/src/components/ResolutionPicker.tsx +85 -0
- package/src/components/RootActivateDialog.tsx +39 -68
- package/src/components/SaveDialog.tsx +55 -127
- package/src/components/SaveStatus.tsx +11 -29
- package/src/components/StartupLoadingScreen.tsx +96 -0
- package/src/components/StateWatchPanel.tsx +302 -0
- package/src/components/StatsOverlay.tsx +12 -5
- package/src/components/StatusBar.tsx +64 -0
- package/src/components/ThemeManagerDialog.css +89 -0
- package/src/components/ThemeManagerDialog.tsx +371 -0
- package/src/components/ToolHost.tsx +26 -15
- package/src/components/Toolbar.tsx +117 -214
- package/src/components/VgaiVoxelLogo.tsx +89 -0
- package/src/components/ViewportOverlay.tsx +97 -172
- package/src/components/ViewportPanel.tsx +130 -31
- package/src/components/WorkspaceDock.tsx +891 -0
- package/src/components/WorldSelectionOverlay.tsx +51 -63
- package/src/components/WorldTextEditor.tsx +9 -5
- package/src/components/asset-documents.tsx +390 -0
- package/src/components/asset-editor-persistence.ts +44 -0
- package/src/components/asset-selection-section.tsx +338 -0
- package/src/components/asset-viewers/AudioViewer.tsx +73 -21
- package/src/components/asset-viewers/GenericJsonViewer.tsx +26 -11
- package/src/components/asset-viewers/ImageViewer.tsx +43 -11
- package/src/components/asset-viewers/InputMapViewer.tsx +512 -134
- package/src/components/asset-viewers/MaterialAssetEditor.tsx +540 -0
- package/src/components/asset-viewers/ModelViewer.tsx +805 -121
- package/src/components/asset-viewers/OnlineAssetDetail.tsx +177 -60
- package/src/components/asset-viewers/SourceAssetViewer.tsx +121 -0
- package/src/components/asset-workflow.css +506 -0
- package/src/components/build-documents.tsx +696 -0
- package/src/components/core-utilities.tsx +75 -0
- package/src/components/data-documents.tsx +364 -0
- package/src/components/{DataPanel.tsx → data-editor.tsx} +138 -288
- package/src/components/debugger-panel-state.ts +57 -0
- package/src/components/inspector-widgets/AlignmentGrid.tsx +8 -14
- package/src/components/inspector-widgets/BorderEditor.tsx +41 -33
- package/src/components/inspector-widgets/ColorPicker.tsx +39 -39
- package/src/components/inspector-widgets/FontPicker.tsx +13 -21
- package/src/components/inspector-widgets/GradientEditor.tsx +11 -19
- package/src/components/inspector-widgets/ScrubbableInput.tsx +6 -3
- package/src/components/inspector-widgets/ShadowEditor.tsx +20 -33
- package/src/components/inspector-widgets/shared.tsx +90 -49
- package/src/components/inspectors/AnimationSection.tsx +39 -40
- package/src/components/inspectors/AudioSection.tsx +28 -39
- package/src/components/inspectors/BoneAttachmentSection.tsx +19 -24
- package/src/components/inspectors/CameraSection.tsx +14 -23
- package/src/components/inspectors/ComponentsSection.tsx +39 -43
- package/src/components/inspectors/JointsSection.tsx +34 -43
- package/src/components/inspectors/LightSection.tsx +90 -35
- package/src/components/inspectors/MaterialSection.tsx +137 -174
- package/src/components/inspectors/MeshSection.tsx +9 -17
- package/src/components/inspectors/NavigationSection.tsx +8 -15
- package/src/components/inspectors/ParticleSection.tsx +91 -76
- package/src/components/inspectors/PhysicsSection.tsx +42 -46
- package/src/components/inspectors/PostProcessingSection.tsx +28 -36
- package/src/components/inspectors/RenderingSection.tsx +12 -20
- package/src/components/inspectors/ShadowSection.tsx +7 -8
- package/src/components/inspectors/SplineSection.tsx +31 -42
- package/src/components/inspectors/TransformSection.tsx +14 -22
- package/src/components/patterns/Dialog.tsx +95 -0
- package/src/components/patterns/Fields.tsx +44 -0
- package/src/components/patterns/List.tsx +25 -0
- package/src/components/patterns/StateSurface.tsx +40 -0
- package/src/components/patterns/Surfaces.tsx +115 -0
- package/src/components/patterns/Tabs.tsx +80 -0
- package/src/components/patterns/Toolbar.tsx +51 -0
- package/src/components/patterns/Tree.tsx +72 -0
- package/src/components/primitives/Button.tsx +46 -0
- package/src/components/primitives/ColorInput.tsx +11 -47
- package/src/components/primitives/EditorIcon.tsx +99 -0
- package/src/components/primitives/FormControls.tsx +71 -0
- package/src/components/primitives/JsonInput.tsx +15 -2
- package/src/components/primitives/Layout.tsx +100 -0
- package/src/components/primitives/Menu.tsx +105 -0
- package/src/components/primitives/NumberInput.tsx +8 -35
- package/src/components/primitives/Panel.tsx +49 -44
- package/src/components/primitives/SectionHeader.tsx +10 -28
- package/src/components/primitives/Text.tsx +46 -0
- package/src/components/primitives/Tooltip.tsx +20 -12
- package/src/components/primitives/Vec3Input.tsx +3 -0
- package/src/components/primitives/banner-tones.ts +33 -0
- package/src/components/primitives/editor-icons.ts +102 -0
- package/src/components/primitives/panel-header-styles.ts +30 -0
- package/src/components/project-operation-documents.tsx +226 -0
- package/src/components/status-contributions.tsx +175 -0
- package/src/components/story-documents-register.ts +15 -0
- package/src/components/story-documents.tsx +1185 -0
- package/src/components/tool-documents.tsx +110 -0
- package/src/components/workspace-dock.css +293 -0
- package/src/components/workspace-history.ts +33 -0
- package/src/components/world-documents.tsx +145 -0
- package/src/components/xstate-documents.tsx +209 -0
- package/src/composite-screenshot.ts +248 -0
- package/src/design-system/index.ts +70 -0
- package/src/design-system-stories/Foundation.stories.tsx +183 -0
- package/src/design-system-stories/Patterns.stories.tsx +251 -0
- package/src/design-system-stories/Workspace.stories.tsx +538 -0
- package/src/doctor/report.ts +57 -13
- package/src/editor-api.ts +295 -31
- package/src/editor-compatibility.ts +200 -0
- package/src/editor-console.ts +35 -17
- package/src/editor-hotkeys.ts +85 -33
- package/src/editor-store.ts +538 -233
- package/src/editor-viewport.ts +160 -34
- package/src/entity-factory.ts +14 -3
- package/src/gizmo-registry.ts +21 -5
- package/src/harness-chat-editor-context.ts +37 -0
- package/src/harness-chat-indicator.ts +68 -0
- package/src/harness-chat-layout.ts +53 -0
- package/src/harness-chat-lifecycle.ts +170 -0
- package/src/harness-chat-reconnect.ts +85 -0
- package/src/harness-chat-types.ts +409 -0
- package/src/hierarchy-drop.ts +50 -0
- package/src/hierarchy-expansion-state.ts +100 -0
- package/src/hierarchy-menu-registry.ts +1 -1
- package/src/hierarchy-projection.ts +76 -0
- package/src/history/editor-session.ts +21 -0
- package/src/history/history-commands.ts +147 -0
- package/src/history/history-service.ts +904 -0
- package/src/history/json-history-resource.ts +165 -0
- package/src/history/persistence-coordinator.ts +35 -0
- package/src/history/project-file-history.ts +266 -0
- package/src/history/project-root-history-backends.ts +53 -0
- package/src/history/resource-registry.ts +209 -0
- package/src/history/snapshot-store.ts +108 -0
- package/src/history/source-history-backend.ts +374 -0
- package/src/history/types.ts +100 -0
- package/src/hmr-registration-group.ts +67 -0
- package/src/hmr-stable-react-context.ts +23 -0
- package/src/hosted-example.ts +12 -2
- package/src/hotkeys.ts +89 -18
- package/src/ingest/discovery-react.ts +9 -10
- package/src/ingest/discovery.ts +4 -4
- package/src/ingest/discovery2d.ts +5 -5
- package/src/ingest/games/bloom-composer/vgai.game.json +3 -3
- package/src/ingest/games/embed-bundle/vgai.game.json +3 -3
- package/src/ingest/games/games-fps/vgai.game.json +3 -3
- package/src/ingest/games/iframe-reachable/vgai.game.json +3 -3
- package/src/ingest/games/interactive-cubes/vgai.game.json +3 -3
- package/src/ingest/games/mismatched-three/vgai.game.json +3 -3
- package/src/ingest/games/own-three-bundled/vgai.game.json +3 -3
- package/src/ingest/games/pointerlock-maze/vgai.game.json +7 -4
- package/src/ingest/games-2d/bubbo-bubbo/vgai.game.json +3 -3
- package/src/ingest/games-2d/bunnymark/vgai.game.json +3 -3
- package/src/ingest/games-2d/flappy/vgai.game.json +3 -3
- package/src/ingest/games-2d/flappy-pixi/vgai.game.json +3 -3
- package/src/ingest/games-2d/puzzling-potions/vgai.game.json +3 -3
- package/src/ingest/games-2d/tilemap-camera/vgai.game.json +3 -3
- package/src/ingest/games-react/react-rpg/vgai.game.json +3 -3
- package/src/ingest/types.ts +1 -1
- package/src/ingest-mode.ts +47 -52
- package/src/ingest-siblings.ts +23 -33
- package/src/initial-scene.ts +1 -2
- package/src/main.tsx +16 -8
- package/src/manifest-project.ts +10 -12
- package/src/model-thumbnail.ts +261 -26
- package/src/module-mode.ts +44 -50
- package/src/play-log-events.ts +26 -0
- package/src/play-mode.ts +495 -401
- package/src/play-scene-adoption.ts +23 -0
- package/src/playground/main.ts +68 -24
- package/src/playwright-shim.ts +473 -0
- package/src/project-manager.ts +90 -10
- package/src/project-operations.ts +112 -0
- package/src/project-story-discovery.ts +25 -0
- package/src/project-tool-discovery.ts +43 -0
- package/src/scene-serializer.ts +0 -6
- package/src/scene-sync.ts +7 -2
- package/src/script-loader.ts +3 -3
- package/src/skeleton-helper.ts +57 -0
- package/src/storage/seed.ts +68 -8
- package/src/stories/StoryPreviewMount.tsx +265 -0
- package/src/stories/action-stories.fixture.tsx +44 -0
- package/src/stories/button-stories.fixture.tsx +50 -0
- package/src/stories/compose-project-stories.ts +182 -0
- package/src/stories/loader-stories.fixture.tsx +53 -0
- package/src/stories/story-discovery.ts +130 -0
- package/src/stories/story-registry.ts +146 -0
- package/src/theme-css.ts +1049 -0
- package/src/theme-library.ts +238 -0
- package/src/theme-preference.ts +130 -0
- package/src/theme.ts +527 -0
- package/src/tool-loader.ts +80 -24
- package/src/ui-source/inspect.ts +27 -4
- package/src/ui-source/source-write-backend.ts +83 -22
- package/src/widgets/index.ts +9 -18
- package/src/workspace-aux-commands.ts +11 -0
- package/src/workspace-dock-controller.ts +64 -0
- package/src/workspace-document-ids.ts +5 -0
- package/src/workspace-document-registry.ts +367 -0
- package/src/workspace-state-persistence.ts +455 -0
- package/src/workspace-status-registry.ts +110 -0
- package/src/workspace-utility-commands.ts +49 -0
- package/src/workspace-utility-registry.ts +161 -0
- package/src/world-document-routing.ts +16 -0
- package/src/xstate-inspect/XStateMachineInspector.tsx +544 -0
- package/src/xstate-inspect/character-animation-machine.fixture.ts +74 -0
- package/src/xstate-inspect/use-live-actor-state.ts +39 -0
- package/src/xstate-inspect/xstate-graph.ts +265 -0
- package/src/xstate-inspect/xstate-layout.ts +76 -0
- package/src/z-index.ts +26 -0
- package/template/.claude/skills/editor/SKILL.md +129 -90
- package/template/AGENTS.md +1110 -0
- package/template/CLAUDE.md +1 -512
- package/template/IDIOMS.md +50 -0
- package/template/check-idioms.ts +706 -0
- package/template/manifest-entry-modules-plugin.ts +53 -0
- package/template/package.json +13 -4
- package/template/public/scenes/default.scn2d.json +6 -0
- package/template/public/scenes/default.vscn.json +7 -1
- package/template/public/scenes/untitled.vscn.json +10 -0
- package/template/scripts/playtest.ts +40 -0
- package/template/server/loopback.ts +75 -0
- package/template/server/rooms/arena-room.ts +69 -1
- package/template/server/rooms/game-room.ts +68 -1
- package/template/src/data/README.md +9 -4
- package/template/src/data/data-ref.ts +35 -0
- package/template/src/data/tuning.schema.ts +1 -1
- package/template/src/main.ts +41 -62
- package/template/src/manifest-entry-modules.d.ts +3 -0
- package/template/src/operations/README.md +88 -0
- package/template/src/runtime/pixi-adapter.ts +24 -0
- package/template/src/scripts/components/data-spinner.ts +43 -2
- package/template/src/scripts/components/spin-lap-math.ts +27 -0
- package/template/src/tools/README.md +5 -3
- package/template/src/tools/example.tool.tsx +13 -7
- package/template/src/ui/Button.stories.tsx +66 -0
- package/template/src/ui/RebindPanel.tsx +2 -6
- package/template/src/ui/game.tsx +17 -0
- package/template/tests/acceptance/example.spec.ts +64 -0
- package/template/tests/logic/example.test.ts +31 -0
- package/template/tests/logic/room.test.ts +100 -0
- package/template/tests/tsconfig.json +30 -0
- package/template/validate-manifest.ts +5 -5
- package/template/validate-scenes.ts +50 -18
- package/template/vgai.game.json +20 -13
- package/template/vite.config.ts +77 -16
- package/vite-plugin-ui-oid.ts +261 -17
- package/dist/assets/CCDIKSolver-Dz-ysxZF.js +0 -1
- package/dist/assets/SelectionBox-BXsv_6aH.js +0 -1
- package/dist/assets/browser-project-scripts-DOru4L1x.js +0 -2
- package/dist/assets/browserAll-CEkWYlFk.js +0 -1
- package/dist/assets/create-runtime-DYIWr3va.js +0 -1
- package/dist/assets/data-asset-Bb9sIM7i.js +0 -5
- package/dist/assets/game-8SDZPPgH.js +0 -1
- package/dist/assets/game-CevorxyV.js +0 -1
- package/dist/assets/game-Di7XlywD.js +0 -1
- package/dist/assets/game-DjnCaw8R.js +0 -1
- package/dist/assets/game-Q9A0aXSV.js +0 -1
- package/dist/assets/index-CPVV6pjw.js +0 -1
- package/dist/assets/index-CpOnYyeD.js +0 -213
- package/dist/assets/index-Dr8H_6Rl.js +0 -1
- package/dist/assets/index-_bCaipUk.js +0 -13650
- package/dist/assets/ingest-siblings-Dz8eEqrV.js +0 -1
- package/dist/assets/mount-manifest-Bhk0ohsl.js +0 -1
- package/dist/assets/scene-query-BivAHhJu.js +0 -1
- package/dist/assets/webworkerAll-BLrH0F6F.js +0 -1
- package/dist/assets/xstate-animation-binding-1NGLmw6y.js +0 -1
- package/dist/assets/xstate.esm-BgUD33nl.js +0 -3
- package/src/authoring/scene-ui-layer.ts +0 -485
- package/src/authoring/ui-authoring-adapter.ts +0 -325
- package/src/authoring/ui-hierarchy-menu.ts +0 -40
- package/src/authoring/ui-inspector-sections.tsx +0 -156
- package/src/authoring/undo-timeline.ts +0 -145
- package/src/components/BottomPanel.tsx +0 -326
- package/src/components/BuildPanel.tsx +0 -455
- package/src/components/FolderTree.tsx +0 -179
- package/src/components/RightPanel.tsx +0 -166
- package/src/components/WorldContextMenu.tsx +0 -257
- package/src/components/asset-viewers/AssetViewer.tsx +0 -38
- package/src/ingest/games-react/react-rpg/src/ui/game-state.tsx +0 -129
- package/src/scene-ui-mode/active-scene-ui.ts +0 -58
- package/src/scene-ui-mode/scene-ui-palette-panel.tsx +0 -152
- package/src/scene-ui-mode/scene-ui-timeline-tab.tsx +0 -29
- package/src/scene-ui-mode/ui-prefab-dialog-host.tsx +0 -74
- package/src/scene-ui-mode/ui-prefab-hierarchy-menu.ts +0 -49
- package/src/scene-ui-mode/ui-prefab-io.ts +0 -76
- package/src/ui-editor/inspector.tsx +0 -563
- package/src/ui-editor/node-ops.ts +0 -266
- package/src/ui-editor/overlay.tsx +0 -380
- package/src/ui-editor/palette.tsx +0 -114
- package/src/ui-editor/react-store.ts +0 -78
- package/src/ui-editor/timeline-panel.tsx +0 -443
- package/src/ui-editor/ui-edit-store.ts +0 -552
- package/src/ui-editor/ui-prefab.ts +0 -95
- package/template/project.json +0 -9
- package/template/src/scripts/main.ts +0 -94
- package/template/src/ui/DefaultHud.tsx +0 -40
- package/template/src/ui/game-state.tsx +0 -37
- package/template/src/ui/mount-default-hud.tsx +0 -19
- package/template/src/ui/mount-ui.ts +0 -15
- package/vite-plugin-react-world-provider.ts +0 -81
|
@@ -0,0 +1,904 @@
|
|
|
1
|
+
import { PersistenceCoordinator } from './persistence-coordinator';
|
|
2
|
+
import { ResourceRegistry } from './resource-registry';
|
|
3
|
+
import { SnapshotStore } from './snapshot-store';
|
|
4
|
+
import type {
|
|
5
|
+
HistoryError,
|
|
6
|
+
HistoryStatus,
|
|
7
|
+
HistoryTransaction,
|
|
8
|
+
ResourceChange,
|
|
9
|
+
ResourceDriver,
|
|
10
|
+
ResourceKey,
|
|
11
|
+
ResourceSnapshot,
|
|
12
|
+
SnapshotRef,
|
|
13
|
+
} from './types';
|
|
14
|
+
|
|
15
|
+
const DEFAULT_MAX_TRANSACTIONS = 200;
|
|
16
|
+
const DEFAULT_MAX_BYTES = 64 * 1024 * 1024;
|
|
17
|
+
const DEFAULT_MAX_TRANSACTION_BYTES = 16 * 1024 * 1024;
|
|
18
|
+
|
|
19
|
+
export interface HistoryLimits {
|
|
20
|
+
readonly maxTransactions: number;
|
|
21
|
+
readonly maxBytes: number;
|
|
22
|
+
readonly maxTransactionBytes: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface TransactionOptions {
|
|
26
|
+
readonly label: string;
|
|
27
|
+
readonly detail?: string;
|
|
28
|
+
readonly resources: readonly ResourceKey[];
|
|
29
|
+
readonly scope: 'project' | 'session';
|
|
30
|
+
readonly sessionId?: string;
|
|
31
|
+
readonly mergeKey?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface HistoryTransactionContext {
|
|
35
|
+
read(resource: ResourceKey): Promise<ResourceSnapshot>;
|
|
36
|
+
write(resource: ResourceKey, bytes: Uint8Array, contentType?: string): Promise<void>;
|
|
37
|
+
writeText(resource: ResourceKey, text: string, contentType?: string): Promise<void>;
|
|
38
|
+
writeJson(resource: ResourceKey, value: unknown): Promise<void>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface AppliedResourceChange {
|
|
42
|
+
readonly resource: ResourceKey;
|
|
43
|
+
readonly beforeBytes: Uint8Array;
|
|
44
|
+
readonly afterBytes: Uint8Array;
|
|
45
|
+
readonly contentType: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface HistorySnapshot {
|
|
49
|
+
readonly version: number;
|
|
50
|
+
readonly transactions: readonly HistoryTransaction[];
|
|
51
|
+
readonly cursor: number;
|
|
52
|
+
readonly busy: boolean;
|
|
53
|
+
readonly blocked: boolean;
|
|
54
|
+
readonly canUndo: boolean;
|
|
55
|
+
readonly canRedo: boolean;
|
|
56
|
+
readonly undoLabel: string | null;
|
|
57
|
+
readonly redoLabel: string | null;
|
|
58
|
+
readonly uniqueSnapshotBytes: number;
|
|
59
|
+
readonly lastError: HistoryError | null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface HistoryServiceOptions {
|
|
63
|
+
readonly registry?: ResourceRegistry;
|
|
64
|
+
readonly snapshots?: SnapshotStore;
|
|
65
|
+
readonly persistence?: PersistenceCoordinator;
|
|
66
|
+
readonly limits?: Partial<HistoryLimits>;
|
|
67
|
+
readonly makeId?: () => string;
|
|
68
|
+
readonly now?: () => number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface StagedResource {
|
|
72
|
+
readonly driver: ResourceDriver;
|
|
73
|
+
readonly beforeSnapshot: ResourceSnapshot;
|
|
74
|
+
readonly before: SnapshotRef;
|
|
75
|
+
afterBytes: Uint8Array;
|
|
76
|
+
afterContentType: string;
|
|
77
|
+
beforeOwned: boolean;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface MutableTransaction extends Omit<HistoryTransaction, 'status' | 'error'> {
|
|
81
|
+
status: HistoryStatus;
|
|
82
|
+
error: HistoryError | null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export class HistoryOperationError extends Error {
|
|
86
|
+
constructor(readonly historyError: HistoryError) {
|
|
87
|
+
super(historyError.message);
|
|
88
|
+
this.name = 'HistoryOperationError';
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function defaultId(): string {
|
|
93
|
+
return crypto.randomUUID();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function historyError(
|
|
97
|
+
code: HistoryError['code'],
|
|
98
|
+
message: string,
|
|
99
|
+
resources: readonly ResourceKey[],
|
|
100
|
+
cause?: unknown,
|
|
101
|
+
): HistoryError {
|
|
102
|
+
const error: HistoryError = { code, message, resources };
|
|
103
|
+
return cause === undefined ? error : { ...error, cause };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function transactionBytes(changes: readonly ResourceChange[]): number {
|
|
107
|
+
const blobs = new Map<string, number>();
|
|
108
|
+
for (const change of changes) {
|
|
109
|
+
blobs.set(change.before.blobKey, change.before.byteLength);
|
|
110
|
+
blobs.set(change.after.blobKey, change.after.byteLength);
|
|
111
|
+
}
|
|
112
|
+
let total = 0;
|
|
113
|
+
for (const length of blobs.values()) total += length;
|
|
114
|
+
return total;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** One authoritative, async undo/redo timeline for an open project session. */
|
|
118
|
+
export class HistoryService {
|
|
119
|
+
readonly registry: ResourceRegistry;
|
|
120
|
+
readonly snapshots: SnapshotStore;
|
|
121
|
+
readonly persistence: PersistenceCoordinator;
|
|
122
|
+
readonly limits: HistoryLimits;
|
|
123
|
+
|
|
124
|
+
private readonly drivers = new Map<ResourceKey, ResourceDriver>();
|
|
125
|
+
private readonly listeners = new Set<() => void>();
|
|
126
|
+
private readonly commitListeners = new Set<(transaction: HistoryTransaction) => void>();
|
|
127
|
+
private readonly makeId: () => string;
|
|
128
|
+
private readonly now: () => number;
|
|
129
|
+
private entries: MutableTransaction[] = [];
|
|
130
|
+
private cursorValue = 0;
|
|
131
|
+
private version = 0;
|
|
132
|
+
private pendingExecutions = 0;
|
|
133
|
+
private pendingProducers = 0;
|
|
134
|
+
private activeTransaction = false;
|
|
135
|
+
private disposeRequested = false;
|
|
136
|
+
private disposeComplete = false;
|
|
137
|
+
private blockedValue = false;
|
|
138
|
+
private lastErrorValue: HistoryError | null = null;
|
|
139
|
+
private executionTail: Promise<void> = Promise.resolve();
|
|
140
|
+
private publicSnapshot: HistorySnapshot;
|
|
141
|
+
|
|
142
|
+
constructor(options: HistoryServiceOptions = {}) {
|
|
143
|
+
this.registry = options.registry ?? new ResourceRegistry();
|
|
144
|
+
this.snapshots = options.snapshots ?? new SnapshotStore();
|
|
145
|
+
this.persistence = options.persistence ?? new PersistenceCoordinator();
|
|
146
|
+
this.makeId = options.makeId ?? defaultId;
|
|
147
|
+
this.now = options.now ?? Date.now;
|
|
148
|
+
this.limits = {
|
|
149
|
+
maxTransactions: options.limits?.maxTransactions ?? DEFAULT_MAX_TRANSACTIONS,
|
|
150
|
+
maxBytes: options.limits?.maxBytes ?? DEFAULT_MAX_BYTES,
|
|
151
|
+
maxTransactionBytes: options.limits?.maxTransactionBytes ?? DEFAULT_MAX_TRANSACTION_BYTES,
|
|
152
|
+
};
|
|
153
|
+
if (
|
|
154
|
+
this.limits.maxTransactions < 1 ||
|
|
155
|
+
this.limits.maxBytes < 1 ||
|
|
156
|
+
this.limits.maxTransactionBytes < 1 ||
|
|
157
|
+
this.limits.maxTransactionBytes > this.limits.maxBytes
|
|
158
|
+
) {
|
|
159
|
+
throw new Error('History limits must be positive.');
|
|
160
|
+
}
|
|
161
|
+
this.publicSnapshot = this.buildSnapshot();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
subscribe = (listener: () => void): (() => void) => {
|
|
165
|
+
if (this.disposeRequested) return () => undefined;
|
|
166
|
+
this.listeners.add(listener);
|
|
167
|
+
return () => this.listeners.delete(listener);
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
getSnapshot = (): HistorySnapshot => this.publicSnapshot;
|
|
171
|
+
|
|
172
|
+
subscribeCommits(listener: (transaction: HistoryTransaction) => void): () => void {
|
|
173
|
+
this.assertNotDisposed();
|
|
174
|
+
this.commitListeners.add(listener);
|
|
175
|
+
return () => this.commitListeners.delete(listener);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Reserves shell ordering while an async producer prepares a transaction. */
|
|
179
|
+
reserveProducer(): () => void {
|
|
180
|
+
this.assertNotDisposed();
|
|
181
|
+
this.pendingProducers++;
|
|
182
|
+
this.notify();
|
|
183
|
+
let released = false;
|
|
184
|
+
return () => {
|
|
185
|
+
if (released) return;
|
|
186
|
+
released = true;
|
|
187
|
+
this.pendingProducers--;
|
|
188
|
+
this.finishDisposeIfIdle();
|
|
189
|
+
if (!this.disposeComplete) this.notify();
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
registerDriver(driver: ResourceDriver): () => void {
|
|
194
|
+
this.assertNotDisposed();
|
|
195
|
+
const key = driver.descriptor.key;
|
|
196
|
+
const registered = this.registry.get(key);
|
|
197
|
+
if (!registered) throw new Error(`Driver resource "${key}" is not registered.`);
|
|
198
|
+
if (this.drivers.has(key)) throw new Error(`Resource "${key}" already has a driver.`);
|
|
199
|
+
this.drivers.set(key, driver);
|
|
200
|
+
return () => {
|
|
201
|
+
if (this.drivers.get(key) === driver) this.drivers.delete(key);
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
transaction<T>(
|
|
206
|
+
options: TransactionOptions,
|
|
207
|
+
operation: (tx: HistoryTransactionContext) => Promise<T> | T,
|
|
208
|
+
): Promise<T> {
|
|
209
|
+
this.validateOptions(options);
|
|
210
|
+
if (this.activeTransaction) {
|
|
211
|
+
return Promise.reject(
|
|
212
|
+
new HistoryOperationError(
|
|
213
|
+
historyError('busy', 'A history transaction cannot nest another transaction.', []),
|
|
214
|
+
),
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
return this.enqueue(() => this.transactionNow(options, operation));
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
private async transactionNow<T>(
|
|
221
|
+
options: TransactionOptions,
|
|
222
|
+
operation: (tx: HistoryTransactionContext) => Promise<T> | T,
|
|
223
|
+
): Promise<T> {
|
|
224
|
+
this.assertCanStartTransaction();
|
|
225
|
+
|
|
226
|
+
this.activeTransaction = true;
|
|
227
|
+
this.notify();
|
|
228
|
+
const allowed = new Set(options.resources);
|
|
229
|
+
const staged = new Map<ResourceKey, StagedResource>();
|
|
230
|
+
const ensureStaged = async (resource: ResourceKey): Promise<StagedResource> => {
|
|
231
|
+
if (!allowed.has(resource)) {
|
|
232
|
+
throw new Error(`Transaction did not declare resource "${resource}".`);
|
|
233
|
+
}
|
|
234
|
+
const existing = staged.get(resource);
|
|
235
|
+
if (existing) return existing;
|
|
236
|
+
const driver = this.requireDriver(resource);
|
|
237
|
+
this.validateResourceScope(driver, options);
|
|
238
|
+
const beforeSnapshot = await driver.capture();
|
|
239
|
+
const before = await this.storeCapturedSnapshot(beforeSnapshot);
|
|
240
|
+
const state: StagedResource = {
|
|
241
|
+
driver,
|
|
242
|
+
beforeSnapshot,
|
|
243
|
+
before,
|
|
244
|
+
afterBytes: Uint8Array.from(beforeSnapshot.bytes),
|
|
245
|
+
afterContentType: beforeSnapshot.contentType,
|
|
246
|
+
beforeOwned: true,
|
|
247
|
+
};
|
|
248
|
+
staged.set(resource, state);
|
|
249
|
+
return state;
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const context: HistoryTransactionContext = {
|
|
253
|
+
read: async (resource) => {
|
|
254
|
+
const state = await ensureStaged(resource);
|
|
255
|
+
return {
|
|
256
|
+
revision: state.beforeSnapshot.revision,
|
|
257
|
+
contentType: state.afterContentType,
|
|
258
|
+
bytes: Uint8Array.from(state.afterBytes),
|
|
259
|
+
sha256: state.beforeSnapshot.sha256,
|
|
260
|
+
};
|
|
261
|
+
},
|
|
262
|
+
write: async (resource, bytes, contentType) => {
|
|
263
|
+
const state = await ensureStaged(resource);
|
|
264
|
+
state.afterBytes = Uint8Array.from(bytes);
|
|
265
|
+
state.afterContentType = contentType ?? state.afterContentType;
|
|
266
|
+
},
|
|
267
|
+
writeText: async (resource, text, contentType = 'text/plain') => {
|
|
268
|
+
await context.write(resource, new TextEncoder().encode(text), contentType);
|
|
269
|
+
},
|
|
270
|
+
writeJson: async (resource, value) => {
|
|
271
|
+
await context.writeText(resource, JSON.stringify(value, null, 2), 'application/json');
|
|
272
|
+
},
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
try {
|
|
276
|
+
const result = await operation(context);
|
|
277
|
+
const changes = await this.finishStaging(staged);
|
|
278
|
+
if (changes.length === 0) return result;
|
|
279
|
+
await this.commitStagedTransaction(options, changes);
|
|
280
|
+
return result;
|
|
281
|
+
} finally {
|
|
282
|
+
// Any before refs left in a staging map with no matching committed change
|
|
283
|
+
// are released by finishStaging. If the callback throws before that point,
|
|
284
|
+
// release them here.
|
|
285
|
+
for (const state of staged.values()) {
|
|
286
|
+
if (state.beforeOwned) this.snapshots.release(state.before);
|
|
287
|
+
}
|
|
288
|
+
this.activeTransaction = false;
|
|
289
|
+
this.finishDisposeIfIdle();
|
|
290
|
+
if (!this.disposeComplete) this.notify();
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
undo(): Promise<boolean> {
|
|
295
|
+
return this.enqueue(() => this.undoNow());
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
redo(): Promise<boolean> {
|
|
299
|
+
return this.enqueue(() => this.redoNow());
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Journals an editor mutation whose exact before/after bytes were captured in
|
|
304
|
+
* the same synchronous UI turn. The command queue is shared with undo/redo, so
|
|
305
|
+
* an immediate shortcut always runs after this entry becomes authoritative.
|
|
306
|
+
*/
|
|
307
|
+
recordAppliedTransaction(
|
|
308
|
+
options: TransactionOptions,
|
|
309
|
+
applied: readonly AppliedResourceChange[],
|
|
310
|
+
): Promise<boolean> {
|
|
311
|
+
this.assertCanRecordAppliedChange();
|
|
312
|
+
this.validateOptions(options);
|
|
313
|
+
if (applied.length === 0) return Promise.resolve(false);
|
|
314
|
+
const declared = new Set(options.resources);
|
|
315
|
+
if (
|
|
316
|
+
applied.length !== declared.size ||
|
|
317
|
+
applied.some((change) => !declared.has(change.resource))
|
|
318
|
+
) {
|
|
319
|
+
return Promise.reject(
|
|
320
|
+
new Error('Applied history changes must exactly match declared resources.'),
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
// Resolve drivers before queueing. A synchronous document switch may retire
|
|
324
|
+
// the resource before this journal task runs, but the already-applied edit
|
|
325
|
+
// must still be recorded (as expired), never rejected or retargeted.
|
|
326
|
+
for (const change of applied) {
|
|
327
|
+
const driver = this.requireDriver(change.resource);
|
|
328
|
+
this.validateResourceScope(driver, options);
|
|
329
|
+
}
|
|
330
|
+
return this.enqueue(async () => {
|
|
331
|
+
this.assertCanRecordAppliedChange();
|
|
332
|
+
const changes: ResourceChange[] = [];
|
|
333
|
+
try {
|
|
334
|
+
for (const change of applied) {
|
|
335
|
+
const before = await this.snapshots.put({
|
|
336
|
+
revision: 0,
|
|
337
|
+
contentType: change.contentType,
|
|
338
|
+
bytes: change.beforeBytes,
|
|
339
|
+
});
|
|
340
|
+
const after = await this.snapshots.put({
|
|
341
|
+
revision: 1,
|
|
342
|
+
contentType: change.contentType,
|
|
343
|
+
bytes: change.afterBytes,
|
|
344
|
+
});
|
|
345
|
+
if (before.sha256 === after.sha256) {
|
|
346
|
+
this.snapshots.release(before);
|
|
347
|
+
this.snapshots.release(after);
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
changes.push({ resource: change.resource, before, after });
|
|
351
|
+
}
|
|
352
|
+
if (changes.length === 0) return false;
|
|
353
|
+
this.appendAppliedTransaction(options, changes);
|
|
354
|
+
return true;
|
|
355
|
+
} catch (error) {
|
|
356
|
+
this.releaseChanges(changes);
|
|
357
|
+
this.lastErrorValue =
|
|
358
|
+
error instanceof HistoryOperationError
|
|
359
|
+
? error.historyError
|
|
360
|
+
: historyError(
|
|
361
|
+
'apply-failed',
|
|
362
|
+
`Failed to journal “${options.label.trim()}”.`,
|
|
363
|
+
applied.map((change) => change.resource),
|
|
364
|
+
error,
|
|
365
|
+
);
|
|
366
|
+
this.notify();
|
|
367
|
+
throw error;
|
|
368
|
+
}
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** Preflight for producers that mutate state before journaling it. */
|
|
373
|
+
assertCanRecordAppliedChange(): void {
|
|
374
|
+
this.assertNotDisposed();
|
|
375
|
+
if (this.blockedValue) {
|
|
376
|
+
if (this.lastErrorValue?.code === 'compensation-failed') {
|
|
377
|
+
throw new HistoryOperationError(this.lastErrorValue);
|
|
378
|
+
}
|
|
379
|
+
throw new HistoryOperationError(
|
|
380
|
+
historyError('compensation-failed', 'History is blocked pending recovery.', []),
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Surface a recovered producer failure without blocking later safe edits. */
|
|
386
|
+
reportAppliedChangeFailure(
|
|
387
|
+
label: string,
|
|
388
|
+
resources: readonly ResourceKey[],
|
|
389
|
+
cause: unknown,
|
|
390
|
+
): void {
|
|
391
|
+
if (this.blockedValue) return;
|
|
392
|
+
this.lastErrorValue = historyError(
|
|
393
|
+
'apply-failed',
|
|
394
|
+
`Failed to apply “${label.trim()}”; prior state was restored.`,
|
|
395
|
+
resources,
|
|
396
|
+
cause,
|
|
397
|
+
);
|
|
398
|
+
this.notify();
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Permanently blocks history after an already-applied producer could not
|
|
403
|
+
* restore exact prior bytes. Producers outside HistoryService's own apply
|
|
404
|
+
* loop use this to surface the same loud recovery state as undo/redo.
|
|
405
|
+
*/
|
|
406
|
+
blockForCompensationFailure(
|
|
407
|
+
label: string,
|
|
408
|
+
resources: readonly ResourceKey[],
|
|
409
|
+
cause: unknown,
|
|
410
|
+
): HistoryOperationError {
|
|
411
|
+
if (this.blockedValue && this.lastErrorValue?.code === 'compensation-failed') {
|
|
412
|
+
return new HistoryOperationError(this.lastErrorValue);
|
|
413
|
+
}
|
|
414
|
+
const problem = historyError(
|
|
415
|
+
'compensation-failed',
|
|
416
|
+
`History recovery failed while applying “${label.trim()}”.`,
|
|
417
|
+
resources,
|
|
418
|
+
cause,
|
|
419
|
+
);
|
|
420
|
+
this.blockedValue = true;
|
|
421
|
+
this.lastErrorValue = problem;
|
|
422
|
+
this.notify();
|
|
423
|
+
return new HistoryOperationError(problem);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
jumpTo(cursor: number): Promise<boolean> {
|
|
427
|
+
if (!Number.isInteger(cursor) || cursor < 0 || cursor > this.entries.length) {
|
|
428
|
+
return Promise.reject(new Error(`History cursor ${cursor} is out of range.`));
|
|
429
|
+
}
|
|
430
|
+
return this.enqueue(async () => {
|
|
431
|
+
while (this.cursorValue > cursor) {
|
|
432
|
+
if (!(await this.undoNow())) return false;
|
|
433
|
+
}
|
|
434
|
+
while (this.cursorValue < cursor) {
|
|
435
|
+
if (!(await this.redoNow())) return false;
|
|
436
|
+
}
|
|
437
|
+
return true;
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
expireSession(sessionId: string): void {
|
|
442
|
+
this.assertNotDisposed();
|
|
443
|
+
const resources = new Set(this.registry.expireSession(sessionId));
|
|
444
|
+
if (resources.size === 0) return;
|
|
445
|
+
this.entries = this.entries.map((entry) =>
|
|
446
|
+
entry.sessionId === sessionId ||
|
|
447
|
+
entry.changes.some((change) => resources.has(change.resource))
|
|
448
|
+
? { ...entry, status: 'expired', error: null }
|
|
449
|
+
: entry,
|
|
450
|
+
);
|
|
451
|
+
this.notify();
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
expireResources(resources: Iterable<ResourceKey>): void {
|
|
455
|
+
this.assertNotDisposed();
|
|
456
|
+
const expired = new Set(this.registry.expireResources(resources));
|
|
457
|
+
if (expired.size === 0) return;
|
|
458
|
+
this.entries = this.entries.map((entry) =>
|
|
459
|
+
entry.changes.some((change) => expired.has(change.resource))
|
|
460
|
+
? { ...entry, status: 'expired', error: null }
|
|
461
|
+
: entry,
|
|
462
|
+
);
|
|
463
|
+
this.notify();
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
dispose(): void {
|
|
467
|
+
if (this.disposeRequested) return;
|
|
468
|
+
this.disposeRequested = true;
|
|
469
|
+
this.listeners.clear();
|
|
470
|
+
this.commitListeners.clear();
|
|
471
|
+
this.finishDisposeIfIdle();
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
private finishDisposeIfIdle(): void {
|
|
475
|
+
if (
|
|
476
|
+
!this.disposeRequested ||
|
|
477
|
+
this.disposeComplete ||
|
|
478
|
+
this.activeTransaction ||
|
|
479
|
+
this.pendingExecutions > 0 ||
|
|
480
|
+
this.pendingProducers > 0
|
|
481
|
+
) {
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
for (const entry of this.entries) this.releaseChanges(entry.changes);
|
|
485
|
+
this.entries = [];
|
|
486
|
+
this.cursorValue = 0;
|
|
487
|
+
this.drivers.clear();
|
|
488
|
+
this.registry.clear();
|
|
489
|
+
this.snapshots.clear();
|
|
490
|
+
this.blockedValue = false;
|
|
491
|
+
this.lastErrorValue = null;
|
|
492
|
+
this.disposeComplete = true;
|
|
493
|
+
this.notify();
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
private async finishStaging(staged: Map<ResourceKey, StagedResource>): Promise<ResourceChange[]> {
|
|
497
|
+
const changes: ResourceChange[] = [];
|
|
498
|
+
try {
|
|
499
|
+
for (const [resource, state] of staged) {
|
|
500
|
+
const after = await this.snapshots.put({
|
|
501
|
+
revision: state.beforeSnapshot.revision + 1,
|
|
502
|
+
contentType: state.afterContentType,
|
|
503
|
+
bytes: state.afterBytes,
|
|
504
|
+
});
|
|
505
|
+
if (after.sha256 === state.before.sha256) {
|
|
506
|
+
this.snapshots.release(after);
|
|
507
|
+
this.snapshots.release(state.before);
|
|
508
|
+
state.beforeOwned = false;
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
changes.push({ resource, before: state.before, after });
|
|
512
|
+
state.beforeOwned = false;
|
|
513
|
+
}
|
|
514
|
+
return changes;
|
|
515
|
+
} catch (error) {
|
|
516
|
+
this.releaseChanges(changes);
|
|
517
|
+
throw error;
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
private async commitStagedTransaction(
|
|
522
|
+
options: TransactionOptions,
|
|
523
|
+
changes: readonly ResourceChange[],
|
|
524
|
+
): Promise<void> {
|
|
525
|
+
const byteSize = transactionBytes(changes);
|
|
526
|
+
if (byteSize > this.limits.maxTransactionBytes) {
|
|
527
|
+
this.releaseChanges(changes);
|
|
528
|
+
throw new HistoryOperationError(
|
|
529
|
+
historyError(
|
|
530
|
+
'history-limit',
|
|
531
|
+
`Transaction requires ${byteSize} bytes; the limit is ${this.limits.maxTransactionBytes}.`,
|
|
532
|
+
changes.map((change) => change.resource),
|
|
533
|
+
),
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
const transaction: MutableTransaction = {
|
|
538
|
+
id: this.makeId(),
|
|
539
|
+
label: options.label.trim(),
|
|
540
|
+
detail: options.detail?.trim() || null,
|
|
541
|
+
timestamp: this.now(),
|
|
542
|
+
scope: options.scope,
|
|
543
|
+
sessionId: options.scope === 'session' ? options.sessionId! : null,
|
|
544
|
+
changes,
|
|
545
|
+
byteSize,
|
|
546
|
+
mergeKey: options.mergeKey?.trim() || null,
|
|
547
|
+
status: 'committed',
|
|
548
|
+
error: null,
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
try {
|
|
552
|
+
this.assertCanRecordAppliedChange();
|
|
553
|
+
await this.persistence.runMany(
|
|
554
|
+
changes.map((change) => change.resource),
|
|
555
|
+
async () => {
|
|
556
|
+
this.assertCanRecordAppliedChange();
|
|
557
|
+
await this.preflight(transaction, 'redo');
|
|
558
|
+
await this.applyWithCompensation(transaction, 'redo');
|
|
559
|
+
},
|
|
560
|
+
);
|
|
561
|
+
} catch (error) {
|
|
562
|
+
const problem =
|
|
563
|
+
error instanceof HistoryOperationError
|
|
564
|
+
? error.historyError
|
|
565
|
+
: historyError(
|
|
566
|
+
'apply-failed',
|
|
567
|
+
`Failed to commit “${transaction.label}”.`,
|
|
568
|
+
changes.map((change) => change.resource),
|
|
569
|
+
error,
|
|
570
|
+
);
|
|
571
|
+
this.lastErrorValue = problem;
|
|
572
|
+
if (problem.code === 'compensation-failed') this.blockedValue = true;
|
|
573
|
+
this.releaseChanges(changes);
|
|
574
|
+
this.notify();
|
|
575
|
+
throw error;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
this.truncateRedo();
|
|
579
|
+
this.entries.push(transaction);
|
|
580
|
+
this.cursorValue = this.entries.length;
|
|
581
|
+
if (!this.blockedValue) this.lastErrorValue = null;
|
|
582
|
+
this.trimToLimits();
|
|
583
|
+
this.notify();
|
|
584
|
+
for (const listener of this.commitListeners) listener(transaction);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
private appendAppliedTransaction(
|
|
588
|
+
options: TransactionOptions,
|
|
589
|
+
changes: readonly ResourceChange[],
|
|
590
|
+
): void {
|
|
591
|
+
const byteSize = transactionBytes(changes);
|
|
592
|
+
if (byteSize > this.limits.maxTransactionBytes) {
|
|
593
|
+
this.releaseChanges(changes);
|
|
594
|
+
throw new HistoryOperationError(
|
|
595
|
+
historyError(
|
|
596
|
+
'history-limit',
|
|
597
|
+
`Transaction requires ${byteSize} bytes; the limit is ${this.limits.maxTransactionBytes}.`,
|
|
598
|
+
changes.map((change) => change.resource),
|
|
599
|
+
),
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
const transaction: MutableTransaction = {
|
|
603
|
+
id: this.makeId(),
|
|
604
|
+
label: options.label.trim(),
|
|
605
|
+
detail: options.detail?.trim() || null,
|
|
606
|
+
timestamp: this.now(),
|
|
607
|
+
scope: options.scope,
|
|
608
|
+
sessionId: options.scope === 'session' ? options.sessionId! : null,
|
|
609
|
+
changes,
|
|
610
|
+
byteSize,
|
|
611
|
+
mergeKey: options.mergeKey?.trim() || null,
|
|
612
|
+
status: changes.every((change) => this.registry.isActive(change.resource))
|
|
613
|
+
? 'committed'
|
|
614
|
+
: 'expired',
|
|
615
|
+
error: null,
|
|
616
|
+
};
|
|
617
|
+
this.truncateRedo();
|
|
618
|
+
this.entries.push(transaction);
|
|
619
|
+
this.cursorValue = this.entries.length;
|
|
620
|
+
if (!this.blockedValue) this.lastErrorValue = null;
|
|
621
|
+
this.trimToLimits();
|
|
622
|
+
this.notify();
|
|
623
|
+
for (const listener of this.commitListeners) listener(transaction);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
private async undoNow(): Promise<boolean> {
|
|
627
|
+
if (this.cursorValue === 0 || this.blockedValue) return false;
|
|
628
|
+
const transaction = this.entries[this.cursorValue - 1]!;
|
|
629
|
+
if (transaction.status === 'expired') {
|
|
630
|
+
const error = historyError(
|
|
631
|
+
'expired',
|
|
632
|
+
`“${transaction.label}” belongs to an expired session.`,
|
|
633
|
+
[],
|
|
634
|
+
);
|
|
635
|
+
transaction.error = error;
|
|
636
|
+
this.lastErrorValue = error;
|
|
637
|
+
this.notify();
|
|
638
|
+
return false;
|
|
639
|
+
}
|
|
640
|
+
return this.executeExisting(transaction, 'undo');
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
private async redoNow(): Promise<boolean> {
|
|
644
|
+
if (this.cursorValue >= this.entries.length || this.blockedValue) return false;
|
|
645
|
+
const transaction = this.entries[this.cursorValue]!;
|
|
646
|
+
if (transaction.status === 'expired') {
|
|
647
|
+
const error = historyError(
|
|
648
|
+
'expired',
|
|
649
|
+
`“${transaction.label}” belongs to an expired session.`,
|
|
650
|
+
[],
|
|
651
|
+
);
|
|
652
|
+
transaction.error = error;
|
|
653
|
+
this.lastErrorValue = error;
|
|
654
|
+
this.notify();
|
|
655
|
+
return false;
|
|
656
|
+
}
|
|
657
|
+
return this.executeExisting(transaction, 'redo');
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
private async executeExisting(
|
|
661
|
+
transaction: MutableTransaction,
|
|
662
|
+
direction: 'undo' | 'redo',
|
|
663
|
+
): Promise<boolean> {
|
|
664
|
+
const resources = transaction.changes.map((change) => change.resource);
|
|
665
|
+
try {
|
|
666
|
+
await this.persistence.runMany(resources, async () => {
|
|
667
|
+
await this.preflight(transaction, direction);
|
|
668
|
+
transaction.status = direction === 'undo' ? 'undoing' : 'redoing';
|
|
669
|
+
transaction.error = null;
|
|
670
|
+
this.notify();
|
|
671
|
+
await this.applyWithCompensation(transaction, direction);
|
|
672
|
+
});
|
|
673
|
+
if (direction === 'undo') {
|
|
674
|
+
this.cursorValue--;
|
|
675
|
+
transaction.status = 'undone';
|
|
676
|
+
} else {
|
|
677
|
+
this.cursorValue++;
|
|
678
|
+
transaction.status = 'committed';
|
|
679
|
+
}
|
|
680
|
+
transaction.error = null;
|
|
681
|
+
this.lastErrorValue = null;
|
|
682
|
+
this.notify();
|
|
683
|
+
return true;
|
|
684
|
+
} catch (error) {
|
|
685
|
+
const problem =
|
|
686
|
+
error instanceof HistoryOperationError
|
|
687
|
+
? error.historyError
|
|
688
|
+
: historyError(
|
|
689
|
+
'apply-failed',
|
|
690
|
+
`Failed to ${direction} “${transaction.label}”.`,
|
|
691
|
+
resources,
|
|
692
|
+
error,
|
|
693
|
+
);
|
|
694
|
+
transaction.error = problem;
|
|
695
|
+
transaction.status = problem.code === 'compensation-failed' ? 'failed-partial' : 'failed';
|
|
696
|
+
this.lastErrorValue = problem;
|
|
697
|
+
if (problem.code === 'compensation-failed') this.blockedValue = true;
|
|
698
|
+
this.notify();
|
|
699
|
+
return false;
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
private async preflight(
|
|
704
|
+
transaction: MutableTransaction,
|
|
705
|
+
direction: 'undo' | 'redo',
|
|
706
|
+
): Promise<void> {
|
|
707
|
+
for (const change of transaction.changes) {
|
|
708
|
+
if (!this.registry.isActive(change.resource)) {
|
|
709
|
+
throw new HistoryOperationError(
|
|
710
|
+
historyError('expired', `Resource "${change.resource}" is unavailable.`, [
|
|
711
|
+
change.resource,
|
|
712
|
+
]),
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
const expected = direction === 'undo' ? change.after : change.before;
|
|
716
|
+
const result = await this.requireDriver(change.resource).preflight(expected);
|
|
717
|
+
if (!result.ok) {
|
|
718
|
+
throw new HistoryOperationError(
|
|
719
|
+
historyError(
|
|
720
|
+
result.reason === 'expired' ? 'expired' : 'conflict',
|
|
721
|
+
`Resource "${change.resource}" no longer matches “${transaction.label}”.`,
|
|
722
|
+
[change.resource],
|
|
723
|
+
),
|
|
724
|
+
);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
private async applyWithCompensation(
|
|
730
|
+
transaction: MutableTransaction,
|
|
731
|
+
direction: 'undo' | 'redo',
|
|
732
|
+
): Promise<void> {
|
|
733
|
+
const ordered =
|
|
734
|
+
direction === 'undo' ? [...transaction.changes].reverse() : [...transaction.changes];
|
|
735
|
+
const applied: ResourceChange[] = [];
|
|
736
|
+
try {
|
|
737
|
+
for (const change of ordered) {
|
|
738
|
+
const target = direction === 'undo' ? change.before : change.after;
|
|
739
|
+
await this.requireDriver(change.resource).restore(target);
|
|
740
|
+
applied.push(change);
|
|
741
|
+
}
|
|
742
|
+
} catch (applyError) {
|
|
743
|
+
try {
|
|
744
|
+
for (const change of [...applied].reverse()) {
|
|
745
|
+
const compensation = direction === 'undo' ? change.after : change.before;
|
|
746
|
+
await this.requireDriver(change.resource).restore(compensation);
|
|
747
|
+
}
|
|
748
|
+
} catch (compensationError) {
|
|
749
|
+
throw new HistoryOperationError(
|
|
750
|
+
historyError(
|
|
751
|
+
'compensation-failed',
|
|
752
|
+
`History recovery failed while applying “${transaction.label}”.`,
|
|
753
|
+
transaction.changes.map((change) => change.resource),
|
|
754
|
+
{ applyError, compensationError },
|
|
755
|
+
),
|
|
756
|
+
);
|
|
757
|
+
}
|
|
758
|
+
throw new HistoryOperationError(
|
|
759
|
+
historyError(
|
|
760
|
+
'apply-failed',
|
|
761
|
+
`Failed to apply “${transaction.label}”; prior state was restored.`,
|
|
762
|
+
transaction.changes.map((change) => change.resource),
|
|
763
|
+
applyError,
|
|
764
|
+
),
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
private enqueue<T>(operation: () => Promise<T>): Promise<T> {
|
|
770
|
+
if (this.disposeRequested) return Promise.reject(new Error('History service is disposed.'));
|
|
771
|
+
this.pendingExecutions++;
|
|
772
|
+
this.notify();
|
|
773
|
+
const run = () => {
|
|
774
|
+
this.assertNotDisposed();
|
|
775
|
+
return operation();
|
|
776
|
+
};
|
|
777
|
+
const result = this.executionTail.then(run, run);
|
|
778
|
+
this.executionTail = result.then(
|
|
779
|
+
() => undefined,
|
|
780
|
+
() => undefined,
|
|
781
|
+
);
|
|
782
|
+
return result.finally(() => {
|
|
783
|
+
this.pendingExecutions--;
|
|
784
|
+
this.finishDisposeIfIdle();
|
|
785
|
+
if (!this.disposeComplete) this.notify();
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
private async storeCapturedSnapshot(snapshot: ResourceSnapshot): Promise<SnapshotRef> {
|
|
790
|
+
const ref = await this.snapshots.put({
|
|
791
|
+
revision: snapshot.revision,
|
|
792
|
+
contentType: snapshot.contentType,
|
|
793
|
+
bytes: snapshot.bytes,
|
|
794
|
+
});
|
|
795
|
+
if (ref.sha256 !== snapshot.sha256) {
|
|
796
|
+
this.snapshots.release(ref);
|
|
797
|
+
throw new Error('Resource driver returned bytes that do not match its SHA-256.');
|
|
798
|
+
}
|
|
799
|
+
return ref;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
private truncateRedo(): void {
|
|
803
|
+
if (this.cursorValue === this.entries.length) return;
|
|
804
|
+
for (const entry of this.entries.slice(this.cursorValue)) this.releaseChanges(entry.changes);
|
|
805
|
+
this.entries = this.entries.slice(0, this.cursorValue);
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
private trimToLimits(): void {
|
|
809
|
+
while (
|
|
810
|
+
this.entries.length > 1 &&
|
|
811
|
+
(this.entries.length > this.limits.maxTransactions ||
|
|
812
|
+
this.snapshots.uniqueByteLength > this.limits.maxBytes)
|
|
813
|
+
) {
|
|
814
|
+
const [oldest, ...rest] = this.entries;
|
|
815
|
+
this.entries = rest;
|
|
816
|
+
this.releaseChanges(oldest!.changes);
|
|
817
|
+
this.cursorValue = Math.max(0, this.cursorValue - 1);
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
private releaseChanges(changes: readonly ResourceChange[]): void {
|
|
822
|
+
for (const change of changes) {
|
|
823
|
+
if (this.snapshots.has(change.before)) this.snapshots.release(change.before);
|
|
824
|
+
if (this.snapshots.has(change.after)) this.snapshots.release(change.after);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
private requireDriver(resource: ResourceKey): ResourceDriver {
|
|
829
|
+
const driver = this.drivers.get(resource);
|
|
830
|
+
if (!driver) throw new Error(`No history resource driver is registered for "${resource}".`);
|
|
831
|
+
return driver;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
private validateOptions(options: TransactionOptions): void {
|
|
835
|
+
if (!options.label.trim()) throw new Error('History transaction label cannot be empty.');
|
|
836
|
+
if (options.resources.length === 0) throw new Error('History transaction needs a resource.');
|
|
837
|
+
if (new Set(options.resources).size !== options.resources.length) {
|
|
838
|
+
throw new Error('History transaction resources must be unique.');
|
|
839
|
+
}
|
|
840
|
+
if (options.scope === 'session' && !options.sessionId?.trim()) {
|
|
841
|
+
throw new Error('A session transaction requires a session id.');
|
|
842
|
+
}
|
|
843
|
+
if (options.scope === 'project' && options.sessionId !== undefined) {
|
|
844
|
+
throw new Error('A project transaction cannot have a session id.');
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
private assertCanStartTransaction(): void {
|
|
849
|
+
this.assertNotDisposed();
|
|
850
|
+
if (this.blockedValue) {
|
|
851
|
+
throw new HistoryOperationError(
|
|
852
|
+
historyError('compensation-failed', 'History is blocked pending recovery.', []),
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
if (this.activeTransaction) {
|
|
856
|
+
throw new HistoryOperationError(
|
|
857
|
+
historyError('busy', 'Another history operation is already in progress.', []),
|
|
858
|
+
);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
private assertNotDisposed(): void {
|
|
863
|
+
if (this.disposeRequested) throw new Error('History service is disposed.');
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
private validateResourceScope(driver: ResourceDriver, options: TransactionOptions): void {
|
|
867
|
+
const descriptor = driver.descriptor;
|
|
868
|
+
if (descriptor.scope !== options.scope) {
|
|
869
|
+
throw new Error(`Resource "${descriptor.key}" does not match transaction scope.`);
|
|
870
|
+
}
|
|
871
|
+
if (options.scope === 'session' && descriptor.sessionId !== options.sessionId) {
|
|
872
|
+
throw new Error(`Resource "${descriptor.key}" belongs to another session.`);
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
private notify(): void {
|
|
877
|
+
this.version++;
|
|
878
|
+
this.publicSnapshot = this.buildSnapshot();
|
|
879
|
+
for (const listener of this.listeners) listener();
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
private buildSnapshot(): HistorySnapshot {
|
|
883
|
+
const undo = this.cursorValue > 0 ? this.entries[this.cursorValue - 1]! : null;
|
|
884
|
+
const redo = this.cursorValue < this.entries.length ? this.entries[this.cursorValue]! : null;
|
|
885
|
+
const idle =
|
|
886
|
+
this.pendingExecutions === 0 && this.pendingProducers === 0 && !this.activeTransaction;
|
|
887
|
+
return {
|
|
888
|
+
version: this.version,
|
|
889
|
+
transactions: this.entries.map((entry) => ({
|
|
890
|
+
...entry,
|
|
891
|
+
changes: entry.changes.map((change) => ({ ...change })),
|
|
892
|
+
})),
|
|
893
|
+
cursor: this.cursorValue,
|
|
894
|
+
busy: !idle,
|
|
895
|
+
blocked: this.blockedValue,
|
|
896
|
+
canUndo: idle && !!undo && undo.status !== 'expired' && !this.blockedValue,
|
|
897
|
+
canRedo: idle && !!redo && redo.status !== 'expired' && !this.blockedValue,
|
|
898
|
+
undoLabel: undo?.label ?? null,
|
|
899
|
+
redoLabel: redo?.label ?? null,
|
|
900
|
+
uniqueSnapshotBytes: this.snapshots.uniqueByteLength,
|
|
901
|
+
lastError: this.lastErrorValue,
|
|
902
|
+
};
|
|
903
|
+
}
|
|
904
|
+
}
|