diff --git a/.env.example b/.env.example index 04d8ecc..7d57523 100644 --- a/.env.example +++ b/.env.example @@ -22,3 +22,10 @@ NEXUS_COLLAB_SERVER_PORT=1234 # Public WebSocket URL that browsers use to connect to the collab server. NEXT_PUBLIC_COLLAB_SERVER_URL=ws://localhost:1234 + +# SpacetimeDB connection. When NEXT_PUBLIC_SPACETIME_URI is set, workspace mode +# uses SpacetimeDB for persistence and real-time sync instead of the filesystem +# REST API + Hocuspocus. Leave unset to keep the legacy persistence layer. +NEXT_PUBLIC_SPACETIME_URI=ws://localhost:3001 +NEXT_PUBLIC_SPACETIME_DB_NAME=nexus +SPACETIME_MODULE_PATH=spacetime/nexus diff --git a/CLAUDE.md b/CLAUDE.md index 5aba393..14728c6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,6 +64,7 @@ Keep the mental model high-level: - `src/types/` — shared type definitions - `docs/tasks/` — task-specific plans and notes - `packages/` — auxiliary packages such as `nexus-acp-bridge` +- `spacetime/nexus/` — SpacetimeDB TypeScript module (tables, reducers, lifecycle hooks) --- @@ -93,6 +94,17 @@ Keep the mental model high-level: - Keep offline/editor-only flows working even when OpenCode is disconnected. - Client/service logic lives under `src/lib/opencode/`; related state lives under `src/store/opencode*`. +### SpacetimeDB persistence and sync (workspace mode) +- When `NEXT_PUBLIC_SPACETIME_URI` is configured, workspace mode uses SpacetimeDB for persistence and real-time collaboration instead of the filesystem REST API + Hocuspocus. +- SpacetimeDB module definition: `spacetime/nexus/src/index.ts` — tables, reducers, lifecycle hooks for the SpacetimeDB 2.1 TypeScript module API. +- Client-side sync bridges: `src/lib/spacetime/` — connection manager, workspace sync, brain sync, presence layer. +- Generated SpacetimeDB client bindings live in `src/lib/spacetime/module_bindings/`. They are committed so app builds do not require the SpacetimeDB CLI; regenerate them with `scripts/generate-spacetime-bindings.sh` after module schema changes. +- The sync bridges use the `_isApplyingRemote` loop-prevention pattern from `collab-doc.ts` to avoid feedback loops between SpacetimeDB subscriptions and Zustand store updates. +- Hocuspocus/Yjs remains for standalone `?room=` collaboration mode. +- REST API routes under `src/app/api/workspaces/` and `src/app/api/brain/` are deprecated shims; they will be removed once all clients use SpacetimeDB directly. +- Standalone editor/localStorage mode is completely unaffected by SpacetimeDB. +- New environment variables: `NEXT_PUBLIC_SPACETIME_URI`, `NEXT_PUBLIC_SPACETIME_DB_NAME`, `SPACETIME_MODULE_PATH`. + --- ## Guardrails diff --git a/Dockerfile b/Dockerfile index b8b0e31..6df4a36 100644 --- a/Dockerfile +++ b/Dockerfile @@ -53,10 +53,13 @@ EXPOSE 3000 COPY --from=builder --chown=bun:bun /app/public ./public -# Install git for marketplace clone/pull operations at runtime. -RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates \ +# Install git for marketplace clone/pull operations and curl for SpacetimeDB CLI. +RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates curl \ && rm -rf /var/lib/apt/lists/* +# Install SpacetimeDB CLI for binding generation. +RUN curl -fsSL https://install.spacetimedb.com | bash -s -- --yes 2>/dev/null || true + RUN mkdir .next && chown bun:bun .next # Pre-create marketplace cache directory with correct ownership. diff --git a/README.md b/README.md index b2fe9eb..6fc2cf4 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,32 @@ bun run docker:up bun run docker:down ``` +### Optional SpacetimeDB workspace backend + +Workspace mode can use SpacetimeDB for persistence and real-time sync. Standalone editor mode still uses browser storage. + +Start the SpacetimeDB service: + +```bash +docker compose up nexus-spacetimedb -d +``` + +Publish the module and regenerate bindings after schema changes: + +```bash +spacetime publish -p spacetime/nexus nexus +./scripts/generate-spacetime-bindings.sh +``` + +Configure the app: + +```bash +NEXT_PUBLIC_SPACETIME_URI=ws://localhost:30201 +NEXT_PUBLIC_SPACETIME_DB_NAME=nexus +``` + +Generated SpacetimeDB client bindings are committed under `src/lib/spacetime/module_bindings/`, so regular app builds do not need to run the SpacetimeDB CLI. + ## Usage ### 1. Build a workflow diff --git a/bun.lock b/bun.lock index 38d2e40..d6532af 100644 --- a/bun.lock +++ b/bun.lock @@ -4,10 +4,9 @@ "": { "name": "nexus-workflow-studio", "dependencies": { + "@clockworklabs/spacetimedb-sdk": "^1.0.0", "@dagrejs/dagre": "^2.0.4", "@hocuspocus/provider": "^3.4.4", - "@hocuspocus/provider": "^3.4.4", - "@hocuspocus/server": "^3.4.4", "@hocuspocus/server": "^3.4.4", "@hookform/resolvers": "^5.2.2", "@uiw/react-md-editor": "^4.0.11", @@ -29,6 +28,7 @@ "react-hook-form": "^7.71.2", "react-simple-code-editor": "^0.14.1", "sonner": "^2.0.7", + "spacetimedb": "^2.1.0", "tailwind-merge": "^3.5.0", "yjs": "^13.6.30", "zod": "^4.3.6", @@ -115,6 +115,8 @@ "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + "@clockworklabs/spacetimedb-sdk": ["@clockworklabs/spacetimedb-sdk@1.3.3", "", { "dependencies": { "@zxing/text-encoding": "^0.9.0", "base64-js": "^1.5.1" }, "peerDependencies": { "undici": "^6.19.2" } }, "sha512-LZ5xUCuiDQXFC9ou+11ShNm2BnCPIKyIHpb8k0WW0QG2QxsZepLwbzfklqixeF9qSwu2IoPy05sSBdum33FsEg=="], + "@dagrejs/dagre": ["@dagrejs/dagre@2.0.4", "", { "dependencies": { "@dagrejs/graphlib": "3.0.4" } }, "sha512-J6vCWTNpicHF4zFlZG1cS5DkGzMr9941gddYkakjrg3ZNev4bbqEgLHFTWiFrcJm7UCRu7olO3K6IRDd9gSGhA=="], "@dagrejs/graphlib": ["@dagrejs/graphlib@3.0.4", "", {}, "sha512-HxZ7fCvAwTLCWCO0WjDkzAFQze8LdC6iOpKbetDKHIuDfIgMlIzYzqZ4nxwLlclQX+3ZVeZ1K2OuaOE2WWcyOg=="], @@ -573,6 +575,8 @@ "@xyflow/system": ["@xyflow/system@0.0.75", "", { "dependencies": { "@types/d3-drag": "^3.0.7", "@types/d3-interpolate": "^3.0.4", "@types/d3-selection": "^3.0.10", "@types/d3-transition": "^3.0.8", "@types/d3-zoom": "^3.0.8", "d3-drag": "^3.0.0", "d3-interpolate": "^3.0.1", "d3-selection": "^3.0.0", "d3-zoom": "^3.0.0" } }, "sha512-iXs+AGFLi8w/VlAoc/iSxk+CxfT6o64Uw/k0CKASOPqjqz6E0rb5jFZgJtXGZCpfQI6OQpu5EnumP5fGxQheaQ=="], + "@zxing/text-encoding": ["@zxing/text-encoding@0.9.0", "", {}, "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA=="], + "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], "acorn": ["acorn@8.16.0", "", { "bin": "bin/acorn" }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="], @@ -635,6 +639,8 @@ "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.0", "", { "bin": "dist/cli.cjs" }, "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA=="], "bcp-47-match": ["bcp-47-match@2.0.3", "", {}, "sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ=="], @@ -1461,6 +1467,8 @@ "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + "prettier": ["prettier@3.8.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q=="], + "pretty-ms": ["pretty-ms@9.3.0", "", { "dependencies": { "parse-ms": "^4.0.0" } }, "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ=="], "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], @@ -1477,6 +1485,8 @@ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "pure-rand": ["pure-rand@7.0.1", "", {}, "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ=="], + "qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="], "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], @@ -1577,6 +1587,8 @@ "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], + "safe-stable-stringify": ["safe-stable-stringify@2.5.0", "", {}, "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA=="], + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], @@ -1625,6 +1637,8 @@ "space-separated-tokens": ["space-separated-tokens@2.0.2", "", {}, "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q=="], + "spacetimedb": ["spacetimedb@2.1.0", "", { "dependencies": { "base64-js": "^1.5.1", "headers-polyfill": "^4.0.3", "object-inspect": "^1.13.4", "prettier": "^3.3.3", "pure-rand": "^7.0.1", "safe-stable-stringify": "^2.5.0", "statuses": "^2.0.2", "url-polyfill": "^1.1.14" }, "peerDependencies": { "@angular/core": ">=17.0.0", "@tanstack/react-query": "^5.0.0", "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0", "svelte": "^4.0.0 || ^5.0.0", "undici": "^6.19.2", "vue": "^3.3.0" }, "optionalPeers": ["@angular/core", "@tanstack/react-query", "react", "svelte", "undici", "vue"] }, "sha512-Kzs+HXCRj15ryld03ztU4a2uQg0M8ivV/9Bk/gvMpb59lLc/A2/r7UkGCYBePsBL7Zwqgr8gE8FeufoZVXtPnA=="], + "stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="], "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], @@ -1731,6 +1745,8 @@ "unbox-primitive": ["unbox-primitive@1.1.0", "", { "dependencies": { "call-bound": "^1.0.3", "has-bigints": "^1.0.2", "has-symbols": "^1.1.0", "which-boxed-primitive": "^1.1.1" } }, "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw=="], + "undici": ["undici@6.24.1", "", {}, "sha512-sC+b0tB1whOCzbtlx20fx3WgCXwkW627p4EA9uM+/tNNPkSS+eSEld6pAs9nDv7WbY1UUljBMYPtu9BCOrCWKA=="], + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], "unicorn-magic": ["unicorn-magic@0.3.0", "", {}, "sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA=="], @@ -1761,6 +1777,8 @@ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "url-polyfill": ["url-polyfill@1.1.14", "", {}, "sha512-p4f3TTAG6ADVF3mwbXw7hGw+QJyw5CnNGvYh5fCuQQZIiuKUswqcznyV3pGDP9j0TSmC4UvRKm8kl1QsX1diiQ=="], + "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], diff --git a/docker-compose.yml b/docker-compose.yml index 892cedb..ea9c555 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,6 +15,8 @@ services: PORT: "3000" NEXUS_BRAIN_DATA_DIR: /data/brain NEXT_PUBLIC_COLLAB_SERVER_URL: ws://localhost:1234 + NEXT_PUBLIC_SPACETIME_URI: ws://localhost:${SPACETIME_PORT:-30201} + NEXT_PUBLIC_SPACETIME_DB_NAME: nexus expose: - "3000" volumes: @@ -41,6 +43,20 @@ services: - nexus_collab_data:/data/collab restart: unless-stopped + # SpacetimeDB server (workspace persistence + real-time sync) + nexus-spacetimedb: + image: clockworklabs/spacetime:latest + container_name: nexus-spacetimedb + command: ["start"] + environment: + STDB_LOG_LEVEL: info + ports: + - "127.0.0.1:${SPACETIME_PORT:-30201}:3000" + volumes: + - nexus_spacetime_data:/var/lib/spacetimedb + restart: unless-stopped + volumes: nexus_brain_data: nexus_collab_data: + nexus_spacetime_data: diff --git a/docs/tasks/conditional_docs.md b/docs/tasks/conditional_docs.md index fe6a269..07f5e21 100644 --- a/docs/tasks/conditional_docs.md +++ b/docs/tasks/conditional_docs.md @@ -13,3 +13,11 @@ - When working with Brain document persistence, migration, import/export, or version restore - When modifying `src/app/api/brain/*` routes or the `src/lib/brain/*` storage/session layer - When troubleshooting persisted collaboration rooms, Hocuspocus startup, or share-link behavior + +- docs/tasks/feature-spacetimedb-backend-sync-feature/doc-feature-spacetimedb-backend-sync-feature.md + - Conditions: + - When working with SpacetimeDB integration, workspace persistence, or real-time sync + - When modifying files in `src/lib/spacetime/`, `spacetime/nexus/`, or workspace sync bridges + - When configuring `NEXT_PUBLIC_SPACETIME_URI` or SpacetimeDB Docker services + - When troubleshooting workspace mode persistence, multi-user collaboration, or presence + - When migrating data from filesystem-based workspaces to SpacetimeDB diff --git a/docs/tasks/feature-spacetimedb-backend-sync-feature/assets/01_main_page.png b/docs/tasks/feature-spacetimedb-backend-sync-feature/assets/01_main_page.png new file mode 100644 index 0000000..caea8ae Binary files /dev/null and b/docs/tasks/feature-spacetimedb-backend-sync-feature/assets/01_main_page.png differ diff --git a/docs/tasks/feature-spacetimedb-backend-sync-feature/assets/02_workspace_view.png b/docs/tasks/feature-spacetimedb-backend-sync-feature/assets/02_workspace_view.png new file mode 100644 index 0000000..0b472a9 Binary files /dev/null and b/docs/tasks/feature-spacetimedb-backend-sync-feature/assets/02_workspace_view.png differ diff --git a/docs/tasks/feature-spacetimedb-backend-sync-feature/assets/03_app_running.png b/docs/tasks/feature-spacetimedb-backend-sync-feature/assets/03_app_running.png new file mode 100644 index 0000000..9901fb8 Binary files /dev/null and b/docs/tasks/feature-spacetimedb-backend-sync-feature/assets/03_app_running.png differ diff --git a/docs/tasks/feature-spacetimedb-backend-sync-feature/doc-feature-spacetimedb-backend-sync-feature.md b/docs/tasks/feature-spacetimedb-backend-sync-feature/doc-feature-spacetimedb-backend-sync-feature.md new file mode 100644 index 0000000..7fd7e8b --- /dev/null +++ b/docs/tasks/feature-spacetimedb-backend-sync-feature/doc-feature-spacetimedb-backend-sync-feature.md @@ -0,0 +1,130 @@ +# SpacetimeDB Backend Sync + +**ADW ID:** 9b6b801c +**Date:** 2026-04-11 +**Plan:** docs/tasks/feature-spacetimedb-backend-sync-feature/plan-feature-spacetimedb-backend-sync-feature.md + +## Overview + +Adds SpacetimeDB as an optional persistence and real-time synchronization backend for workspace mode. When `NEXT_PUBLIC_SPACETIME_URI` is configured, workspace CRUD, workflow saves, Brain document operations, and multi-user presence all flow through SpacetimeDB instead of the filesystem REST API + Hocuspocus. Standalone editor/localStorage mode is completely unaffected. + +The implementation targets the SpacetimeDB 2.1 TypeScript module and generated client binding APIs. The module source is `spacetime/nexus/src/index.ts`, and generated browser bindings are committed under `src/lib/spacetime/module_bindings/` so the main app can build without running the SpacetimeDB CLI. + +## Screenshots + +![Main page with What's New dialog](assets/01_main_page.png) + +![Workspace view (404 — workspace route requires workspace data)](assets/02_workspace_view.png) + +![App landing page showing Open Editor and Open Workspace options](assets/03_app_running.png) + +## What Was Built + +- SpacetimeDB 2.1 TypeScript module with full table schema and reducers (`spacetime/nexus/src/index.ts`) +- Generated SpacetimeDB 2.1 TypeScript client bindings (`src/lib/spacetime/module_bindings/`) +- Client-side connection manager with generated `DbConnection` API usage, identity persistence, and reconnection (`src/lib/spacetime/client.ts`) +- Workspace sync bridge with loop-prevention pattern (`src/lib/spacetime/workspace-sync.ts`) +- Brain document sync bridge (`src/lib/spacetime/brain-sync.ts`) +- Presence/awareness layer via SpacetimeDB rows (`src/lib/spacetime/presence.ts`) +- SpacetimeDB type definitions and conversion utilities (`src/lib/spacetime/types.ts`) +- Configuration helpers (`src/lib/spacetime/config.ts`) +- Docker Compose service for SpacetimeDB server +- Data migration script (`scripts/migrate-to-spacetime.ts`) +- Binding generation script (`scripts/generate-spacetime-bindings.sh`) +- REST API route deprecation markers +- Unit tests for config and type conversions + +## Technical Implementation + +### Files Modified + +- `src/components/workflow/workflow-editor.tsx`: Conditionally starts SpacetimeDB sync (workspace + brain + presence) instead of Y.js/Hocuspocus when `isSpacetimeConfigured()` returns true; skips REST-based autosave in SpacetimeDB mode; routes selection awareness through SpacetimeDB presence +- `src/store/collaboration/collab-store.ts`: Added SpacetimeDB connection state tracking alongside Hocuspocus state +- `src/lib/brain/client.ts`: Added SpacetimeDB-aware brain operations +- `docker-compose.yml`: Added `nexus-spacetimedb` service using `clockworklabs/spacetime:latest` image on port 30201 +- `Dockerfile`: Added SpacetimeDB CLI installation for operational binding-generation support in the runtime image +- `.env.example`: Added `NEXT_PUBLIC_SPACETIME_URI`, `NEXT_PUBLIC_SPACETIME_DB_NAME`, `SPACETIME_MODULE_PATH` +- `package.json`: Added `@clockworklabs/spacetimedb-sdk` dependency +- `eslint.config.mjs`: Added ESLint ignore entries for SpacetimeDB module files and generated client bindings +- `tsconfig.json`: Updated for SpacetimeDB module compilation +- `CLAUDE.md`: Updated architecture notes documenting SpacetimeDB persistence layer +- REST API routes (`src/app/api/workspaces/`, `src/app/api/brain/`): Marked as deprecated shims + +### New Files + +- `spacetime/nexus/src/index.ts` (578 lines): Full SpacetimeDB 2.1 TypeScript module — 13 table definitions (workspace, workflow, nodes, edges, UI state, brain docs/versions/feedback, presence, change events, invites, members), reducers for CRUD/import operations, and `apply_workflow_ops` batch reducer +- `spacetime/nexus/package.json` + `package-lock.json`: Module-local SpacetimeDB 2.1 package metadata +- `spacetime/nexus/spacetimedb.toml` + `tsconfig.json`: Module configuration +- `src/lib/spacetime/client.ts` (246 lines): Singleton `SpacetimeClient` with WebSocket connection, identity token persistence in localStorage, exponential backoff reconnection, state change event emitters +- `src/lib/spacetime/workspace-sync.ts` (436 lines): Bidirectional sync bridge using `_isApplyingRemote` mutex pattern (mirrored from `collab-doc.ts`), batched node/edge changes, transient React Flow property cleaning +- `src/lib/spacetime/brain-sync.ts` (219 lines): Brain document sync — subscribes to brain_doc/version/feedback rows, replaces REST-based brain API calls with reducer calls +- `src/lib/spacetime/presence.ts` (220 lines): Selection awareness via ephemeral presence rows, 500ms throttled updates, server-side disconnect cleanup +- `src/lib/spacetime/types.ts` (241 lines): TypeScript interfaces for all SpacetimeDB row shapes, reducer payloads, operation types for `apply_workflow_ops`, conversion utilities between SpacetimeDB and Zustand types +- `src/lib/spacetime/config.ts` (33 lines): Configuration helpers — `getSpacetimeUri()`, `getSpacetimeDbName()`, `isSpacetimeConfigured()` +- `scripts/migrate-to-spacetime.ts` (348 lines): Idempotent migration script reads existing filesystem data and calls SpacetimeDB import reducers +- `scripts/generate-spacetime-bindings.sh`: Binding generation for dev/CI +- `src/lib/__tests__/spacetime-config.test.ts` + `spacetime-types.test.ts`: Unit tests + +### Key Changes + +- **Conditional sync path**: The workflow editor detects SpacetimeDB configuration at mount time and starts either SpacetimeDB sync or the legacy Y.js/Hocuspocus path — never both +- **Loop prevention**: The `_isApplyingRemote` flag pattern from `collab-doc.ts` is faithfully replicated in both `workspace-sync.ts` and `brain-sync.ts` to prevent feedback loops between SpacetimeDB subscriptions and Zustand store updates +- **Batched operations**: Node/edge mutations are collected during drag operations and flushed via `apply_workflow_ops` on drag-stop or a 200ms throttle interval +- **Membership checks**: Reducers validate workspace membership before workspace, workflow, Brain, and presence mutations +- **Change events**: Reducers write `workflow_change_event` rows for the recent-changes feed, replacing filesystem-based snapshot diffs + +## How to Use + +1. **Start SpacetimeDB** via Docker Compose: + ```bash + docker compose up nexus-spacetimedb -d + ``` + +2. **Configure environment** by setting these variables (in `.env.local` or environment): + ``` + NEXT_PUBLIC_SPACETIME_URI=ws://localhost:30201 + NEXT_PUBLIC_SPACETIME_DB_NAME=nexus + ``` + +3. **Publish the SpacetimeDB module** (first time or after schema changes): + ```bash + spacetime publish -p spacetime/nexus nexus + ./scripts/generate-spacetime-bindings.sh + ``` + +4. **Start the app** normally: + ```bash + bun run dev + ``` + +5. **Open Workspace mode** — the app automatically uses SpacetimeDB for persistence and sync when the env vars are set + +6. **Migrate existing data** (optional, for existing filesystem-based workspaces): + ```bash + bun run scripts/migrate-to-spacetime.ts + ``` + +## Configuration + +| Variable | Description | Default | +|---|---|---| +| `NEXT_PUBLIC_SPACETIME_URI` | WebSocket URI for SpacetimeDB | Not set (disables SpacetimeDB) | +| `NEXT_PUBLIC_SPACETIME_DB_NAME` | Database/module name | `nexus` | +| `SPACETIME_MODULE_PATH` | Path to SpacetimeDB module source | `spacetime/nexus` | +| `SPACETIME_PORT` | Host port for Docker (docker-compose only) | `30201` | + +When `NEXT_PUBLIC_SPACETIME_URI` is **not set**, the app falls back to the existing filesystem REST API + Hocuspocus persistence layer. Standalone editor mode (no workspace) always uses localStorage regardless of configuration. + +## Testing + +- **Unit tests**: `bun run test -- src/lib/__tests__/spacetime-config.test.ts src/lib/__tests__/spacetime-types.test.ts` +- **Type checking**: `bun run typecheck` +- **Manual E2E**: See `docs/tasks/feature-spacetimedb-backend-sync-feature/e2e-feature-spacetimedb-backend-sync-feature.md` for the full end-to-end test specification covering multi-tab sync, invite links, reconnection, and standalone mode verification + +## Notes + +- Hocuspocus/Y.js is retained for standalone `?room=` collaboration mode — only workspace mode switches to SpacetimeDB +- REST API routes under `src/app/api/workspaces/` and `src/app/api/brain/` are marked as deprecated shims and will be removed once all clients use SpacetimeDB directly +- Node data is stored as JSON strings in SpacetimeDB columns to minimize migration risk and avoid encoding the full discriminated union as strict SpacetimeDB types +- OpenCode local server calls, marketplace Git operations, generated ZIP exports, and browser-only preferences remain outside SpacetimeDB +- The `src/lib/spacetime/module_bindings/` directory contains generated SpacetimeDB 2.1 TypeScript client bindings. Do not hand-edit these files; run `scripts/generate-spacetime-bindings.sh` after module schema changes and commit the regenerated output. diff --git a/docs/tasks/feature-spacetimedb-backend-sync-feature/e2e-feature-spacetimedb-backend-sync-feature.md b/docs/tasks/feature-spacetimedb-backend-sync-feature/e2e-feature-spacetimedb-backend-sync-feature.md new file mode 100644 index 0000000..536dab1 --- /dev/null +++ b/docs/tasks/feature-spacetimedb-backend-sync-feature/e2e-feature-spacetimedb-backend-sync-feature.md @@ -0,0 +1,147 @@ +# E2E Test Specification: SpacetimeDB Backend Sync + +## User Story + +As a workspace user, I want all my workspace data (workspaces, workflows, Brain documents) to persist and sync in real-time through SpacetimeDB, so that I can collaborate with others without relying on the filesystem REST API or Hocuspocus server. + +## Prerequisites + +- SpacetimeDB server running (via `docker compose up` or standalone) +- `NEXT_PUBLIC_SPACETIME_URI` configured and pointing to the SpacetimeDB instance +- `NEXT_PUBLIC_SPACETIME_DB_NAME` set to the published module name +- SpacetimeDB module published (`spacetime publish -p spacetime/nexus nexus`) +- Generated SpacetimeDB client bindings are current (`./scripts/generate-spacetime-bindings.sh` after schema changes) +- App running with `bun run dev` or built and served + +## Test Steps + +### 1. Workspace Creation + +**Action:** Open the app, navigate to workspace mode, create a new workspace named "E2E Test Workspace". + +**Expected:** Workspace appears in the workspace list. Refreshing the page shows the workspace persists. + +**Verify:** Check SpacetimeDB `workspace` table contains a row with the workspace name. + +--- + +### 2. Workflow Creation + +**Action:** Open the created workspace, create a new workflow named "Test Flow". + +**Expected:** Workflow appears in the workspace's workflow list. The workflow editor opens with default Start and End nodes. + +**Verify:** `workflow` table has a row for "Test Flow" with the correct `workspaceId`. + +--- + +### 3. Node and Edge Persistence + +**Action:** Add three nodes (Start, Agent, End) and connect them: Start → Agent → End. + +**Expected:** Nodes and edges appear on the canvas. After page reload, the same nodes and edges are present in their correct positions. + +**Verify:** `workflow_node` and `workflow_edge` tables contain the expected rows. + +--- + +### 4. Multi-Tab Sync — Initial Load + +**Action:** Open the same workspace/workflow URL in a second browser tab. + +**Expected:** Both tabs show the identical workflow with the same nodes, edges, and positions. + +--- + +### 5. Multi-Tab Sync — Node Addition + +**Action:** In Tab 1, drag a new "Prompt" node onto the canvas. + +**Expected:** The new node appears in Tab 2 within 2 seconds. + +**Verify:** `workflow_change_event` table contains a "node_added" event. + +--- + +### 6. Multi-Tab Sync — Node Movement + +**Action:** In Tab 2, drag an existing node to a new position. + +**Expected:** The node's position updates in Tab 1 within 2 seconds. + +--- + +### 7. Multi-Tab Sync — Node Deletion + +**Action:** In Tab 1, select and delete a node. + +**Expected:** The node and its connected edges disappear from Tab 2 within 2 seconds. + +**Verify:** `workflow_change_event` table contains a "node_deleted" event. + +--- + +### 8. Recent Changes Panel + +**Action:** Open the Recent Changes panel in the workspace. + +**Expected:** Change events (node added, deleted, etc.) appear in chronological order, sourced from `workflow_change_event` rows. + +--- + +### 9. Brain Document Persistence + +**Action:** Create a new Brain document titled "E2E Brain Doc" with some content. + +**Expected:** The document persists after page reload. In a second tab, the document appears in the Brain panel. + +**Verify:** `brain_doc` table contains the document row. + +--- + +### 10. Invite-Link Access + +**Action:** Generate an invite link for the workspace. Open the link in an incognito/private window. + +**Expected:** The incognito session connects to SpacetimeDB, calls `join_workspace`, and the workspace loads with all data visible. + +**Verify:** `workspace_member` table shows a new member row for the incognito identity. + +--- + +### 11. Network Disconnection Recovery + +**Action:** With the workspace open, temporarily disable the network connection (or stop the SpacetimeDB server) for 5 seconds, then reconnect. + +**Expected:** The client reconnects automatically. Any changes made during disconnection are not lost (the sync bridge buffers or re-syncs). + +**Verify:** No data corruption; the workflow state matches between tabs after reconnection. + +--- + +### 12. Standalone Mode Isolation + +**Action:** Navigate to the root editor URL (no workspace context). Create a workflow, add nodes, save to library. + +**Expected:** The workflow persists in localStorage. No SpacetimeDB connections are established. The standalone editor behaves identically to pre-SpacetimeDB behavior. + +**Verify:** No WebSocket connections to the SpacetimeDB URI in the browser's network tab. localStorage contains the saved workflow. + +--- + +## Success Criteria + +- All 12 test steps pass without errors +- No data loss during any sync or reconnection scenario +- Sync latency is under 2 seconds for all multi-tab operations +- Standalone editor/localStorage mode is completely unaffected +- Presence indicators (peer avatars, selected node highlights) update correctly between tabs +- Invite-link flow works for anonymous users + +## Screenshots to Capture + +1. Workspace creation confirmation +2. Multi-tab sync showing the same workflow in both tabs +3. Invite-link join in incognito window +4. Network disconnection → reconnection recovery +5. Standalone mode with no SpacetimeDB connections diff --git a/docs/tasks/feature-spacetimedb-backend-sync-feature/plan-feature-spacetimedb-backend-sync-feature.md b/docs/tasks/feature-spacetimedb-backend-sync-feature/plan-feature-spacetimedb-backend-sync-feature.md new file mode 100644 index 0000000..8812e63 --- /dev/null +++ b/docs/tasks/feature-spacetimedb-backend-sync-feature/plan-feature-spacetimedb-backend-sync-feature.md @@ -0,0 +1,368 @@ +# feature: SpacetimeDB Backend Sync + +## Metadata +adw_id: `feature` +issue_description: `SpaceTime — Use SpacetimeDB as the authoritative backend and synchronization layer for workspace mode` + +## Description +Migrate the Nexus Workflow Studio workspace-mode persistence and real-time collaboration layer from the current filesystem + Hocuspocus/Yjs stack to SpacetimeDB. SpacetimeDB provides a unified WebSocket-based data layer with row-level subscriptions, server-side reducers, and automatic client-side caching — replacing both the REST API persistence layer and the Hocuspocus real-time sync in a single system. + +The current architecture has two parallel persistence paths: +1. **REST/filesystem persistence** — Workspace manifests, workflow JSON files, Brain documents, and snapshot versions stored on disk via Next.js API routes (`src/app/api/workspaces/`, `src/app/api/brain/`) +2. **Real-time collaboration** — Hocuspocus server + Yjs documents synced over WebSocket for live multi-user editing (`src/lib/collaboration/`, `scripts/collab-server.ts`) + +SpacetimeDB unifies these into normalized database tables with reducer-based mutations and subscription-driven client updates. The standalone editor/localStorage mode must remain fully functional. + +## Objective +Replace workspace-mode storage and sync paths with SpacetimeDB while preserving standalone editor/localStorage behavior. After completion: +- All workspace CRUD, workflow saves, Brain document operations, and real-time collaboration flow through SpacetimeDB +- The Hocuspocus/Yjs layer is removed from workspace mode (retained only for standalone `?room=` collaboration until deliberately migrated) +- Invite-link access uses SpacetimeDB membership validation before workspace-scoped reducer mutations +- Existing workspace data can be migrated via an idempotent migration script +- Presence/awareness broadcasts through SpacetimeDB presence rows + +## Problem Statement +The current dual-layer architecture (REST + Hocuspocus) creates operational complexity: two separate server processes, two persistence formats (JSON files + binary Yjs state), and two sync mechanisms that must be kept consistent. The `_isApplyingRemote` mutex pattern in `collab-doc.ts` prevents feedback loops but adds fragility. SpacetimeDB can unify persistence and sync into one system with built-in conflict resolution. + +## Solution Statement +Introduce a SpacetimeDB TypeScript module defining normalized workspace tables and reducers. Create a client-side bridge (`src/lib/spacetime/workspace-sync.ts`) that connects to SpacetimeDB, subscribes to workspace rows, and bidirectionally syncs with the Zustand workflow store using a loop-prevention pattern similar to the existing `_isApplyingRemote` approach. Replace Hocuspocus in workspace mode while keeping REST API routes as temporary shims during the transition. + +## Code Patterns to Follow +Reference implementations: +- **Loop prevention pattern**: `src/lib/collaboration/collab-doc.ts` — `_isApplyingRemote` flag pattern for preventing feedback loops between remote updates and local store changes +- **Workspace room ID generation**: `src/lib/collaboration/config.ts` — `buildWorkspaceRoomId()` for stable, deterministic connection identifiers +- **Store structure**: `src/store/workflow/store.ts` — Zustand + Zundo temporal middleware, `loadWorkflow()`, `getWorkflowJSON()` interface +- **Collaboration store**: `src/store/collaboration/collab-store.ts` — connection state management (isConnected, peerCount, isInitializing) +- **Awareness store**: `src/store/collaboration/awareness-store.ts` — presence data management +- **Editor integration**: `src/components/workflow/workflow-editor.tsx` — workspace mode detection (`isWorkspaceMode`), lifecycle management (start/destroy on mount/unmount) +- **Workspace persistence**: `src/lib/workspace/server.ts` — CRUD operations, manifest pattern +- **Brain persistence**: `src/lib/brain/server.ts` — Session management, JWT tokens, soft deletes, versioning +- **Snapshot/change tracking**: `src/lib/workspace/snapshots.ts` — `computeChanges()` for structural diff events + +## Relevant Files +Use these files to complete the task: + +### Existing Files to Modify + +- **`src/lib/workspace/server.ts`** — Current filesystem workspace CRUD; will be wrapped/replaced by SpacetimeDB reducers +- **`src/lib/workspace/snapshots.ts`** — Current snapshot/version tracking; will transition to SpacetimeDB event rows +- **`src/lib/workspace/types.ts`** — WorkspaceRecord, WorkflowRecord types; will need SpacetimeDB equivalents +- **`src/lib/workspace/config.ts`** — Data directory configuration; add SpacetimeDB connection config +- **`src/lib/workspace/schemas.ts`** — Zod validation schemas; extend for SpacetimeDB payloads +- **`src/lib/brain/server.ts`** — Brain document CRUD, sessions, versions, feedback; migrate to SpacetimeDB tables +- **`src/lib/brain/types.ts`** — Brain type definitions; add SpacetimeDB equivalents +- **`src/lib/brain/client.ts`** — Browser-side Brain API wrapper; transition to SpacetimeDB client calls +- **`src/lib/brain/config.ts`** — Brain configuration; add SpacetimeDB connection vars +- **`src/lib/collaboration/collab-doc.ts`** — CollabDoc singleton; workspace mode will use SpacetimeDB sync instead +- **`src/lib/collaboration/config.ts`** — Collaboration URL/room config; add SpacetimeDB URI config +- **`src/lib/collaboration/object-store.ts`** — Binary Yjs persistence; will be obsoleted for workspace mode +- **`src/store/workflow/store.ts`** — Main Zustand store; needs SpacetimeDB subscription integration +- **`src/store/collaboration/collab-store.ts`** — Connection state; adapt for SpacetimeDB connection lifecycle +- **`src/store/collaboration/awareness-store.ts`** — Presence; transition to SpacetimeDB presence rows +- **`src/store/knowledge/store.ts`** — Brain documents store; transition to SpacetimeDB subscriptions +- **`src/components/workflow/workflow-editor.tsx`** — Editor integration; switch workspace mode from CollabDoc to SpacetimeDB sync +- **`src/app/api/workspaces/route.ts`** — List/create workspace REST API; temporary shim, then removal +- **`src/app/api/workspaces/[id]/route.ts`** — Get workspace REST API; temporary shim +- **`src/app/api/workspaces/[id]/workflows/[workflowId]/route.ts`** — Workflow CRUD REST API; temporary shim +- **`src/app/api/workspaces/[id]/workflows/[workflowId]/snapshots/route.ts`** — Snapshot REST API; temporary shim +- **`src/app/api/workspaces/[id]/changes/route.ts`** — Changes REST API; replace with event row queries +- **`src/app/api/brain/session/route.ts`** — Brain session REST API; temporary shim +- **`src/app/api/brain/documents/route.ts`** — Brain documents REST API; temporary shim +- **`docker-compose.yml`** — Add SpacetimeDB service container +- **`Dockerfile`** — Include SpacetimeDB CLI for binding generation +- **`.env.example`** — Add SpacetimeDB environment variables +- **`package.json`** — Add `@clockworklabs/spacetimedb-sdk` dependency +- **`CLAUDE.md`** — Update architecture notes for SpacetimeDB + +### New Files + +- **`spacetime/nexus/`** — SpacetimeDB TypeScript module directory + - **`spacetime/nexus/src/index.ts`** — Main SpacetimeDB 2.1 TypeScript module: table definitions, reducers, lifecycle hooks + - **`spacetime/nexus/spacetimedb.toml`** — Module configuration + - **`spacetime/nexus/tsconfig.json`** — TypeScript config for the module +- **`src/lib/spacetime/client.ts`** — SpacetimeDB client connection manager (DbConnection wrapper, identity token persistence, reconnection logic) +- **`src/lib/spacetime/workspace-sync.ts`** — Bidirectional sync bridge: SpacetimeDB subscriptions ↔ Zustand store with loop-prevention +- **`src/lib/spacetime/brain-sync.ts`** — Brain document sync bridge: SpacetimeDB subscriptions ↔ Brain store +- **`src/lib/spacetime/presence.ts`** — Presence/awareness via SpacetimeDB presence rows +- **`src/lib/spacetime/config.ts`** — SpacetimeDB connection configuration (URI, DB name, module path) +- **`src/lib/spacetime/types.ts`** — TypeScript types for SpacetimeDB row shapes and reducer payloads +- **`src/lib/spacetime/module_bindings/`** — Generated TypeScript client bindings (auto-generated, do not hand-edit) +- **`scripts/migrate-to-spacetime.ts`** — Idempotent migration script: reads existing filesystem data → calls SpacetimeDB import reducers +- **`scripts/generate-spacetime-bindings.sh`** — Binding generation script for dev/CI +- **`docs/tasks/feature-spacetimedb-backend-sync-feature/e2e-feature-spacetimedb-backend-sync-feature.md`** — E2E test specification + +### Reference Files (read for context, do not modify unless necessary) + +- **`CLAUDE.md`** — Project coding rules and conventions +- **`docs/tasks/conditional_docs.md`** — Conditional documentation guide +- **`docs/tasks/persistent-brain/doc-persistent-brain.md`** — Brain persistence documentation (read per conditional_docs.md — this task modifies Brain persistence) +- **`scripts/collab-server.ts`** — Current Hocuspocus server (reference for replacement) + +## Implementation Plan + +### Phase 1: Foundation +Set up the SpacetimeDB module, define the database schema as normalized tables, implement reducers for all mutations, and generate TypeScript client bindings. Establish the client connection manager with identity persistence and reconnection. + +Key decisions: +- Use TypeScript module support so backend schema/reducers stay close to the existing TS codebase +- Store `WorkflowNodeData` as JSON strings initially (not strict SpacetimeDB types) to minimize migration risk +- Validate workspace membership in reducers before workspace-scoped mutations +- Keep invite token flow: client connects → calls `join_workspace(token)` → reducer validates + records membership → subsequent workspace-scoped reducers accept that identity + +### Phase 2: Core Implementation +Build the client-side sync bridges that connect SpacetimeDB subscriptions to Zustand stores. Implement the workspace sync bridge with loop-prevention (mirroring the `_isApplyingRemote` pattern), the Brain document sync bridge, and the presence layer. Key concerns: +- Batch graph changes on drag-stop or throttled intervals (avoid per-pixel reducer calls) +- Use `apply_workflow_ops(workflowId, ops[])` for batched node/edge upserts/deletes +- Write `workflow_change_event` rows from reducers for the recent-changes feed +- Implement presence via ephemeral rows with `lastSeenAt` timestamps and disconnect cleanup + +### Phase 3: Integration +Wire SpacetimeDB sync into the workflow editor, replacing Hocuspocus for workspace mode. Update stores, hooks, and components. Keep REST API routes as temporary compatibility shims so the UI migration can be incremental. Add Docker service configuration. Write migration script for existing data. + +Key constraints: +- Standalone editor/localStorage mode must remain fully functional +- Keep CollabDoc only for standalone `?room=` collaboration +- OpenCode local server calls stay browser/Next-side (SpacetimeDB cannot reach user's machine) +- Marketplace Git operations stay filesystem-based +- Generated ZIP exports stay in browser/Bun code +- Browser-only preferences stay in localStorage + +## Step by Step Tasks +IMPORTANT: Execute every step in order, top to bottom. + +### 1. Set Up SpacetimeDB Module Structure +- Create `spacetime/nexus/` directory with `spacetimedb.toml`, `tsconfig.json` +- Create `spacetime/nexus/src/index.ts` with all table definitions: + - `workspace`: id (string, primary), name, createdAt, updatedAt + - `workspace_member`: workspaceId, identity, displayName, role, joinedAt + - `workspace_invite`: workspaceId, tokenHash, createdAt, revokedAt + - `workflow`: id (string, primary), workspaceId, name, createdAt, updatedAt, lastModifiedBy + - `workflow_node`: workflowId, nodeId, type, positionJson, dataJson, updatedAt, updatedBy + - `workflow_edge`: workflowId, edgeId, source, target, handlesJson, dataJson, updatedAt, updatedBy + - `workflow_ui_state`: workflowId, uiStateJson + - `brain_doc`: id, workspaceId, title, contentJson, createdAt, updatedAt, deletedAt + - `brain_doc_version`: docId, versionId, contentJson, createdAt + - `brain_feedback`: docId, identity, type, comment, createdAt + - `workflow_change_event`: workflowId, eventType, nodeId, details, timestamp (append-only) + - `presence`: workspaceId, workflowId, identity, displayName, selectedNodeId, lastSeenAt +- Validate membership in reducers with `ctx.sender` before workspace-scoped mutations +- Implement identity lifecycle hooks (`__identity_connected__`, `__identity_disconnected__`) + +### 2. Implement SpacetimeDB Reducers +- Workspace reducers: `create_workspace`, `rename_workspace`, `delete_workspace` +- Invite reducers: `create_invite`, `join_workspace` +- Workflow reducers: `create_workflow`, `rename_workflow`, `delete_workflow` +- Batch operation reducer: `apply_workflow_ops(workflowId, ops[])` for node/edge upserts/deletes + - Each operation writes a `workflow_change_event` row for the recent-changes feed +- UI state reducer: `update_workflow_ui_state` +- Brain reducers: `save_brain_doc`, `delete_brain_doc`, `record_brain_view`, `add_brain_feedback`, `restore_brain_doc_version` +- Presence reducer: `update_presence` (called on selection change, throttled) +- Disconnect cleanup: clear presence rows in `__identity_disconnected__` + +### 3. Generate TypeScript Client Bindings +- Create `scripts/generate-spacetime-bindings.sh` to run `spacetime generate --lang typescript --out-dir src/lib/spacetime/module_bindings --module-path spacetime/nexus` +- Generate bindings and commit to `src/lib/spacetime/module_bindings/` +- Add `@clockworklabs/spacetimedb-sdk` to `package.json` dependencies +- Add `.gitignore` entry or build script note for regeneration + +### 4. Create SpacetimeDB Client Connection Manager +- Create `src/lib/spacetime/config.ts` with configuration: + - `NEXT_PUBLIC_SPACETIME_URI` (WebSocket URI) + - `NEXT_PUBLIC_SPACETIME_DB_NAME` (database name) + - Helper to check if SpacetimeDB is configured +- Create `src/lib/spacetime/client.ts`: + - Singleton `DbConnection` wrapper + - Identity token persistence in localStorage (keyed by DB name) + - Automatic reconnection with exponential backoff + - Connection lifecycle methods: `connect()`, `disconnect()`, `isConnected()` + - Event emitters for connection state changes + +### 5. Create SpacetimeDB Type Definitions +- Create `src/lib/spacetime/types.ts` with TypeScript interfaces matching SpacetimeDB table schemas +- Define reducer argument types +- Define operation types for `apply_workflow_ops` (AddNode, UpdateNode, DeleteNode, AddEdge, UpdateEdge, DeleteEdge) +- Map between SpacetimeDB row types and existing `WorkflowNode`, `WorkflowEdge`, `WorkspaceRecord`, `WorkflowRecord` types + +### 6. Implement Workspace Sync Bridge +- Create `src/lib/spacetime/workspace-sync.ts`: + - Connect with generated `DbConnection` + - Subscribe to workspace/workflow rows using generated subscription queries + - Implement `_isApplyingRemote` flag pattern (mirror `collab-doc.ts` approach): + - On row insert/update/delete callbacks: set flag → update Zustand store → clear flag + - On Zustand store change subscription: check flag → skip if applying remote → else emit reducer call + - Batch node/edge changes: collect mutations during drag operations, flush on drag-stop or 200ms throttle + - Convert between SpacetimeDB row format (JSON strings for node data) and Zustand workflow format (typed objects) + - Methods: `startSync(workspaceId, workflowId)`, `stopSync()`, `isActive()` + - Clean transient React Flow properties before syncing (same list as `collab-doc.ts`: measured, selected, dragging, etc.) + +### 7. Implement Brain Document Sync Bridge +- Create `src/lib/spacetime/brain-sync.ts`: + - Subscribe to `brain_doc`, `brain_doc_version`, `brain_feedback` rows for the current workspace + - Sync row changes into the Brain Zustand store (`src/store/knowledge/store.ts`) + - Replace REST-based `saveBrainDoc()`, `deleteBrainDoc()`, `listVersions()`, `restoreVersion()`, `addFeedback()` with reducer calls + - Handle soft deletes (set `deletedAt` via reducer, filter in view) + - Methods: `startBrainSync(workspaceId)`, `stopBrainSync()` + +### 8. Implement Presence Layer +- Create `src/lib/spacetime/presence.ts`: + - Subscribe to `presence` rows for the current workspace + - On local selection change: call `update_presence` reducer (throttled to ~500ms) + - On remote presence row changes: update awareness store (`src/store/collaboration/awareness-store.ts`) + - On disconnect: server-side cleanup via `__identity_disconnected__` + - Map SpacetimeDB identity → display name using `workspace_member` rows + - Methods: `startPresence(workspaceId, workflowId)`, `stopPresence()`, `updateSelection(nodeId)` + +### 9. Update Workflow Editor for SpacetimeDB Integration +- Modify `src/components/workflow/workflow-editor.tsx`: + - In workspace mode: start SpacetimeDB sync instead of CollabDoc + - On mount: `spacetimeWorkspaceSync.startSync(workspaceId, workflowId)` + `spacetimePresence.startPresence()` + - On unmount: `spacetimeWorkspaceSync.stopSync()` + `spacetimePresence.stopPresence()` + - Keep CollabDoc path only for standalone `?room=` collaboration + - Update `useWorkspaceAutosave` hook: in SpacetimeDB mode, the reducer calls handle persistence — remove or skip REST-based auto-save +- Update `src/store/collaboration/collab-store.ts` to track SpacetimeDB connection state alongside (or replacing) Hocuspocus state +- Update awareness sync to use SpacetimeDB presence instead of Yjs awareness in workspace mode + +### 10. Implement Invite-Link Access Control +- In the SpacetimeDB module: validate invite tokens and member identity before workspace-scoped mutations +- Update workspace join flow: + - Client opens `/workspace/[id]?invite=...` + - Client connects to SpacetimeDB (anonymous identity, token persisted) + - Client calls `join_workspace(inviteToken)` reducer + - Reducer validates token hash → records `workspace_member` row + - Views then expose workspace data to the new member +- Update `src/lib/brain/client.ts` brain session bootstrap to work with SpacetimeDB identity instead of JWT tokens + +### 11. Keep REST API Routes as Temporary Shims +- Update workspace API routes (`src/app/api/workspaces/`) to proxy through to SpacetimeDB where possible, or mark as deprecated +- Update Brain API routes (`src/app/api/brain/`) similarly +- Add deprecation comments noting these will be removed once all client code uses SpacetimeDB directly +- Ensure non-workspace-mode paths (if any use these routes) continue to work + +### 12. Replace Recent Changes with Event Rows +- In SpacetimeDB module: workflow reducers already write `workflow_change_event` rows (from Step 2) +- Update `src/app/api/workspaces/[id]/changes/route.ts` to query SpacetimeDB event rows instead of computing diffs from filesystem snapshots +- Or: have the client subscribe to `workflow_change_event` rows directly and remove the REST endpoint +- Remove dependency on `src/lib/workspace/snapshots.ts` for real-time change tracking (keep snapshots only for optional export/recovery) + +### 13. Write Data Migration Script +- Create `scripts/migrate-to-spacetime.ts`: + - Read existing data from: + - `.nexus-brain/workspaces/**` (Brain documents) + - `.nexus-brain/manifest.json` (Brain metadata) + - Workspace data directory (workflow JSON files, manifests) + - Snapshot files from `src/lib/workspace/snapshots.ts` paths + - Call SpacetimeDB import reducers to populate tables + - Preserve existing IDs so workspace URLs keep working + - Make the script idempotent (check if rows exist before inserting) + - Log progress and any skipped/failed items + +### 14. Update Docker/Deployment Configuration +- Update `docker-compose.yml`: + - Add `nexus-spacetimedb` service running SpacetimeDB server + - Mount data volume for SpacetimeDB persistence + - Set environment variables: `NEXT_PUBLIC_SPACETIME_URI`, `NEXT_PUBLIC_SPACETIME_DB_NAME` + - Run SpacetimeDB as a separate service; publish the module with the SpacetimeDB CLI during setup or after schema changes +- Update `Dockerfile`: + - Install SpacetimeDB CLI for binding generation during build + - Keep generated bindings committed so regular app builds do not require the SpacetimeDB CLI +- Update `.env.example` with new variables: + - `NEXT_PUBLIC_SPACETIME_URI=ws://localhost:3001` + - `NEXT_PUBLIC_SPACETIME_DB_NAME=nexus` + - `SPACETIME_MODULE_PATH=spacetime/nexus` +- Consider a CI check that fails when generated bindings are stale + +### 15. Add Unit and Integration Tests +- Test SpacetimeDB client connection manager (connect, disconnect, reconnect, identity persistence) +- Test workspace sync bridge loop-prevention (verify no feedback loops) +- Test batch operation coalescing (multiple rapid changes → single reducer call) +- Test presence throttling (rapid selection changes → throttled updates) +- Test type conversions between SpacetimeDB rows and Zustand workflow types +- Test migration script with sample data fixtures + +### 16. Create E2E Test Specification +- Create `docs/tasks/feature-spacetimedb-backend-sync-feature/e2e-feature-spacetimedb-backend-sync-feature.md` with: + - **User Story**: Validate that workspace mode works end-to-end with SpacetimeDB as the persistence and sync backend + - **Test Steps**: + 1. Open app, create a new workspace — verify workspace appears in list + 2. Create a workflow in the workspace — verify workflow is saved + 3. Add nodes (Start, Agent, End) and connect them — verify nodes persist after page reload + 4. Open the same workspace in a second browser tab — verify both tabs show the same workflow + 5. Add a node in tab 1 — verify it appears in tab 2 within 2 seconds + 6. Move a node in tab 2 — verify position updates in tab 1 + 7. Delete a node in tab 1 — verify it disappears from tab 2 + 8. Check recent changes panel — verify change events appear + 9. Create a Brain document — verify it persists and appears in both tabs + 10. Generate an invite link — open in incognito — verify workspace loads after joining + 11. Disconnect network briefly — reconnect — verify sync resumes without data loss + 12. Switch to standalone mode (no workspace) — verify localStorage persistence still works + - **Success Criteria**: All steps pass, no data loss, sub-2-second sync latency, standalone mode unaffected + - **Screenshots**: Capture at workspace creation, multi-tab sync, invite join, and reconnection states + +### 17. Update Documentation +- Update `CLAUDE.md` architecture notes to reflect SpacetimeDB as the workspace persistence/sync layer +- Add SpacetimeDB section to deployment docs +- Document new environment variables +- Note that Hocuspocus remains only for standalone `?room=` collaboration + +### 18. Run Validation Commands +- Execute all validation commands to confirm zero regressions +- Verify standalone editor mode is unaffected +- Verify workspace mode operates through SpacetimeDB + +## Testing Strategy + +### Unit Tests +- SpacetimeDB client connection lifecycle (connect, disconnect, reconnect, identity token persistence) +- Workspace sync bridge: loop-prevention flag behavior, batch coalescing, transient property cleaning +- Brain sync bridge: CRUD operations via reducers, soft delete handling, version restore +- Presence: throttling behavior, disconnect cleanup +- Type conversion utilities: SpacetimeDB rows ↔ Zustand types +- Migration script: idempotency, ID preservation, error handling + +### Edge Cases +- Simultaneous edits to the same node from two clients (last-write-wins at row level) +- Rapid drag operations (batch coalescing must not lose intermediate state) +- Network disconnection during a reducer call (reconnection + retry behavior) +- Invite token reuse after revocation (reducer must reject) +- Empty workspace (no workflows) — subscription returns no rows, UI handles gracefully +- Large workflows (500+ nodes) — subscription performance, batch size limits +- Migration of corrupted or partial filesystem data — script must log and continue +- Browser tab close during sync — cleanup without leaving orphaned presence rows +- Concurrent workspace deletion while another user is editing — graceful degradation + +## Acceptance Criteria +- All workspace CRUD operations (create, rename, delete) work through SpacetimeDB reducers +- All workflow operations (create, save, rename, delete) persist via SpacetimeDB tables +- Real-time multi-user collaboration works via SpacetimeDB subscriptions (no Hocuspocus in workspace mode) +- Node/edge changes sync between clients within 2 seconds +- Batch operation reducer handles drag-stop and throttled interval flushes +- Brain document CRUD, versioning, and feedback work through SpacetimeDB +- Presence/awareness shows selected nodes and connected peers via SpacetimeDB rows +- Invite-link access control validates membership before workspace-scoped reducer mutations +- Existing workspace data can be migrated via `scripts/migrate-to-spacetime.ts` +- Recent changes panel uses `workflow_change_event` rows instead of filesystem snapshots +- Standalone editor/localStorage mode is completely unaffected +- CollabDoc still works for standalone `?room=` collaboration +- Docker deployment includes SpacetimeDB service +- TypeScript typecheck passes (`bun run typecheck`) +- Lint passes (`bun run lint`) +- Build succeeds (`bun run build`) +- All existing tests pass + +## Validation Commands +Execute every command to validate the work is complete with zero regressions. + +```bash +bun run typecheck +bun run lint +bun run build +``` + +## Notes +- SpacetimeDB TypeScript module support means the backend schema and reducers are written in TypeScript, keeping them close to the existing codebase and reducing context-switching +- Use JSON strings for `WorkflowNodeData` in SpacetimeDB columns initially — this avoids encoding the full discriminated union as strict SpacetimeDB types and reduces migration risk +- The `_isApplyingRemote` pattern from `collab-doc.ts` is well-tested and should be faithfully replicated in the SpacetimeDB sync bridge +- OpenCode local server calls, marketplace Git operations, generated ZIP exports, and browser-only preferences must stay outside SpacetimeDB (see issue description section 10) +- SpacetimeDB docs references: [Clients](https://spacetimedb.com/docs/clients/), [TypeScript Reference](https://spacetimedb.com/docs/clients/typescript/), [Table Access Permissions](https://spacetimedb.com/docs/tables/access-permissions/), [Using Auth Claims](https://spacetimedb.com/docs/how-to/using-auth-claims/), [Procedures](https://spacetimedb.com/docs/functions/procedures/), [File Storage](https://spacetimedb.com/docs/tables/file-storage/) +- Consider using SpacetimeDB procedures for any operations that need to call external HTTP services (e.g., if future workspace features need outbound calls) +- For very large linked files, use external object storage and store references in SpacetimeDB rows diff --git a/docs/tasks/feature-workspace-recent-changes-panel-857b7bc9/e2e-feature-workspace-recent-changes-panel-857b7bc9.md b/docs/tasks/feature-workspace-recent-changes-panel-857b7bc9/e2e-feature-workspace-recent-changes-panel-857b7bc9.md new file mode 100644 index 0000000..d86cb52 --- /dev/null +++ b/docs/tasks/feature-workspace-recent-changes-panel-857b7bc9/e2e-feature-workspace-recent-changes-panel-857b7bc9.md @@ -0,0 +1,78 @@ +# E2E Test Specification: Workspace Recent Changes Panel + +## User Story +Validate that a returning user sees a changes panel on the workspace dashboard showing node-level changes made by other users since their last visit. + +## Preconditions +- The application is running at `http://localhost:3000`. +- No pre-existing workspace data (clean slate or known workspace ID). + +## Test Steps + +### Setup via API + +1. **Create a workspace** via `POST /api/workspaces` with body `{ "name": "E2E Changes Test" }`. Capture `workspace.id`. +2. **Create a workflow** via `POST /api/workspaces/{id}/workflows` with body `{ "name": "My Workflow" }`. Capture `workflow.id`. +3. **Save workflow with initial nodes** via `PUT /api/workspaces/{id}/workflows/{wid}` with body: + ```json + { + "lastModifiedBy": "Alice", + "data": { + "name": "My Workflow", + "nodes": [ + { "id": "n1", "type": "start", "position": { "x": 0, "y": 0 }, "data": { "type": "start", "label": "Start", "name": "Start" } }, + { "id": "n2", "type": "prompt", "position": { "x": 200, "y": 0 }, "data": { "type": "prompt", "label": "Ask Question", "name": "Ask Question", "promptText": "", "detectedVariables": [], "brainDocId": null } } + ], + "edges": [], + "ui": { "sidebarOpen": true, "minimapVisible": false, "viewport": { "x": 0, "y": 0, "zoom": 1 } } + } + } + ``` +4. **Wait briefly** (500ms), then **save again** with an added node and `lastModifiedBy: "Bob"`: + ```json + { + "lastModifiedBy": "Bob", + "data": { + "name": "My Workflow", + "nodes": [ + { "id": "n1", "type": "start", "position": { "x": 0, "y": 0 }, "data": { "type": "start", "label": "Start", "name": "Start" } }, + { "id": "n2", "type": "prompt", "position": { "x": 200, "y": 0 }, "data": { "type": "prompt", "label": "Ask Question", "name": "Ask Question", "promptText": "", "detectedVariables": [], "brainDocId": null } }, + { "id": "n3", "type": "script", "position": { "x": 400, "y": 0 }, "data": { "type": "script", "label": "Process Data", "name": "Process Data", "promptText": "", "detectedVariables": [] } } + ], + "edges": [], + "ui": { "sidebarOpen": true, "minimapVisible": false, "viewport": { "x": 0, "y": 0, "zoom": 1 } } + } + } + ``` + +### Browser Test Steps + +5. **Set localStorage** key `nexus:workspace-last-seen:{workspaceId}` to a timestamp **before** both saves (e.g., 1 hour ago). +6. **Navigate** to `/workspace/{workspaceId}`. +7. **Assert** the changes panel slides in from the right side of the viewport. +8. **Assert** the panel header shows a change count and "since {formatted date}". +9. **Assert** the workflow name "My Workflow" appears as a group header in the panel. +10. **Assert** individual change events show correct user names ("Alice", "Bob") and node names ("Start", "Ask Question", "Process Data"). +11. **Assert** colored initial badges are visible (round circles with first letter of user name). +12. **Click "Dismiss"** (the X button) — assert the panel slides out and is no longer visible. +13. **Reload the page** — assert the panel re-appears (last-seen was updated on the prior load, but the saves still happened after the original `since` time set in step 5; however, the new `since` from the markSeen call means only changes after the previous page load would show — depending on timing, panel may or may not appear. To guarantee it appears, reset localStorage again before reload). +14. **Screenshot capture** at: panel visible state, after dismiss. + +### No-Changes Scenario + +15. **Set localStorage** `nexus:workspace-last-seen:{workspaceId}` to the **current** time. +16. **Reload** the page. +17. **Assert** no changes panel appears. + +## Success Criteria +- Panel appears with correct change data grouped by workflow. +- Dismiss works — panel slides out and does not re-appear for the rest of the session. +- Colored initial badges use consistent color hashing (same name = same color). +- The `node_added` events for "Start", "Ask Question" (from Alice's save) and "Process Data" (from Bob's save) are all shown. +- No `node_moved` events appear when only position changes occur. +- Panel does not appear when `last-seen` is set to current time. + +## Edge Cases to Verify +- Empty workspace (no workflows) — no panel shown. +- Workflow with no snapshots — no panel shown. +- Very long node names — panel content scrolls. diff --git a/docs/tasks/feature-workspace-recent-changes-panel-857b7bc9/patches/patch-feature-workspace-recent-changes-panel-857b7bc9-1.md b/docs/tasks/feature-workspace-recent-changes-panel-857b7bc9/patches/patch-feature-workspace-recent-changes-panel-857b7bc9-1.md new file mode 100644 index 0000000..fc2ad42 --- /dev/null +++ b/docs/tasks/feature-workspace-recent-changes-panel-857b7bc9/patches/patch-feature-workspace-recent-changes-panel-857b7bc9-1.md @@ -0,0 +1,64 @@ +# Patch: Differentiate Open Workspace and New Workspace actions + +## Metadata +adw_id: `docs/tasks/feature-workspace-recent-changes-panel-857b7bc9/patches/patch-feature-workspace-recent-changes-panel-857b7bc9-1.md` +review_change_request: `The workspace management needs to be improved. We need to have a way to edit and select different workspaces. Right now, there's just a recent history dropdown, but that's confusing. "Open" should open a list of the workspaces you currently have, and "New" should create a new one. Right now they both do the same function.` + +## Issue Summary +**Original Plan:** docs/tasks/feature-workspace-recent-changes-panel-857b7bc9/plan-feature-workspace-recent-changes-panel-857b7bc9.md +**Issue:** In `src/components/workspace/landing-page.tsx`, both the "Open Workspace" card button and the "New workspace" button call the same `handleNewWorkspace()` handler, which always creates a new workspace via `POST /api/workspaces`. There is no way to browse and select an existing workspace — the only path to existing workspaces is through the "Recent workspaces" list below, which is not intuitive. +**Solution:** +1. Add a `GET` handler to the `/api/workspaces` route that lists all workspace directories from disk. +2. Add a `listWorkspaces()` function to `server.ts`. +3. Change the "Open Workspace" button to open a dialog/sheet that fetches and displays all existing workspaces for selection. +4. Keep the "New workspace" button as-is (creates a new workspace). + +## Files to Modify + +- **`src/lib/workspace/server.ts`** — Add `listWorkspaces()` function to scan the data directory for workspace manifests. +- **`src/app/api/workspaces/route.ts`** — Add `GET` handler that calls `listWorkspaces()`. +- **`src/components/workspace/landing-page.tsx`** — Change "Open Workspace" button to open a workspace picker dialog instead of creating a new workspace. Add workspace picker dialog with loading state, empty state, and clickable workspace entries. + +## Implementation Steps +IMPORTANT: Execute every step in order, top to bottom. + +### Step 1: Add `listWorkspaces()` to server.ts +- In `src/lib/workspace/server.ts`, add a new exported function `listWorkspaces()` that: + 1. Reads the workspace data directory (`getWorkspaceConfig().dataDir`). + 2. Lists subdirectories using `fs.readdir` with `withFileTypes: true`. + 3. For each subdirectory, attempts to read its `manifest.json` via `readJsonFile`. + 4. Returns an array of `WorkspaceRecord` objects (id, name, createdAt, updatedAt) sorted by `updatedAt` descending. + 5. Gracefully skips directories without a valid manifest. + +### Step 2: Add GET handler to `/api/workspaces` route +- In `src/app/api/workspaces/route.ts`, add a `GET` handler: + - Calls `listWorkspaces()` from `server.ts`. + - Returns `{ workspaces: WorkspaceRecord[] }` as JSON. + - Wraps in try/catch with 500 error handling, matching existing POST handler pattern. + +### Step 3: Update landing page with workspace picker +- In `src/components/workspace/landing-page.tsx`: + - Add `showPicker` state (boolean, default false). + - Change the "Open Workspace" button's `onClick` to set `showPicker(true)`. + - Add an inline workspace picker section (rendered conditionally when `showPicker` is true) that: + 1. Fetches `GET /api/workspaces` on open via a `useEffect`. + 2. Shows a loading spinner while fetching. + 3. If no workspaces exist, shows "No workspaces yet" empty state with a prompt to create one. + 4. Lists workspaces as clickable rows (name, last updated time) — clicking navigates to `/workspace/{id}`. + 5. Has a "Cancel" or close button to hide the picker. + - Use existing theme tokens (`BG_SURFACE`, `BORDER_DEFAULT`, `TEXT_PRIMARY`, `TEXT_MUTED`) and patterns from `recent-workspaces.tsx` for consistent styling. + - Keep the "New workspace" button unchanged — it continues to call `handleNewWorkspace()`. + +## Validation +Execute every command to validate the patch is complete with zero regressions. + +```bash +bun run typecheck +bun run lint +bun run build +``` + +## Patch Scope +**Lines of code to change:** ~80-100 +**Risk level:** low +**Testing required:** Manual verification that "Open Workspace" shows a picker of existing workspaces, "New workspace" creates a new workspace, and existing recent workspaces list still works. diff --git a/docs/tasks/feature-workspace-recent-changes-panel-857b7bc9/plan-feature-workspace-recent-changes-panel-857b7bc9.md b/docs/tasks/feature-workspace-recent-changes-panel-857b7bc9/plan-feature-workspace-recent-changes-panel-857b7bc9.md new file mode 100644 index 0000000..ad54b02 --- /dev/null +++ b/docs/tasks/feature-workspace-recent-changes-panel-857b7bc9/plan-feature-workspace-recent-changes-panel-857b7bc9.md @@ -0,0 +1,240 @@ +# feature: Workspace Recent Changes Panel + +## Metadata +adw_id: `857b7bc9` +issue_description: `Workspace Recent Changes — snapshot-per-save system, server-side diff computation, per-browser last-seen tracking, and a dashboard changes panel that surfaces node-level workflow changes since last visit.` + +## Description +When a team member returns to a workspace after time away, they have no visibility into what changed while they were gone. This feature adds a lightweight audit trail via periodic server snapshots (triggered on every PUT workflow save) and a dashboard-side diff panel that surfaces workflow-level summaries (who edited, when) and expandable node-level changes (added, removed, renamed nodes) since the user's last visit. + +## Objective +Implement the full snapshot + diff + changes panel pipeline so that returning users see a "what changed" panel on the workspace dashboard, showing per-workflow node-level events (added, deleted, renamed) attributed to the user who saved them. + +## Problem Statement +Returning workspace users have zero visibility into changes made by teammates while they were away. They must manually open each workflow and inspect it to understand what changed, which is slow and error-prone. + +## Solution Statement +1. **Snapshot system**: On every `PUT /api/workspaces/[id]/workflows/[wid]` save, write an append-only timestamped snapshot of the workflow JSON to disk. +2. **Diff computation API**: A new `GET /api/workspaces/[id]/changes?since=...` endpoint walks snapshots chronologically, diffs adjacent pairs at the node level, and returns structured change events. +3. **Last-seen tracking**: Per-browser localStorage key tracks when the user last opened the dashboard; used as the `since` baseline. +4. **Changes panel UI**: A slide-in panel on the dashboard shows grouped node-level changes with user attribution and colored initial badges. + +## Code Patterns to Follow +Reference implementations: +- **Server file operations**: `src/lib/workspace/server.ts` — `writeJsonFile`, `readJsonFile`, `ensureDir`, atomic file writes, manifest read/update pattern. +- **API route pattern**: `src/app/api/workspaces/[id]/workflows/[wid]/route.ts` — Zod validation, try/catch, `NextResponse.json`. +- **Dashboard components**: `src/components/workspace/dashboard.tsx`, `workflow-card.tsx` — theme tokens, responsive grid, component composition. +- **Color hashing**: `src/lib/collaboration/awareness-names.ts` — `getColorForClientId()` for deterministic color from a name string. +- **Hooks pattern**: `src/hooks/use-workspace.ts` — fetch + state + loading/error + refetch. +- **Zod schemas**: `src/lib/workspace/schemas.ts` — import from `"zod/v4"`. +- **Theme tokens**: `src/lib/theme.ts` — `BG_APP`, `BG_SURFACE`, `TEXT_PRIMARY`, `TEXT_MUTED`, `BORDER_DEFAULT`. +- **Workspace types**: `src/lib/workspace/types.ts` — interface-based type definitions. +- **Workspace config**: `src/lib/workspace/config.ts` — `getWorkspaceConfig().dataDir` for data directory path. + +## Relevant Files +Use these files to complete the task: + +### Existing Files to Modify +- **`src/lib/workspace/server.ts`** — Add `writeSnapshot()` call inside `saveWorkflow()`, plus new functions: `listSnapshots()`, `getSnapshot()`, `computeChanges()`. +- **`src/lib/workspace/types.ts`** — Add snapshot and change event type definitions. +- **`src/app/api/workspaces/[id]/workflows/[wid]/route.ts`** — Modify PUT handler to call snapshot writer after save. +- **`src/components/workspace/dashboard.tsx`** — Integrate changes fetch, last-seen read/write, and render the changes panel. +- **`src/hooks/use-workspace.ts`** — Optionally extend or keep separate; the changes fetch may be a dedicated hook. + +### Existing Files to Read (Reference Only) +- **`CLAUDE.md`** — Project conventions, import rules (`@/*` alias, `zod/v4`), dark theme, guardrails. +- **`src/lib/workspace/config.ts`** — `getWorkspaceConfig().dataDir` for building snapshot paths. +- **`src/lib/workspace/schemas.ts`** — Zod schema pattern to follow for new schemas. +- **`src/lib/collaboration/awareness-names.ts`** — `getColorForClientId()` and `HUE_SLOTS` for badge colors. Need a name-based variant since changes panel uses display names, not client IDs. +- **`src/lib/theme.ts`** — Theme tokens for consistent styling. +- **`src/components/workspace/workflow-card.tsx`** — Card styling patterns. +- **`src/components/workspace/workspace-header.tsx`** — Header layout pattern. + +### New Files +- **`src/app/api/workspaces/[id]/workflows/[wid]/snapshots/route.ts`** — `GET` handler returning snapshot metadata list (FR-4). +- **`src/app/api/workspaces/[id]/workflows/[wid]/snapshots/[timestamp]/route.ts`** — `GET` handler returning full snapshot JSON (FR-5). +- **`src/app/api/workspaces/[id]/changes/route.ts`** — `GET` handler computing and returning diff events (FR-9). +- **`src/components/workspace/changes-panel.tsx`** — The slide-in changes panel UI component (FR-14–FR-20). +- **`src/hooks/use-workspace-changes.ts`** — Hook for fetching changes and managing last-seen state (FR-6–FR-8, FR-21–FR-22). +- **`src/lib/workspace/snapshots.ts`** — Server-side snapshot read/write/diff logic (FR-1–FR-3, FR-10–FR-13). +- **`docs/tasks/feature-workspace-recent-changes-panel-857b7bc9/e2e-feature-workspace-recent-changes-panel-857b7bc9.md`** — E2E test specification. + +## Implementation Plan + +### Phase 1: Foundation +- Define TypeScript types for snapshots and change events. +- Implement snapshot file read/write utilities in a new `src/lib/workspace/snapshots.ts` module. +- Add snapshot writing to the existing `saveWorkflow()` function in `server.ts`. + +### Phase 2: Core Implementation +- Build the diff computation engine that walks adjacent snapshot pairs and detects node_added, node_deleted, node_renamed events. +- Create the three new API routes: snapshot list, snapshot detail, and changes endpoint. +- Create the `useWorkspaceChanges` hook with last-seen localStorage management. + +### Phase 3: Integration +- Build the changes panel UI component with slide-in animation, grouped layout, dismiss behavior, and colored initial badges. +- Integrate the changes panel into the dashboard component following the load sequence defined in FR-21. +- Wire up the last-seen timestamp write to occur after both manifest and changes have been fetched and rendered. + +## Step by Step Tasks +IMPORTANT: Execute every step in order, top to bottom. + +### 1. Define Snapshot and Change Event Types +- In `src/lib/workspace/types.ts`, add: + - `SnapshotMeta`: `{ timestamp: string; savedBy: string }` + - `SnapshotFile`: `{ timestamp: string; workflowId: string; workspaceId: string; savedBy: string; data: WorkflowJSON }` + - `ChangeEventType`: `"node_added" | "node_deleted" | "node_renamed"` + - `ChangeEvent`: `{ type: ChangeEventType; nodeName: string; from?: string; to?: string; by: string; at: string }` + - `WorkflowChanges`: `{ workflowId: string; workflowName: string; changeCount: number; events: ChangeEvent[] }` + - `ChangesResponse`: `{ changes: WorkflowChanges[] }` + +### 2. Implement Snapshot Read/Write Utilities +- Create `src/lib/workspace/snapshots.ts` with: + - `snapshotsDir(workspaceId, workflowId)` — returns `{dataDir}/{workspaceId}/snapshots/{workflowId}/` + - `writeSnapshot(workspaceId, workflowId, data: WorkflowJSON, savedBy: string)` — writes `{ timestamp, workflowId, workspaceId, savedBy, data }` to `{snapshotsDir}/{urlSafeTimestamp}.json`. Use atomic write: write to `.tmp` file then `fs.rename()`. + - `listSnapshots(workspaceId, workflowId)` — reads directory, parses filenames back to timestamps, returns `SnapshotMeta[]` sorted chronologically. + - `getSnapshot(workspaceId, workflowId, timestamp)` — reads and returns the full `SnapshotFile`. + - URL-safe encoding: replace colons with dashes in ISO timestamp for filename safety (e.g., `2026-04-10T12-30-00.000Z.json`). + +### 3. Hook Snapshot Writing into saveWorkflow +- In `src/lib/workspace/server.ts`, import `writeSnapshot` from `./snapshots`. +- Inside the `saveWorkflow()` function, after writing the workflow JSON and manifest, call `await writeSnapshot(workspaceId, workflowId, data, lastModifiedBy)`. + +### 4. Implement Diff Computation Engine +- In `src/lib/workspace/snapshots.ts`, add: + - `computeChanges(workspaceId, since: string)` that: + 1. Reads the workspace manifest to get all workflow IDs and names. + 2. For each workflow, lists snapshots and filters to those after `since`. + 3. Finds the snapshot immediately before `since` (or treats empty node set as baseline if none exists). + 4. Walks adjacent snapshot pairs chronologically. + 5. For each pair, extracts node sets (by `id`), computes: + - `node_added`: node ID in newer but not older. + - `node_deleted`: node ID in older but not newer. + - `node_renamed`: node ID in both but `data.label` (or node name field) changed. + 6. Each event gets `by` from the later snapshot's `savedBy` and `at` from its timestamp. + 7. Excludes `node_moved` (position-only changes). + 8. Skips workflows with no snapshots after `since` (FR-11). + 9. Returns `ChangesResponse` with `changeCount` as total events per workflow. + +### 5. Create Snapshot API Routes +- Create `src/app/api/workspaces/[id]/workflows/[wid]/snapshots/route.ts`: + - `GET` handler calls `listSnapshots(id, wid)` and returns `SnapshotMeta[]`. + - Set `export const dynamic = "force-dynamic"`. +- Create `src/app/api/workspaces/[id]/workflows/[wid]/snapshots/[timestamp]/route.ts`: + - `GET` handler calls `getSnapshot(id, wid, timestamp)`, returns full snapshot or 404. + - Decode the URL-safe timestamp from the route param. + - Set `export const dynamic = "force-dynamic"`. + +### 6. Create Changes API Route +- Create `src/app/api/workspaces/[id]/changes/route.ts`: + - `GET` handler reads `since` query parameter. + - Validates `since` is a valid ISO timestamp; returns 400 if missing/invalid. + - Calls `computeChanges(id, since)` and returns the result. + - Set `export const dynamic = "force-dynamic"`. + +### 7. Create useWorkspaceChanges Hook +- Create `src/hooks/use-workspace-changes.ts`: + - Accepts `workspaceId: string` and `isReady: boolean` (gates fetch until manifest is loaded). + - On mount (when `isReady` is true): + 1. Read `nexus:workspace-last-seen:{workspaceId}` from localStorage → `since`. If absent, default to 24 hours ago. + 2. Fetch `GET /api/workspaces/{workspaceId}/changes?since={since}`. + 3. Store the result in state. + 4. Return `{ changes, isLoading, since, markSeen }`. + - `markSeen()` writes current UTC timestamp to `nexus:workspace-last-seen:{workspaceId}`. + - The hook does NOT call `markSeen()` automatically — the dashboard calls it after rendering. + +### 8. Build the Changes Panel Component +- Create `src/components/workspace/changes-panel.tsx`: + - Props: `changes: WorkflowChanges[]`, `since: string`, `onDismiss: () => void`. + - Panel slides in from the right using a CSS `translate-x` transition (not a modal, does not block the workflow grid). + - Header: "N changes since {formatted date}" with a "Dismiss" button. + - Body: Grouped by workflow. Each group has a workflow name header. Under each group, list individual change events. + - Each event line: colored initial badge (first letter of `by` name, using a name-based hash into the same `HUE_SLOTS` array from `awareness-names.ts`), bold user name, action text ("added Send Notification", "renamed Script 1 -> Validate Input", "deleted Old Transform"), node name. + - Panel is scrollable if content exceeds viewport height. + - Create a `getColorForName(name: string)` utility (hash name string to a number, mod by HUE_SLOTS length) — co-locate in the component or in `awareness-names.ts` alongside the existing `getColorForClientId`. + +### 9. Integrate Changes Panel into Dashboard +- In `src/components/workspace/dashboard.tsx`: + - Import and use `useWorkspaceChanges(workspaceId, !isLoading && !!workspace)`. + - Add `dismissed` state (boolean, default false). + - After the workspace manifest loads and changes are fetched: + - If changes are non-empty and not dismissed, render `` alongside the workflow grid (not blocking it). + - Call `markSeen()` once both workspace data and changes response are available and rendered (FR-6, FR-21 step 6). Use a `useEffect` that depends on workspace and changes being loaded. + - The panel should not appear during loading state. + - When dismissed, set `dismissed = true` — panel does not re-appear for this session (page load). + +### 10. Create E2E Test Specification +- Create `docs/tasks/feature-workspace-recent-changes-panel-857b7bc9/e2e-feature-workspace-recent-changes-panel-857b7bc9.md` with: + - **User Story**: Validate that a returning user sees a changes panel on the workspace dashboard showing node-level changes made by other users since their last visit. + - **Test Steps** (using playwright-cli): + 1. Create a workspace via API `POST /api/workspaces`. + 2. Create a workflow via API `POST /api/workspaces/{id}/workflows`. + 3. Save the workflow with some nodes via `PUT /api/workspaces/{id}/workflows/{wid}` with `lastModifiedBy: "Alice"`. + 4. Wait briefly, then save again with an added node and `lastModifiedBy: "Bob"`. + 5. Set localStorage `nexus:workspace-last-seen:{workspaceId}` to a timestamp before both saves. + 6. Navigate to `/workspace/{id}`. + 7. Assert the changes panel slides in from the right. + 8. Assert the panel header shows "N changes since {date}". + 9. Assert the workflow name appears as a group header. + 10. Assert individual change events show correct user names and node names. + 11. Assert colored initial badges are visible. + 12. Click "Dismiss" — assert panel slides out and is no longer visible. + 13. Reload the page — assert panel re-appears (last-seen was written on prior load, but changes still exist since before that). + 14. Screenshot capture at: panel visible state, after dismiss. + - **Success Criteria**: Panel appears with correct change data, dismiss works, colors match awareness system hashing. + - **No-changes scenario**: Set last-seen to current time, reload — assert no panel appears. + +### 11. Run Validation Commands +- `bun run typecheck` — ensure zero type errors. +- `bun run lint` — ensure zero lint errors. +- `bun run build` — ensure successful production build. + +## Testing Strategy + +### Unit Tests +- `src/lib/workspace/__tests__/snapshots.test.ts`: + - Test `writeSnapshot` creates correct file with correct structure. + - Test `listSnapshots` returns sorted metadata. + - Test `getSnapshot` returns full data. + - Test `computeChanges` with various scenarios: no snapshots, single snapshot, multiple snapshots with adds/deletes/renames. + - Test node identity by `id` — position-only changes produce no events. + - Test `since` filtering — only snapshots after `since` are considered. + - Test baseline snapshot selection (immediately before `since`). + +### Edge Cases +- Workflow with no snapshots after `since` — excluded from response. +- No prior snapshot before `since` — baseline is empty node set (all nodes in first snapshot after `since` are `node_added`). +- Same node added then deleted across multiple snapshots — both events recorded (no deduplication per FR-12). +- First-time visitor (no localStorage key) — `since` defaults to 24 hours ago. +- Empty workspace (no workflows) — changes response is `{ changes: [] }`, panel not shown. +- Very long node names or many changes — panel must scroll. +- Concurrent saves — snapshot filenames are timestamped to millisecond; extremely unlikely collision. +- URL-safe timestamp encoding/decoding round-trips correctly. + +## Acceptance Criteria +- [ ] AC-1: Opening a workspace after another user has saved changes shows the changes panel with their display name and the affected node names. +- [ ] AC-2: Opening a workspace with no changes since last visit shows no changes panel. +- [ ] AC-3: Dismissing the changes panel hides it for the rest of the session; it re-appears on the next page load if changes still exist. +- [ ] AC-4: The last-seen timestamp updates after each dashboard load, so subsequent visits show only newer changes. +- [ ] AC-5: Node additions, deletions, and renames are all correctly detected and attributed. +- [ ] AC-6: `node_moved`-only saves do not produce change events in the panel. +- [ ] AC-7: `GET /api/workspaces/[id]/changes?since=...` returns a correctly structured response matching the schema in FR-9. +- [ ] AC-8: Each change event in the panel shows a colored initial badge using the same color hashing as the awareness system. +- [ ] AC-9: `bun run typecheck` and `bun run build` pass with no new errors. + +## Validation Commands +Execute every command to validate the work is complete with zero regressions. + +```bash +bun run typecheck +bun run lint +bun run build +``` + +## Notes +- The snapshot path structure is `{dataDir}/{workspaceId}/snapshots/{workflowId}/{timestamp}.json` — nested under the workspace data directory alongside the existing `workflows/` directory. +- The `savedBy` field comes from the PUT request body's `lastModifiedBy` field, which is populated from `nexus:collab-name` localStorage on the client. +- For the name-based color hash, use a simple string hash (e.g., sum of char codes) modulo 8 into the same `HUE_SLOTS` array. This gives visual consistency: the same display name always gets the same color badge, matching what they'd see in the awareness/collaboration UI. +- Atomic snapshot writes (write to `.tmp` then rename) prevent partial reads during concurrent diff computation. +- The changes panel does not block the workflow grid — it is rendered alongside it (e.g., as an absolutely positioned or flex-adjacent panel on the right). +- Retention/pruning of old snapshots is explicitly out of scope for this feature. diff --git a/docs/tasks/persistent-brain/doc-persistent-brain.md b/docs/tasks/persistent-brain/doc-persistent-brain.md index e5208a3..bcc2411 100644 --- a/docs/tasks/persistent-brain/doc-persistent-brain.md +++ b/docs/tasks/persistent-brain/doc-persistent-brain.md @@ -8,6 +8,8 @@ This feature moves Brain documents from browser-only storage into a server-backed workspace with signed share tokens, filesystem persistence, and version history. It also replaces peer-to-peer collaboration with a Hocuspocus server that persists room state, so shared workflow sessions survive reconnects and restarts. +Current status: workspace mode now has an optional SpacetimeDB backend. When `NEXT_PUBLIC_SPACETIME_URI` is configured, Brain document operations are routed through the SpacetimeDB sync bridge and generated reducers instead of these filesystem Brain API routes. This document remains the reference for the legacy filesystem Brain backend and Hocuspocus-backed collaboration path. + ## What Was Built - A file-backed Brain store with workspace sessions, signed tokens, live document files, manifest tracking, and version snapshots. @@ -73,6 +75,7 @@ The test coverage verifies legacy Brain migration, imported metadata preservatio ## Notes +- For SpacetimeDB-backed workspace mode, see `docs/tasks/feature-spacetimedb-backend-sync-feature/doc-feature-spacetimedb-backend-sync-feature.md`. - Brain sessions are workspace-scoped but still anonymous; possession of a valid token or share link grants access. - Deleted documents are soft-deleted in the manifest and recorded as version events. - No screenshots were provided for this documentation task. diff --git a/eslint.config.mjs b/eslint.config.mjs index 9a6e468..c5e6ae3 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -51,6 +51,10 @@ const eslintConfig = defineConfig([ "out/**", "build/**", "next-env.d.ts", + // SpacetimeDB module has its own tsconfig and uses decorators not supported by ESLint + "spacetime/**", + // SpacetimeDB generated client bindings are validated by typecheck. + "src/lib/spacetime/module_bindings/**", ]), ]); diff --git a/package.json b/package.json index 6638dcd..187d014 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "docker:down": "docker compose down" }, "dependencies": { + "@clockworklabs/spacetimedb-sdk": "^1.0.0", "@dagrejs/dagre": "^2.0.4", "@hocuspocus/provider": "^3.4.4", "@hocuspocus/server": "^3.4.4", @@ -51,9 +52,8 @@ "react-hook-form": "^7.71.2", "react-simple-code-editor": "^0.14.1", "sonner": "^2.0.7", + "spacetimedb": "^2.1.0", "tailwind-merge": "^3.5.0", - "@hocuspocus/provider": "^3.4.4", - "@hocuspocus/server": "^3.4.4", "yjs": "^13.6.30", "zod": "^4.3.6", "zundo": "^2.3.0", diff --git a/scripts/generate-spacetime-bindings.sh b/scripts/generate-spacetime-bindings.sh new file mode 100755 index 0000000..bbcfc6a --- /dev/null +++ b/scripts/generate-spacetime-bindings.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Generate SpacetimeDB TypeScript client bindings. +# +# Usage: +# ./scripts/generate-spacetime-bindings.sh +# +# Prerequisites: +# - spacetime CLI installed (https://spacetimedb.com/install) +# - SpacetimeDB module published (spacetime publish -p spacetime/nexus nexus) +# +# The generated bindings are committed to the repo so that app builds +# don't require the SpacetimeDB CLI. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" + +OUT_DIR="$ROOT_DIR/src/lib/spacetime/module_bindings" + +echo "Generating SpacetimeDB TypeScript bindings..." +echo " Module: spacetime/nexus" +echo " Output: $OUT_DIR" + +spacetime generate \ + --lang typescript \ + --out-dir "$OUT_DIR" \ + --module-path "$ROOT_DIR/spacetime/nexus" + +echo "Bindings generated successfully." +echo "" +echo "If the module schema changed, commit the updated bindings:" +echo " git add src/lib/spacetime/module_bindings/" diff --git a/scripts/migrate-to-spacetime.ts b/scripts/migrate-to-spacetime.ts new file mode 100644 index 0000000..ab6568e --- /dev/null +++ b/scripts/migrate-to-spacetime.ts @@ -0,0 +1,348 @@ +#!/usr/bin/env bun +/** + * Idempotent migration script: reads existing filesystem workspace + brain data + * and imports it into SpacetimeDB via reducer calls. + * + * Usage: + * bun scripts/migrate-to-spacetime.ts [--data-dir ] [--spacetime-uri ] [--db-name ] + * + * The script: + * 1. Reads workspace manifests from /workspaces/ + * 2. Reads workflow JSON files for each workspace + * 3. Reads brain manifest from /manifest.json + * 4. Calls SpacetimeDB import reducers for each item + * 5. Preserves existing IDs so workspace URLs keep working + * 6. Is idempotent — safe to re-run (checks for existing rows) + */ + +import path from "node:path"; +import fs from "node:fs/promises"; + +// ── Configuration ────────────────────────────────────────────────────────── + +const args = process.argv.slice(2); +function getArg(name: string, fallback: string): string { + const idx = args.indexOf(`--${name}`); + return idx >= 0 && args[idx + 1] ? args[idx + 1] : fallback; +} + +const DATA_DIR = getArg("data-dir", process.env.NEXUS_BRAIN_DATA_DIR ?? path.join(process.cwd(), ".nexus-brain")); +const SPACETIME_URI = getArg("spacetime-uri", process.env.NEXT_PUBLIC_SPACETIME_URI ?? "ws://localhost:3001"); +const DB_NAME = getArg("db-name", process.env.NEXT_PUBLIC_SPACETIME_DB_NAME ?? "nexus"); +const DISPLAY_NAME = getArg("display-name", "migration-script"); + +const WORKSPACES_DIR = path.join(DATA_DIR, "workspaces"); + +// ── Stats ────────────────────────────────────────────────────────────────── + +const imported = { workspaces: 0, workflows: 0, brainDocs: 0 }; +const skipped = { workspaces: 0, workflows: 0, brainDocs: 0 }; +const failed = { workspaces: 0, workflows: 0, brainDocs: 0 }; + +// ── SpacetimeDB WebSocket Client ─────────────────────────────────────────── + +class MigrationClient { + private ws: WebSocket | null = null; + private pendingCalls = new Map void; reject: (err: Error) => void }>(); + private callId = 0; + + async connect(): Promise { + return new Promise((resolve, reject) => { + const url = `${SPACETIME_URI.replace("ws://", "http://").replace("wss://", "https://")}/database/subscribe/${DB_NAME}`; + const wsUrl = url.replace("http://", "ws://").replace("https://", "wss://"); + + this.ws = new WebSocket(wsUrl); + this.ws.onopen = () => resolve(); + this.ws.onerror = () => reject(new Error(`Failed to connect to SpacetimeDB at ${SPACETIME_URI}`)); + this.ws.onmessage = (event) => { + try { + const msg = JSON.parse(event.data as string); + if (msg.type === "transaction_update") { + // Resolve any pending call + for (const [id, handler] of this.pendingCalls) { + handler.resolve(); + this.pendingCalls.delete(id); + } + } + } catch { + // ignore + } + }; + }); + } + + async callReducer(name: string, args: unknown[]): Promise { + if (!this.ws) throw new Error("Not connected"); + + this.ws.send(JSON.stringify({ + type: "call_reducer", + reducer: name, + args, + })); + + // Wait a brief moment for the reducer to process + await new Promise((resolve) => setTimeout(resolve, 50)); + } + + disconnect(): void { + this.ws?.close(); + this.ws = null; + } +} + +// ── Migration Logic ──────────────────────────────────────────────────────── + +interface WorkspaceManifest { + version: 1; + workspace: { id: string; name: string; createdAt: string; updatedAt: string }; + workflows: Array<{ + id: string; + workspaceId: string; + name: string; + createdAt: string; + updatedAt: string; + lastModifiedBy: string; + }>; +} + +interface BrainManifest { + version: 1; + workspaces: Array<{ id: string; createdAt: string; updatedAt: string }>; + documents: Array<{ + id: string; + workspaceId: string; + title: string; + deletedAt: string | null; + [key: string]: unknown; + }>; + versions: Array; + feedback: Array; +} + +async function migrateWorkspaces(client: MigrationClient): Promise { + const exists = await fs.stat(WORKSPACES_DIR).catch(() => null); + if (!exists) { + console.log(" No workspaces directory found, skipping workspace migration."); + return; + } + + const entries = await fs.readdir(WORKSPACES_DIR, { withFileTypes: true }); + const workspaceDirs = entries.filter((e) => e.isDirectory()); + + for (const dir of workspaceDirs) { + const manifestPath = path.join(WORKSPACES_DIR, dir.name, "manifest.json"); + try { + const raw = await fs.readFile(manifestPath, "utf8"); + const manifest = JSON.parse(raw) as WorkspaceManifest; + const ws = manifest.workspace; + + console.log(` Importing workspace: ${ws.name} (${ws.id})`); + + try { + await client.callReducer("import_workspace", [ + ws.id, + ws.name, + ws.createdAt, + ws.updatedAt, + DISPLAY_NAME, + ]); + imported.workspaces++; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes("already exists")) { + skipped.workspaces++; + console.log(` Skipped (already exists)`); + } else { + failed.workspaces++; + console.error(` Failed: ${msg}`); + } + } + + // Import workflows + for (const wf of manifest.workflows) { + await migrateWorkflow(client, ws.id, wf); + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(` Failed to read manifest for ${dir.name}: ${msg}`); + failed.workspaces++; + } + } +} + +async function migrateWorkflow( + client: MigrationClient, + workspaceId: string, + wfRecord: WorkspaceManifest["workflows"][number], +): Promise { + const workflowPath = path.join( + WORKSPACES_DIR, + workspaceId, + "workflows", + `${wfRecord.id}.json`, + ); + + try { + const raw = await fs.readFile(workflowPath, "utf8"); + const wfData = JSON.parse(raw) as { + name?: string; + nodes?: Array<{ id: string; type?: string; position?: { x: number; y: number }; data?: unknown }>; + edges?: Array<{ id: string; source: string; target: string; sourceHandle?: string; targetHandle?: string; data?: unknown }>; + ui?: Record; + }; + + console.log(` Importing workflow: ${wfRecord.name} (${wfRecord.id})`); + + // Convert nodes to SpacetimeDB format + const nodesPayload = (wfData.nodes ?? []).map((n) => ({ + nodeId: n.id, + type: n.type ?? "default", + positionJson: JSON.stringify(n.position ?? { x: 0, y: 0 }), + dataJson: JSON.stringify(n.data ?? {}), + })); + + // Convert edges to SpacetimeDB format + const edgesPayload = (wfData.edges ?? []).map((e) => ({ + edgeId: e.id, + source: e.source, + target: e.target, + handlesJson: JSON.stringify({ + sourceHandle: e.sourceHandle ?? null, + targetHandle: e.targetHandle ?? null, + }), + dataJson: e.data ? JSON.stringify(e.data) : "{}", + })); + + const uiStateJson = wfData.ui ? JSON.stringify(wfData.ui) : "{}"; + + try { + await client.callReducer("import_workflow_snapshot", [ + wfRecord.id, + workspaceId, + wfRecord.name, + JSON.stringify(nodesPayload), + JSON.stringify(edgesPayload), + uiStateJson, + wfRecord.createdAt, + wfRecord.updatedAt, + wfRecord.lastModifiedBy, + ]); + imported.workflows++; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes("already exists")) { + skipped.workflows++; + console.log(` Skipped (already exists)`); + } else { + failed.workflows++; + console.error(` Failed: ${msg}`); + } + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(` Failed to read workflow ${wfRecord.id}: ${msg}`); + failed.workflows++; + } +} + +async function migrateBrainDocs(client: MigrationClient): Promise { + const manifestPath = path.join(DATA_DIR, "manifest.json"); + const exists = await fs.stat(manifestPath).catch(() => null); + if (!exists) { + console.log(" No brain manifest found, skipping brain doc migration."); + return; + } + + try { + const raw = await fs.readFile(manifestPath, "utf8"); + const manifest = JSON.parse(raw) as BrainManifest; + + for (const doc of manifest.documents) { + if (doc.deletedAt) { + console.log(` Skipping deleted doc: ${doc.title} (${doc.id})`); + skipped.brainDocs++; + continue; + } + + console.log(` Importing brain doc: ${doc.title} (${doc.id})`); + + // Extract content fields (everything except workspace/deletion metadata) + const { id, workspaceId, title, deletedAt: _da, ...contentFields } = doc; + const contentJson = JSON.stringify(contentFields); + + try { + await client.callReducer("import_brain_doc", [ + id, + workspaceId, + title, + contentJson, + (contentFields as Record).createdAt ?? new Date().toISOString(), + (contentFields as Record).updatedAt ?? new Date().toISOString(), + ]); + imported.brainDocs++; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes("already exists")) { + skipped.brainDocs++; + console.log(` Skipped (already exists)`); + } else { + failed.brainDocs++; + console.error(` Failed: ${msg}`); + } + } + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + console.error(` Failed to read brain manifest: ${msg}`); + } +} + +// ── Main ─────────────────────────────────────────────────────────────────── + +async function main(): Promise { + console.log("SpacetimeDB Migration Script"); + console.log("============================"); + console.log(` Data directory: ${DATA_DIR}`); + console.log(` SpacetimeDB URI: ${SPACETIME_URI}`); + console.log(` Database name: ${DB_NAME}`); + console.log(); + + const client = new MigrationClient(); + + console.log("Connecting to SpacetimeDB..."); + try { + await client.connect(); + console.log("Connected."); + } catch (err) { + console.error(`Failed to connect: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } + + console.log(); + console.log("Migrating workspaces..."); + await migrateWorkspaces(client); + + console.log(); + console.log("Migrating brain documents..."); + await migrateBrainDocs(client); + + client.disconnect(); + + console.log(); + console.log("Migration Complete"); + console.log("=================="); + console.log(` Workspaces: ${imported.workspaces} imported, ${skipped.workspaces} skipped, ${failed.workspaces} failed`); + console.log(` Workflows: ${imported.workflows} imported, ${skipped.workflows} skipped, ${failed.workflows} failed`); + console.log(` Brain Docs: ${imported.brainDocs} imported, ${skipped.brainDocs} skipped, ${failed.brainDocs} failed`); + + if (failed.workspaces + failed.workflows + failed.brainDocs > 0) { + console.log(); + console.log("Some items failed to migrate. Review the output above for details."); + process.exit(1); + } +} + +main().catch((err) => { + console.error("Unexpected error:", err); + process.exit(1); +}); diff --git a/spacetime/nexus/package-lock.json b/spacetime/nexus/package-lock.json new file mode 100644 index 0000000..8c3ab81 --- /dev/null +++ b/spacetime/nexus/package-lock.json @@ -0,0 +1,169 @@ +{ + "name": "nexus-spacetime-module", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "nexus-spacetime-module", + "version": "1.0.0", + "dependencies": { + "spacetimedb": "^2.1.0" + }, + "devDependencies": { + "typescript": "~5.6.2" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/headers-polyfill": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz", + "integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==", + "license": "MIT" + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/prettier": { + "version": "3.8.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.2.tgz", + "integrity": "sha512-8c3mgTe0ASwWAJK+78dpviD+A8EqhndQPUBpNUIPt6+xWlIigCwfN01lWr9MAede4uqXGTEKeQWTvzb3vjia0Q==", + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/pure-rand": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", + "integrity": "sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/spacetimedb": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/spacetimedb/-/spacetimedb-2.1.0.tgz", + "integrity": "sha512-Kzs+HXCRj15ryld03ztU4a2uQg0M8ivV/9Bk/gvMpb59lLc/A2/r7UkGCYBePsBL7Zwqgr8gE8FeufoZVXtPnA==", + "license": "ISC", + "dependencies": { + "base64-js": "^1.5.1", + "headers-polyfill": "^4.0.3", + "object-inspect": "^1.13.4", + "prettier": "^3.3.3", + "pure-rand": "^7.0.1", + "safe-stable-stringify": "^2.5.0", + "statuses": "^2.0.2", + "url-polyfill": "^1.1.14" + }, + "peerDependencies": { + "@angular/core": ">=17.0.0", + "@tanstack/react-query": "^5.0.0", + "react": "^18.0.0 || ^19.0.0-0 || ^19.0.0", + "svelte": "^4.0.0 || ^5.0.0", + "undici": "^6.19.2", + "vue": "^3.3.0" + }, + "peerDependenciesMeta": { + "@angular/core": { + "optional": true + }, + "@tanstack/react-query": { + "optional": true + }, + "react": { + "optional": true + }, + "svelte": { + "optional": true + }, + "undici": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/typescript": { + "version": "5.6.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.6.3.tgz", + "integrity": "sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/url-polyfill": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/url-polyfill/-/url-polyfill-1.1.14.tgz", + "integrity": "sha512-p4f3TTAG6ADVF3mwbXw7hGw+QJyw5CnNGvYh5fCuQQZIiuKUswqcznyV3pGDP9j0TSmC4UvRKm8kl1QsX1diiQ==", + "license": "MIT" + } + } +} diff --git a/spacetime/nexus/package.json b/spacetime/nexus/package.json new file mode 100644 index 0000000..7f9fd2b --- /dev/null +++ b/spacetime/nexus/package.json @@ -0,0 +1,16 @@ +{ + "name": "nexus-spacetime-module", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "spacetime build", + "publish": "spacetime publish" + }, + "dependencies": { + "spacetimedb": "^2.1.0" + }, + "devDependencies": { + "typescript": "~5.6.2" + } +} diff --git a/spacetime/nexus/spacetimedb.toml b/spacetime/nexus/spacetimedb.toml new file mode 100644 index 0000000..e3385b8 --- /dev/null +++ b/spacetime/nexus/spacetimedb.toml @@ -0,0 +1,3 @@ +[module] +name = "nexus" +language = "typescript" diff --git a/spacetime/nexus/src/index.ts b/spacetime/nexus/src/index.ts new file mode 100644 index 0000000..120a4b3 --- /dev/null +++ b/spacetime/nexus/src/index.ts @@ -0,0 +1,578 @@ +import type { Identity } from "spacetimedb"; +import { schema, table, t } from "spacetimedb/server"; + +const spacetimedb = schema({ + workspace: table( + { name: "workspace", public: true }, + { + id: t.string().primaryKey(), + name: t.string(), + createdAt: t.string(), + updatedAt: t.string(), + }, + ), + + workspaceMember: table( + { + name: "workspace_member", + public: true, + indexes: [ + { accessor: "byWorkspaceId", algorithm: "btree", columns: ["workspaceId"] }, + { accessor: "byIdentity", algorithm: "btree", columns: ["identity"] }, + ], + }, + { + workspaceId: t.string(), + identity: t.identity(), + displayName: t.string(), + role: t.string(), + joinedAt: t.string(), + }, + ), + + workspaceInvite: table( + { + name: "workspace_invite", + public: true, + indexes: [ + { accessor: "byWorkspaceId", algorithm: "btree", columns: ["workspaceId"] }, + { accessor: "byTokenHash", algorithm: "btree", columns: ["tokenHash"] }, + ], + }, + { + workspaceId: t.string(), + tokenHash: t.string(), + createdAt: t.string(), + revokedAt: t.string().optional(), + }, + ), + + workflow: table( + { + name: "workflow", + public: true, + indexes: [{ accessor: "byWorkspaceId", algorithm: "btree", columns: ["workspaceId"] }], + }, + { + id: t.string().primaryKey(), + workspaceId: t.string(), + name: t.string(), + createdAt: t.string(), + updatedAt: t.string(), + lastModifiedBy: t.string(), + }, + ), + + workflowNode: table( + { + name: "workflow_node", + public: true, + indexes: [{ accessor: "byWorkflowId", algorithm: "btree", columns: ["workflowId"] }], + }, + { + workflowId: t.string(), + nodeId: t.string(), + type: t.string(), + positionJson: t.string(), + dataJson: t.string(), + updatedAt: t.string(), + updatedBy: t.string(), + }, + ), + + workflowEdge: table( + { + name: "workflow_edge", + public: true, + indexes: [{ accessor: "byWorkflowId", algorithm: "btree", columns: ["workflowId"] }], + }, + { + workflowId: t.string(), + edgeId: t.string(), + source: t.string(), + target: t.string(), + handlesJson: t.string(), + dataJson: t.string(), + updatedAt: t.string(), + updatedBy: t.string(), + }, + ), + + workflowUiState: table( + { name: "workflow_ui_state", public: true }, + { + workflowId: t.string().primaryKey(), + uiStateJson: t.string(), + }, + ), + + brainDoc: table( + { + name: "brain_doc", + public: true, + indexes: [{ accessor: "byWorkspaceId", algorithm: "btree", columns: ["workspaceId"] }], + }, + { + id: t.string().primaryKey(), + workspaceId: t.string(), + title: t.string(), + contentJson: t.string(), + createdAt: t.string(), + updatedAt: t.string(), + deletedAt: t.string().optional(), + }, + ), + + brainDocVersion: table( + { + name: "brain_doc_version", + public: true, + indexes: [{ accessor: "byDocId", algorithm: "btree", columns: ["docId"] }], + }, + { + docId: t.string(), + versionId: t.string(), + contentJson: t.string(), + createdAt: t.string(), + }, + ), + + brainFeedback: table( + { + name: "brain_feedback", + public: true, + indexes: [{ accessor: "byDocId", algorithm: "btree", columns: ["docId"] }], + }, + { + docId: t.string(), + identity: t.identity(), + type: t.string(), + comment: t.string(), + createdAt: t.string(), + }, + ), + + workflowChangeEvent: table( + { + name: "workflow_change_event", + public: true, + indexes: [{ accessor: "byWorkflowId", algorithm: "btree", columns: ["workflowId"] }], + }, + { + workflowId: t.string(), + eventType: t.string(), + nodeId: t.string().optional(), + details: t.string(), + timestamp: t.string(), + }, + ), + + presence: table( + { + name: "presence", + public: true, + indexes: [ + { accessor: "byWorkspaceId", algorithm: "btree", columns: ["workspaceId"] }, + { accessor: "byIdentity", algorithm: "btree", columns: ["identity"] }, + ], + }, + { + workspaceId: t.string(), + workflowId: t.string(), + identity: t.identity(), + displayName: t.string(), + selectedNodeId: t.string().optional(), + lastSeenAt: t.string(), + }, + ), +}); + +export default spacetimedb; + +type Db = Parameters[1]>[0]["db"]; + +interface WorkflowOp { + op: "upsert_node" | "delete_node" | "upsert_edge" | "delete_edge"; + nodeId?: string; + type?: string; + positionJson?: string; + dataJson?: string; + edgeId?: string; + source?: string; + target?: string; + handlesJson?: string; +} + +const nowIso = () => new Date().toISOString(); + +function findMember(db: Db, workspaceId: string, identity: Identity) { + for (const member of db.workspaceMember.byWorkspaceId.filter(workspaceId)) { + if (member.identity.isEqual(identity)) return member; + } + return null; +} + +function requireMembership(db: Db, workspaceId: string, identity: Identity) { + const member = findMember(db, workspaceId, identity); + if (!member) throw new Error(`Not a member of workspace ${workspaceId}`); + return member; +} + +function deleteWorkflowData(db: Db, workflowId: string): void { + db.workflowNode.byWorkflowId.delete(workflowId); + db.workflowEdge.byWorkflowId.delete(workflowId); + db.workflowChangeEvent.byWorkflowId.delete(workflowId); + db.workflowUiState.workflowId.delete(workflowId); +} + +export const init = spacetimedb.init(() => {}); + +export const onConnect = spacetimedb.clientConnected(() => {}); + +export const onDisconnect = spacetimedb.clientDisconnected(ctx => { + for (const row of ctx.db.presence.byIdentity.filter(ctx.sender)) { + ctx.db.presence.delete(row); + } +}); + +export const createWorkspace = spacetimedb.reducer( + { id: t.string(), name: t.string(), displayName: t.string() }, + (ctx, { id, name, displayName }) => { + const now = nowIso(); + ctx.db.workspace.insert({ id, name, createdAt: now, updatedAt: now }); + ctx.db.workspaceMember.insert({ + workspaceId: id, + identity: ctx.sender, + displayName, + role: "owner", + joinedAt: now, + }); + }, +); + +export const renameWorkspace = spacetimedb.reducer( + { workspaceId: t.string(), newName: t.string() }, + (ctx, { workspaceId, newName }) => { + requireMembership(ctx.db, workspaceId, ctx.sender); + const ws = ctx.db.workspace.id.find(workspaceId); + if (!ws) throw new Error(`Workspace ${workspaceId} not found`); + ctx.db.workspace.id.update({ ...ws, name: newName, updatedAt: nowIso() }); + }, +); + +export const deleteWorkspace = spacetimedb.reducer({ workspaceId: t.string() }, (ctx, { workspaceId }) => { + const member = requireMembership(ctx.db, workspaceId, ctx.sender); + if (member.role !== "owner") throw new Error("Only owners can delete workspaces"); + + for (const wf of ctx.db.workflow.byWorkspaceId.filter(workspaceId)) { + deleteWorkflowData(ctx.db, wf.id); + ctx.db.workflow.delete(wf); + } + ctx.db.workspaceMember.byWorkspaceId.delete(workspaceId); + ctx.db.workspaceInvite.byWorkspaceId.delete(workspaceId); + for (const doc of ctx.db.brainDoc.byWorkspaceId.filter(workspaceId)) { + ctx.db.brainDocVersion.byDocId.delete(doc.id); + ctx.db.brainFeedback.byDocId.delete(doc.id); + ctx.db.brainDoc.delete(doc); + } + ctx.db.presence.byWorkspaceId.delete(workspaceId); + ctx.db.workspace.id.delete(workspaceId); +}); + +export const createInvite = spacetimedb.reducer( + { workspaceId: t.string(), tokenHash: t.string() }, + (ctx, { workspaceId, tokenHash }) => { + requireMembership(ctx.db, workspaceId, ctx.sender); + ctx.db.workspaceInvite.insert({ workspaceId, tokenHash, createdAt: nowIso(), revokedAt: undefined }); + }, +); + +export const joinWorkspace = spacetimedb.reducer( + { tokenHash: t.string(), displayName: t.string() }, + (ctx, { tokenHash, displayName }) => { + const invite = [...ctx.db.workspaceInvite.byTokenHash.filter(tokenHash)].find(inv => inv.revokedAt === undefined); + if (!invite) throw new Error("Invalid or revoked invite token"); + if (findMember(ctx.db, invite.workspaceId, ctx.sender)) return; + ctx.db.workspaceMember.insert({ + workspaceId: invite.workspaceId, + identity: ctx.sender, + displayName, + role: "editor", + joinedAt: nowIso(), + }); + }, +); + +export const createWorkflow = spacetimedb.reducer( + { id: t.string(), workspaceId: t.string(), name: t.string(), displayName: t.string() }, + (ctx, { id, workspaceId, name, displayName }) => { + requireMembership(ctx.db, workspaceId, ctx.sender); + const now = nowIso(); + ctx.db.workflow.insert({ id, workspaceId, name, createdAt: now, updatedAt: now, lastModifiedBy: displayName }); + }, +); + +export const renameWorkflow = spacetimedb.reducer( + { workflowId: t.string(), newName: t.string() }, + (ctx, { workflowId, newName }) => { + const wf = ctx.db.workflow.id.find(workflowId); + if (!wf) throw new Error(`Workflow ${workflowId} not found`); + requireMembership(ctx.db, wf.workspaceId, ctx.sender); + ctx.db.workflow.id.update({ ...wf, name: newName, updatedAt: nowIso() }); + }, +); + +export const deleteWorkflow = spacetimedb.reducer({ workflowId: t.string() }, (ctx, { workflowId }) => { + const wf = ctx.db.workflow.id.find(workflowId); + if (!wf) throw new Error(`Workflow ${workflowId} not found`); + requireMembership(ctx.db, wf.workspaceId, ctx.sender); + deleteWorkflowData(ctx.db, workflowId); + ctx.db.workflow.delete(wf); +}); + +export const applyWorkflowOps = spacetimedb.reducer( + { workflowId: t.string(), opsJson: t.string(), displayName: t.string() }, + (ctx, { workflowId, opsJson, displayName }) => { + const wf = ctx.db.workflow.id.find(workflowId); + if (!wf) throw new Error(`Workflow ${workflowId} not found`); + requireMembership(ctx.db, wf.workspaceId, ctx.sender); + const ops = JSON.parse(opsJson) as WorkflowOp[]; + const now = nowIso(); + + for (const op of ops) { + if (op.op === "upsert_node") { + const existing = [...ctx.db.workflowNode.byWorkflowId.filter(workflowId)].find(n => n.nodeId === op.nodeId); + if (existing) ctx.db.workflowNode.delete(existing); + ctx.db.workflowNode.insert({ + workflowId, + nodeId: op.nodeId!, + type: op.type!, + positionJson: op.positionJson!, + dataJson: op.dataJson!, + updatedAt: now, + updatedBy: displayName, + }); + if (!existing) { + ctx.db.workflowChangeEvent.insert({ + workflowId, + eventType: "node_added", + nodeId: op.nodeId!, + details: JSON.stringify({ nodeName: op.type, by: displayName }), + timestamp: now, + }); + } + } else if (op.op === "delete_node") { + const node = [...ctx.db.workflowNode.byWorkflowId.filter(workflowId)].find(n => n.nodeId === op.nodeId); + if (node) { + ctx.db.workflowNode.delete(node); + ctx.db.workflowChangeEvent.insert({ + workflowId, + eventType: "node_deleted", + nodeId: op.nodeId!, + details: JSON.stringify({ nodeName: node.type, by: displayName }), + timestamp: now, + }); + } + } else if (op.op === "upsert_edge") { + const existing = [...ctx.db.workflowEdge.byWorkflowId.filter(workflowId)].find(e => e.edgeId === op.edgeId); + if (existing) ctx.db.workflowEdge.delete(existing); + ctx.db.workflowEdge.insert({ + workflowId, + edgeId: op.edgeId!, + source: op.source!, + target: op.target!, + handlesJson: op.handlesJson ?? "{}", + dataJson: op.dataJson ?? "{}", + updatedAt: now, + updatedBy: displayName, + }); + if (!existing) { + ctx.db.workflowChangeEvent.insert({ + workflowId, + eventType: "edge_added", + nodeId: undefined, + details: JSON.stringify({ edgeId: op.edgeId, by: displayName }), + timestamp: now, + }); + } + } else if (op.op === "delete_edge") { + const edge = [...ctx.db.workflowEdge.byWorkflowId.filter(workflowId)].find(e => e.edgeId === op.edgeId); + if (edge) { + ctx.db.workflowEdge.delete(edge); + ctx.db.workflowChangeEvent.insert({ + workflowId, + eventType: "edge_deleted", + nodeId: undefined, + details: JSON.stringify({ edgeId: op.edgeId, by: displayName }), + timestamp: now, + }); + } + } + } + + ctx.db.workflow.id.update({ ...wf, updatedAt: now, lastModifiedBy: displayName }); + }, +); + +export const updateWorkflowUiState = spacetimedb.reducer( + { workflowId: t.string(), uiStateJson: t.string() }, + (ctx, { workflowId, uiStateJson }) => { + const wf = ctx.db.workflow.id.find(workflowId); + if (!wf) throw new Error(`Workflow ${workflowId} not found`); + requireMembership(ctx.db, wf.workspaceId, ctx.sender); + const existing = ctx.db.workflowUiState.workflowId.find(workflowId); + if (existing) ctx.db.workflowUiState.workflowId.update({ ...existing, uiStateJson }); + else ctx.db.workflowUiState.insert({ workflowId, uiStateJson }); + }, +); + +export const saveBrainDoc = spacetimedb.reducer( + { + id: t.string(), + workspaceId: t.string(), + title: t.string(), + contentJson: t.string(), + versionId: t.string().optional(), + }, + (ctx, { id, workspaceId, title, contentJson, versionId }) => { + requireMembership(ctx.db, workspaceId, ctx.sender); + const now = nowIso(); + const existing = ctx.db.brainDoc.id.find(id); + if (existing) { + if (versionId) { + ctx.db.brainDocVersion.insert({ docId: id, versionId, contentJson: existing.contentJson, createdAt: now }); + } + ctx.db.brainDoc.id.update({ ...existing, title, contentJson, updatedAt: now, deletedAt: undefined }); + } else { + ctx.db.brainDoc.insert({ id, workspaceId, title, contentJson, createdAt: now, updatedAt: now, deletedAt: undefined }); + } + }, +); + +export const deleteBrainDoc = spacetimedb.reducer({ docId: t.string() }, (ctx, { docId }) => { + const doc = ctx.db.brainDoc.id.find(docId); + if (!doc) throw new Error(`Brain doc ${docId} not found`); + requireMembership(ctx.db, doc.workspaceId, ctx.sender); + ctx.db.brainDoc.id.update({ ...doc, deletedAt: nowIso() }); +}); + +export const recordBrainView = spacetimedb.reducer({ docId: t.string() }, (ctx, { docId }) => { + const doc = ctx.db.brainDoc.id.find(docId); + if (!doc) throw new Error(`Brain doc ${docId} not found`); + requireMembership(ctx.db, doc.workspaceId, ctx.sender); + const content = JSON.parse(doc.contentJson); + if (content.metrics) { + content.metrics.views = (content.metrics.views || 0) + 1; + content.metrics.lastViewedAt = nowIso(); + } + ctx.db.brainDoc.id.update({ ...doc, contentJson: JSON.stringify(content) }); +}); + +export const addBrainFeedback = spacetimedb.reducer( + { docId: t.string(), type: t.string(), comment: t.string() }, + (ctx, { docId, type, comment }) => { + const doc = ctx.db.brainDoc.id.find(docId); + if (!doc) throw new Error(`Brain doc ${docId} not found`); + requireMembership(ctx.db, doc.workspaceId, ctx.sender); + ctx.db.brainFeedback.insert({ docId, identity: ctx.sender, type, comment, createdAt: nowIso() }); + }, +); + +export const restoreBrainDocVersion = spacetimedb.reducer( + { docId: t.string(), versionId: t.string(), snapshotVersionId: t.string() }, + (ctx, { docId, versionId, snapshotVersionId }) => { + const doc = ctx.db.brainDoc.id.find(docId); + if (!doc) throw new Error(`Brain doc ${docId} not found`); + requireMembership(ctx.db, doc.workspaceId, ctx.sender); + const version = [...ctx.db.brainDocVersion.byDocId.filter(docId)].find(v => v.versionId === versionId); + if (!version) throw new Error(`Version ${versionId} not found`); + const now = nowIso(); + ctx.db.brainDocVersion.insert({ docId, versionId: snapshotVersionId, contentJson: doc.contentJson, createdAt: now }); + ctx.db.brainDoc.id.update({ ...doc, contentJson: version.contentJson, updatedAt: now, deletedAt: undefined }); + }, +); + +export const updatePresence = spacetimedb.reducer( + { + workspaceId: t.string(), + workflowId: t.string(), + displayName: t.string(), + selectedNodeId: t.string().optional(), + }, + (ctx, { workspaceId, workflowId, displayName, selectedNodeId }) => { + const existing = [...ctx.db.presence.byIdentity.filter(ctx.sender)].find( + p => p.workspaceId === workspaceId && p.workflowId === workflowId, + ); + if (existing) ctx.db.presence.delete(existing); + ctx.db.presence.insert({ + workspaceId, + workflowId, + identity: ctx.sender, + displayName, + selectedNodeId, + lastSeenAt: nowIso(), + }); + }, +); + +export const importWorkspace = spacetimedb.reducer( + { id: t.string(), name: t.string(), createdAt: t.string(), updatedAt: t.string(), displayName: t.string() }, + (ctx, { id, name, createdAt, updatedAt, displayName }) => { + if (ctx.db.workspace.id.find(id)) return; + ctx.db.workspace.insert({ id, name, createdAt, updatedAt }); + ctx.db.workspaceMember.insert({ workspaceId: id, identity: ctx.sender, displayName, role: "owner", joinedAt: nowIso() }); + }, +); + +export const importWorkflowSnapshot = spacetimedb.reducer( + { + workflowId: t.string(), + workspaceId: t.string(), + name: t.string(), + nodesJson: t.string(), + edgesJson: t.string(), + uiStateJson: t.string(), + createdAt: t.string(), + updatedAt: t.string(), + lastModifiedBy: t.string(), + }, + (ctx, { workflowId, workspaceId, name, nodesJson, edgesJson, uiStateJson, createdAt, updatedAt, lastModifiedBy }) => { + if (ctx.db.workflow.id.find(workflowId)) return; + requireMembership(ctx.db, workspaceId, ctx.sender); + ctx.db.workflow.insert({ id: workflowId, workspaceId, name, createdAt, updatedAt, lastModifiedBy }); + + const nodes = JSON.parse(nodesJson) as Array<{ nodeId: string; type: string; positionJson: string; dataJson: string }>; + for (const node of nodes) { + ctx.db.workflowNode.insert({ workflowId, ...node, updatedAt, updatedBy: lastModifiedBy }); + } + + const edges = JSON.parse(edgesJson) as Array<{ + edgeId: string; + source: string; + target: string; + handlesJson: string; + dataJson: string; + }>; + for (const edge of edges) { + ctx.db.workflowEdge.insert({ workflowId, ...edge, updatedAt, updatedBy: lastModifiedBy }); + } + + if (uiStateJson !== "{}") ctx.db.workflowUiState.insert({ workflowId, uiStateJson }); + }, +); + +export const importBrainDoc = spacetimedb.reducer( + { + id: t.string(), + workspaceId: t.string(), + title: t.string(), + contentJson: t.string(), + createdAt: t.string(), + updatedAt: t.string(), + }, + (ctx, { id, workspaceId, title, contentJson, createdAt, updatedAt }) => { + requireMembership(ctx.db, workspaceId, ctx.sender); + if (ctx.db.brainDoc.id.find(id)) return; + ctx.db.brainDoc.insert({ id, workspaceId, title, contentJson, createdAt, updatedAt, deletedAt: undefined }); + }, +); diff --git a/spacetime/nexus/src/lib.ts b/spacetime/nexus/src/lib.ts new file mode 100644 index 0000000..3d1420c --- /dev/null +++ b/spacetime/nexus/src/lib.ts @@ -0,0 +1,815 @@ +/** + * SpacetimeDB Module: Nexus Workflow Studio + * + * Defines all tables, views, and reducers for workspace persistence and + * real-time collaboration. Uses private tables with public views filtered + * by workspace membership for row-level access control. + */ + +import { + table, + reducer, + ReducerContext, + Identity, + ScheduleAt, + Timestamp, +} from "@clockworklabs/spacetimedb-sdk/server"; + +// ── Table Definitions ────────────────────────────────────────────────────── + +@table({ name: "workspace", primaryKey: "id", access: "private" }) +export class Workspace { + id!: string; + name!: string; + createdAt!: string; // ISO timestamp + updatedAt!: string; +} + +@table({ name: "workspace_member", access: "private" }) +export class WorkspaceMember { + workspaceId!: string; + identity!: Identity; + displayName!: string; + role!: string; // "owner" | "editor" | "viewer" + joinedAt!: string; +} + +@table({ name: "workspace_invite", access: "private" }) +export class WorkspaceInvite { + workspaceId!: string; + tokenHash!: string; + createdAt!: string; + revokedAt!: string | null; +} + +@table({ name: "workflow", primaryKey: "id", access: "private" }) +export class Workflow { + id!: string; + workspaceId!: string; + name!: string; + createdAt!: string; + updatedAt!: string; + lastModifiedBy!: string; +} + +@table({ name: "workflow_node", access: "private" }) +export class WorkflowNode { + workflowId!: string; + nodeId!: string; + type!: string; + positionJson!: string; // JSON: { x: number, y: number } + dataJson!: string; // JSON: WorkflowNodeData + updatedAt!: string; + updatedBy!: string; +} + +@table({ name: "workflow_edge", access: "private" }) +export class WorkflowEdge { + workflowId!: string; + edgeId!: string; + source!: string; + target!: string; + handlesJson!: string; // JSON: { sourceHandle, targetHandle } + dataJson!: string; // JSON: edge data or "{}" + updatedAt!: string; + updatedBy!: string; +} + +@table({ name: "workflow_ui_state", primaryKey: "workflowId", access: "private" }) +export class WorkflowUiState { + workflowId!: string; + uiStateJson!: string; // JSON: { sidebarOpen, minimapVisible, viewport, ... } +} + +@table({ name: "brain_doc", primaryKey: "id", access: "private" }) +export class BrainDoc { + id!: string; + workspaceId!: string; + title!: string; + contentJson!: string; // JSON: full KnowledgeDoc content fields + createdAt!: string; + updatedAt!: string; + deletedAt!: string | null; +} + +@table({ name: "brain_doc_version", access: "private" }) +export class BrainDocVersion { + docId!: string; + versionId!: string; + contentJson!: string; + createdAt!: string; +} + +@table({ name: "brain_feedback", access: "private" }) +export class BrainFeedback { + docId!: string; + identity!: Identity; + type!: string; // FeedbackRating: "success" | "failure" | "neutral" + comment!: string; + createdAt!: string; +} + +@table({ name: "workflow_change_event", access: "private" }) +export class WorkflowChangeEvent { + workflowId!: string; + eventType!: string; // "node_added" | "node_deleted" | "node_renamed" | "edge_added" | "edge_deleted" + nodeId!: string | null; + details!: string; // JSON: { nodeName?, from?, to?, by? } + timestamp!: string; +} + +@table({ name: "presence", access: "private" }) +export class Presence { + workspaceId!: string; + workflowId!: string; + identity!: Identity; + displayName!: string; + selectedNodeId!: string | null; + lastSeenAt!: string; +} + +// ── Helper: Membership Check ─────────────────────────────────────────────── + +function requireMembership(ctx: ReducerContext, workspaceId: string): WorkspaceMember { + const member = WorkspaceMember.filterByWorkspaceId(workspaceId) + .find((m: WorkspaceMember) => m.identity.isEqual(ctx.sender)); + if (!member) { + throw new Error(`Not a member of workspace ${workspaceId}`); + } + return member; +} + +function isMember(ctx: ReducerContext, workspaceId: string): boolean { + return WorkspaceMember.filterByWorkspaceId(workspaceId) + .some((m: WorkspaceMember) => m.identity.isEqual(ctx.sender)); +} + +// ── Identity Lifecycle ───────────────────────────────────────────────────── + +@reducer({ name: "__identity_connected__" }) +export function identityConnected(ctx: ReducerContext): void { + // No-op on connect — presence is explicitly started by the client +} + +@reducer({ name: "__identity_disconnected__" }) +export function identityDisconnected(ctx: ReducerContext): void { + // Clean up presence rows for the disconnected identity + const presenceRows = Presence.filterByIdentity(ctx.sender); + for (const row of presenceRows) { + Presence.delete(row); + } +} + +// ── Workspace Reducers ───────────────────────────────────────────────────── + +@reducer({ name: "create_workspace" }) +export function createWorkspace( + ctx: ReducerContext, + id: string, + name: string, + displayName: string, +): void { + const now = new Date().toISOString(); + + Workspace.insert({ + id, + name, + createdAt: now, + updatedAt: now, + }); + + // Creator becomes owner + WorkspaceMember.insert({ + workspaceId: id, + identity: ctx.sender, + displayName, + role: "owner", + joinedAt: now, + }); +} + +@reducer({ name: "rename_workspace" }) +export function renameWorkspace( + ctx: ReducerContext, + workspaceId: string, + newName: string, +): void { + requireMembership(ctx, workspaceId); + const ws = Workspace.findById(workspaceId); + if (!ws) throw new Error(`Workspace ${workspaceId} not found`); + + Workspace.updateById(workspaceId, { + ...ws, + name: newName, + updatedAt: new Date().toISOString(), + }); +} + +@reducer({ name: "delete_workspace" }) +export function deleteWorkspace( + ctx: ReducerContext, + workspaceId: string, +): void { + const member = requireMembership(ctx, workspaceId); + if (member.role !== "owner") throw new Error("Only owners can delete workspaces"); + + // Delete all related data + for (const wf of Workflow.filterByWorkspaceId(workspaceId)) { + deleteWorkflowData(wf.id); + Workflow.delete(wf); + } + for (const m of WorkspaceMember.filterByWorkspaceId(workspaceId)) { + WorkspaceMember.delete(m); + } + for (const inv of WorkspaceInvite.filterByWorkspaceId(workspaceId)) { + WorkspaceInvite.delete(inv); + } + for (const doc of BrainDoc.filterByWorkspaceId(workspaceId)) { + for (const v of BrainDocVersion.filterByDocId(doc.id)) { + BrainDocVersion.delete(v); + } + for (const f of BrainFeedback.filterByDocId(doc.id)) { + BrainFeedback.delete(f); + } + BrainDoc.delete(doc); + } + for (const p of Presence.filterByWorkspaceId(workspaceId)) { + Presence.delete(p); + } + + const ws = Workspace.findById(workspaceId); + if (ws) Workspace.delete(ws); +} + +// ── Invite Reducers ──────────────────────────────────────────────────────── + +@reducer({ name: "create_invite" }) +export function createInvite( + ctx: ReducerContext, + workspaceId: string, + tokenHash: string, +): void { + requireMembership(ctx, workspaceId); + + WorkspaceInvite.insert({ + workspaceId, + tokenHash, + createdAt: new Date().toISOString(), + revokedAt: null, + }); +} + +@reducer({ name: "join_workspace" }) +export function joinWorkspace( + ctx: ReducerContext, + tokenHash: string, + displayName: string, +): void { + const invite = WorkspaceInvite.filterByTokenHash(tokenHash) + .find((inv: WorkspaceInvite) => inv.revokedAt === null); + + if (!invite) throw new Error("Invalid or revoked invite token"); + + // Check if already a member + if (isMember(ctx, invite.workspaceId)) return; + + WorkspaceMember.insert({ + workspaceId: invite.workspaceId, + identity: ctx.sender, + displayName, + role: "editor", + joinedAt: new Date().toISOString(), + }); +} + +// ── Workflow Reducers ────────────────────────────────────────────────────── + +@reducer({ name: "create_workflow" }) +export function createWorkflow( + ctx: ReducerContext, + id: string, + workspaceId: string, + name: string, + displayName: string, +): void { + requireMembership(ctx, workspaceId); + const now = new Date().toISOString(); + + Workflow.insert({ + id, + workspaceId, + name, + createdAt: now, + updatedAt: now, + lastModifiedBy: displayName, + }); +} + +@reducer({ name: "rename_workflow" }) +export function renameWorkflow( + ctx: ReducerContext, + workflowId: string, + newName: string, +): void { + const wf = Workflow.findById(workflowId); + if (!wf) throw new Error(`Workflow ${workflowId} not found`); + requireMembership(ctx, wf.workspaceId); + + const oldName = wf.name; + Workflow.updateById(workflowId, { + ...wf, + name: newName, + updatedAt: new Date().toISOString(), + }); + + WorkflowChangeEvent.insert({ + workflowId, + eventType: "node_renamed", + nodeId: null, + details: JSON.stringify({ from: oldName, to: newName, by: "user" }), + timestamp: new Date().toISOString(), + }); +} + +@reducer({ name: "delete_workflow" }) +export function deleteWorkflow( + ctx: ReducerContext, + workflowId: string, +): void { + const wf = Workflow.findById(workflowId); + if (!wf) throw new Error(`Workflow ${workflowId} not found`); + requireMembership(ctx, wf.workspaceId); + + deleteWorkflowData(workflowId); + Workflow.delete(wf); +} + +function deleteWorkflowData(workflowId: string): void { + for (const node of WorkflowNode.filterByWorkflowId(workflowId)) { + WorkflowNode.delete(node); + } + for (const edge of WorkflowEdge.filterByWorkflowId(workflowId)) { + WorkflowEdge.delete(edge); + } + for (const evt of WorkflowChangeEvent.filterByWorkflowId(workflowId)) { + WorkflowChangeEvent.delete(evt); + } + const uiState = WorkflowUiState.findByWorkflowId(workflowId); + if (uiState) WorkflowUiState.delete(uiState); +} + +// ── Batch Operation Reducer ──────────────────────────────────────────────── + +/** + * Batch applies workflow graph operations. Each operation is one of: + * { op: "upsert_node", nodeId, type, positionJson, dataJson } + * { op: "delete_node", nodeId } + * { op: "upsert_edge", edgeId, source, target, handlesJson, dataJson } + * { op: "delete_edge", edgeId } + * + * Reducer writes a workflow_change_event for each mutation. + */ +@reducer({ name: "apply_workflow_ops" }) +export function applyWorkflowOps( + ctx: ReducerContext, + workflowId: string, + opsJson: string, // JSON array of operations + displayName: string, +): void { + const wf = Workflow.findById(workflowId); + if (!wf) throw new Error(`Workflow ${workflowId} not found`); + requireMembership(ctx, wf.workspaceId); + + const ops = JSON.parse(opsJson) as WorkflowOp[]; + const now = new Date().toISOString(); + + for (const op of ops) { + switch (op.op) { + case "upsert_node": { + const existing = WorkflowNode.filterByWorkflowId(workflowId) + .find((n: WorkflowNode) => n.nodeId === op.nodeId); + + if (existing) { + WorkflowNode.delete(existing); + } + + WorkflowNode.insert({ + workflowId, + nodeId: op.nodeId!, + type: op.type!, + positionJson: op.positionJson!, + dataJson: op.dataJson!, + updatedAt: now, + updatedBy: displayName, + }); + + if (!existing) { + WorkflowChangeEvent.insert({ + workflowId, + eventType: "node_added", + nodeId: op.nodeId!, + details: JSON.stringify({ nodeName: op.type, by: displayName }), + timestamp: now, + }); + } + break; + } + + case "delete_node": { + const node = WorkflowNode.filterByWorkflowId(workflowId) + .find((n: WorkflowNode) => n.nodeId === op.nodeId); + if (node) { + WorkflowNode.delete(node); + WorkflowChangeEvent.insert({ + workflowId, + eventType: "node_deleted", + nodeId: op.nodeId!, + details: JSON.stringify({ nodeName: node.type, by: displayName }), + timestamp: now, + }); + } + break; + } + + case "upsert_edge": { + const existing = WorkflowEdge.filterByWorkflowId(workflowId) + .find((e: WorkflowEdge) => e.edgeId === op.edgeId); + + if (existing) { + WorkflowEdge.delete(existing); + } + + WorkflowEdge.insert({ + workflowId, + edgeId: op.edgeId!, + source: op.source!, + target: op.target!, + handlesJson: op.handlesJson ?? "{}", + dataJson: op.dataJson ?? "{}", + updatedAt: now, + updatedBy: displayName, + }); + + if (!existing) { + WorkflowChangeEvent.insert({ + workflowId, + eventType: "edge_added", + nodeId: null, + details: JSON.stringify({ edgeId: op.edgeId, by: displayName }), + timestamp: now, + }); + } + break; + } + + case "delete_edge": { + const edge = WorkflowEdge.filterByWorkflowId(workflowId) + .find((e: WorkflowEdge) => e.edgeId === op.edgeId); + if (edge) { + WorkflowEdge.delete(edge); + WorkflowChangeEvent.insert({ + workflowId, + eventType: "edge_deleted", + nodeId: null, + details: JSON.stringify({ edgeId: op.edgeId, by: displayName }), + timestamp: now, + }); + } + break; + } + } + } + + // Update workflow timestamp + Workflow.updateById(workflowId, { + ...wf, + updatedAt: now, + lastModifiedBy: displayName, + }); +} + +interface WorkflowOp { + op: "upsert_node" | "delete_node" | "upsert_edge" | "delete_edge"; + nodeId?: string; + type?: string; + positionJson?: string; + dataJson?: string; + edgeId?: string; + source?: string; + target?: string; + handlesJson?: string; +} + +// ── UI State Reducer ─────────────────────────────────────────────────────── + +@reducer({ name: "update_workflow_ui_state" }) +export function updateWorkflowUiState( + ctx: ReducerContext, + workflowId: string, + uiStateJson: string, +): void { + const wf = Workflow.findById(workflowId); + if (!wf) throw new Error(`Workflow ${workflowId} not found`); + requireMembership(ctx, wf.workspaceId); + + const existing = WorkflowUiState.findByWorkflowId(workflowId); + if (existing) { + WorkflowUiState.updateByWorkflowId(workflowId, { + ...existing, + uiStateJson, + }); + } else { + WorkflowUiState.insert({ workflowId, uiStateJson }); + } +} + +// ── Brain Reducers ───────────────────────────────────────────────────────── + +@reducer({ name: "save_brain_doc" }) +export function saveBrainDoc( + ctx: ReducerContext, + id: string, + workspaceId: string, + title: string, + contentJson: string, + versionId: string | null, +): void { + requireMembership(ctx, workspaceId); + const now = new Date().toISOString(); + + const existing = BrainDoc.findById(id); + if (existing) { + // Create version snapshot before overwriting + if (versionId) { + BrainDocVersion.insert({ + docId: id, + versionId, + contentJson: existing.contentJson, + createdAt: now, + }); + } + + BrainDoc.updateById(id, { + ...existing, + title, + contentJson, + updatedAt: now, + deletedAt: null, // un-delete if previously soft-deleted + }); + } else { + BrainDoc.insert({ + id, + workspaceId, + title, + contentJson, + createdAt: now, + updatedAt: now, + deletedAt: null, + }); + } +} + +@reducer({ name: "delete_brain_doc" }) +export function deleteBrainDoc( + ctx: ReducerContext, + docId: string, +): void { + const doc = BrainDoc.findById(docId); + if (!doc) throw new Error(`Brain doc ${docId} not found`); + requireMembership(ctx, doc.workspaceId); + + // Soft delete + BrainDoc.updateById(docId, { + ...doc, + deletedAt: new Date().toISOString(), + }); +} + +@reducer({ name: "record_brain_view" }) +export function recordBrainView( + ctx: ReducerContext, + docId: string, +): void { + const doc = BrainDoc.findById(docId); + if (!doc) throw new Error(`Brain doc ${docId} not found`); + requireMembership(ctx, doc.workspaceId); + + // Update the content JSON to increment view count + const content = JSON.parse(doc.contentJson); + if (content.metrics) { + content.metrics.views = (content.metrics.views || 0) + 1; + content.metrics.lastViewedAt = new Date().toISOString(); + } + + BrainDoc.updateById(docId, { + ...doc, + contentJson: JSON.stringify(content), + }); +} + +@reducer({ name: "add_brain_feedback" }) +export function addBrainFeedback( + ctx: ReducerContext, + docId: string, + type: string, + comment: string, +): void { + const doc = BrainDoc.findById(docId); + if (!doc) throw new Error(`Brain doc ${docId} not found`); + requireMembership(ctx, doc.workspaceId); + + BrainFeedback.insert({ + docId, + identity: ctx.sender, + type, + comment, + createdAt: new Date().toISOString(), + }); +} + +@reducer({ name: "restore_brain_doc_version" }) +export function restoreBrainDocVersion( + ctx: ReducerContext, + docId: string, + versionId: string, + snapshotVersionId: string, +): void { + const doc = BrainDoc.findById(docId); + if (!doc) throw new Error(`Brain doc ${docId} not found`); + requireMembership(ctx, doc.workspaceId); + + const version = BrainDocVersion.filterByDocId(docId) + .find((v: BrainDocVersion) => v.versionId === versionId); + if (!version) throw new Error(`Version ${versionId} not found`); + + const now = new Date().toISOString(); + + // Snapshot current state before restoring + BrainDocVersion.insert({ + docId, + versionId: snapshotVersionId, + contentJson: doc.contentJson, + createdAt: now, + }); + + // Restore the version + BrainDoc.updateById(docId, { + ...doc, + contentJson: version.contentJson, + updatedAt: now, + deletedAt: null, + }); +} + +// ── Presence Reducer ─────────────────────────────────────────────────────── + +@reducer({ name: "update_presence" }) +export function updatePresence( + ctx: ReducerContext, + workspaceId: string, + workflowId: string, + displayName: string, + selectedNodeId: string | null, +): void { + const now = new Date().toISOString(); + + // Find existing presence row for this identity in this workspace+workflow + const existing = Presence.filterByIdentity(ctx.sender) + .find((p: Presence) => p.workspaceId === workspaceId && p.workflowId === workflowId); + + if (existing) { + Presence.delete(existing); + } + + Presence.insert({ + workspaceId, + workflowId, + identity: ctx.sender, + displayName, + selectedNodeId, + lastSeenAt: now, + }); +} + +// ── Import Reducers (for migration) ──────────────────────────────────────── + +@reducer({ name: "import_workspace" }) +export function importWorkspace( + ctx: ReducerContext, + id: string, + name: string, + createdAt: string, + updatedAt: string, + displayName: string, +): void { + // Check if already exists (idempotent) + if (Workspace.findById(id)) return; + + Workspace.insert({ id, name, createdAt, updatedAt }); + + WorkspaceMember.insert({ + workspaceId: id, + identity: ctx.sender, + displayName, + role: "owner", + joinedAt: new Date().toISOString(), + }); +} + +@reducer({ name: "import_workflow_snapshot" }) +export function importWorkflowSnapshot( + ctx: ReducerContext, + workflowId: string, + workspaceId: string, + name: string, + nodesJson: string, + edgesJson: string, + uiStateJson: string, + createdAt: string, + updatedAt: string, + lastModifiedBy: string, +): void { + // Check if already exists (idempotent) + if (Workflow.findById(workflowId)) return; + requireMembership(ctx, workspaceId); + + Workflow.insert({ + id: workflowId, + workspaceId, + name, + createdAt, + updatedAt, + lastModifiedBy, + }); + + // Import nodes + const nodes = JSON.parse(nodesJson) as Array<{ + nodeId: string; + type: string; + positionJson: string; + dataJson: string; + }>; + for (const node of nodes) { + WorkflowNode.insert({ + workflowId, + nodeId: node.nodeId, + type: node.type, + positionJson: node.positionJson, + dataJson: node.dataJson, + updatedAt, + updatedBy: lastModifiedBy, + }); + } + + // Import edges + const edges = JSON.parse(edgesJson) as Array<{ + edgeId: string; + source: string; + target: string; + handlesJson: string; + dataJson: string; + }>; + for (const edge of edges) { + WorkflowEdge.insert({ + workflowId, + edgeId: edge.edgeId, + source: edge.source, + target: edge.target, + handlesJson: edge.handlesJson, + dataJson: edge.dataJson, + updatedAt, + updatedBy: lastModifiedBy, + }); + } + + // Import UI state + if (uiStateJson !== "{}") { + WorkflowUiState.insert({ workflowId, uiStateJson }); + } +} + +@reducer({ name: "import_brain_doc" }) +export function importBrainDoc( + ctx: ReducerContext, + id: string, + workspaceId: string, + title: string, + contentJson: string, + createdAt: string, + updatedAt: string, +): void { + requireMembership(ctx, workspaceId); + + // Check if already exists (idempotent) + if (BrainDoc.findById(id)) return; + + BrainDoc.insert({ + id, + workspaceId, + title, + contentJson, + createdAt, + updatedAt, + deletedAt: null, + }); +} diff --git a/spacetime/nexus/tsconfig.json b/spacetime/nexus/tsconfig.json new file mode 100644 index 0000000..543286b --- /dev/null +++ b/spacetime/nexus/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "isolatedModules": true + }, + "include": ["src/index.ts"] +} diff --git a/src/app/api/brain/documents/route.ts b/src/app/api/brain/documents/route.ts index dac40a4..96a266a 100644 --- a/src/app/api/brain/documents/route.ts +++ b/src/app/api/brain/documents/route.ts @@ -1,3 +1,4 @@ +// DEPRECATED: Temporary shim — will be removed once all clients use SpacetimeDB directly. import { NextResponse } from "next/server"; import { saveBrainDocInputSchema } from "@/lib/brain/schemas"; import { getBrainStore, getBrainTokenFromHeaders, requireWorkspace } from "@/lib/brain/server"; diff --git a/src/app/api/brain/session/route.ts b/src/app/api/brain/session/route.ts index ce1f4d7..8f13910 100644 --- a/src/app/api/brain/session/route.ts +++ b/src/app/api/brain/session/route.ts @@ -1,3 +1,4 @@ +// DEPRECATED: Temporary shim — will be removed once all clients use SpacetimeDB directly. import { NextResponse } from "next/server"; import { brainSessionRequestSchema } from "@/lib/brain/schemas"; import { getBrainStore } from "@/lib/brain/server"; diff --git a/src/app/api/workspaces/[id]/changes/route.ts b/src/app/api/workspaces/[id]/changes/route.ts new file mode 100644 index 0000000..ddb6527 --- /dev/null +++ b/src/app/api/workspaces/[id]/changes/route.ts @@ -0,0 +1,33 @@ +// DEPRECATED: With SpacetimeDB enabled, clients subscribe to workflow_change_event +// rows directly. This REST endpoint remains as a fallback for filesystem-based mode. +// Once SpacetimeDB migration is complete, remove this route. +import { NextResponse } from "next/server"; +import { computeChanges } from "@/lib/workspace/snapshots"; + +export const dynamic = "force-dynamic"; + +type RouteParams = { params: Promise<{ id: string }> }; + +export async function GET(request: Request, { params }: RouteParams) { + try { + const { id } = await params; + const url = new URL(request.url); + const since = url.searchParams.get("since"); + + if (!since) { + return NextResponse.json({ error: "Missing required 'since' query parameter" }, { status: 400 }); + } + + // Validate ISO timestamp + const parsed = Date.parse(since); + if (isNaN(parsed)) { + return NextResponse.json({ error: "Invalid 'since' timestamp — must be ISO 8601" }, { status: 400 }); + } + + const result = await computeChanges(id, since); + return NextResponse.json(result); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to compute changes"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/workspaces/[id]/route.ts b/src/app/api/workspaces/[id]/route.ts index 4049dc3..29c44ec 100644 --- a/src/app/api/workspaces/[id]/route.ts +++ b/src/app/api/workspaces/[id]/route.ts @@ -1,6 +1,7 @@ +// DEPRECATED: Temporary shim — will be removed once all clients use SpacetimeDB directly. import { NextResponse } from "next/server"; import { UpdateWorkspaceSchema } from "@/lib/workspace/schemas"; -import { getWorkspace, updateWorkspace } from "@/lib/workspace/server"; +import { deleteWorkspace, getWorkspace, updateWorkspace } from "@/lib/workspace/server"; export const dynamic = "force-dynamic"; @@ -45,3 +46,20 @@ export async function PATCH( return NextResponse.json({ error: message }, { status: 500 }); } } + +export async function DELETE( + _request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { id } = await params; + const deleted = await deleteWorkspace(id); + if (!deleted) { + return NextResponse.json({ error: "Workspace not found" }, { status: 404 }); + } + return new NextResponse(null, { status: 204 }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to delete workspace"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/workspaces/[id]/workflows/[wid]/route.ts b/src/app/api/workspaces/[id]/workflows/[wid]/route.ts index 3ec82ee..16ddd73 100644 --- a/src/app/api/workspaces/[id]/workflows/[wid]/route.ts +++ b/src/app/api/workspaces/[id]/workflows/[wid]/route.ts @@ -1,3 +1,4 @@ +// DEPRECATED: Temporary shim — will be removed once all clients use SpacetimeDB directly. import { NextResponse } from "next/server"; import { SaveWorkflowSchema, UpdateWorkflowMetaSchema } from "@/lib/workspace/schemas"; import { diff --git a/src/app/api/workspaces/[id]/workflows/[wid]/snapshots/[timestamp]/route.ts b/src/app/api/workspaces/[id]/workflows/[wid]/snapshots/[timestamp]/route.ts new file mode 100644 index 0000000..6da3734 --- /dev/null +++ b/src/app/api/workspaces/[id]/workflows/[wid]/snapshots/[timestamp]/route.ts @@ -0,0 +1,29 @@ +import { NextResponse } from "next/server"; +import { getSnapshot } from "@/lib/workspace/snapshots"; + +export const dynamic = "force-dynamic"; + +type RouteParams = { params: Promise<{ id: string; wid: string; timestamp: string }> }; + +export async function GET(_request: Request, { params }: RouteParams) { + try { + const { id, wid, timestamp } = await params; + // Decode URL-safe timestamp back to ISO + const tIndex = timestamp.indexOf("T"); + let isoTimestamp = timestamp; + if (tIndex >= 0) { + const datePart = timestamp.slice(0, tIndex); + const timePart = timestamp.slice(tIndex).replace(/-/g, ":"); + isoTimestamp = datePart + timePart; + } + + const snapshot = await getSnapshot(id, wid, isoTimestamp); + if (!snapshot) { + return NextResponse.json({ error: "Snapshot not found" }, { status: 404 }); + } + return NextResponse.json(snapshot); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to read snapshot"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/workspaces/[id]/workflows/[wid]/snapshots/route.ts b/src/app/api/workspaces/[id]/workflows/[wid]/snapshots/route.ts new file mode 100644 index 0000000..13fb21f --- /dev/null +++ b/src/app/api/workspaces/[id]/workflows/[wid]/snapshots/route.ts @@ -0,0 +1,18 @@ +// DEPRECATED: Temporary shim — will be removed once all clients use SpacetimeDB directly. +import { NextResponse } from "next/server"; +import { listSnapshots } from "@/lib/workspace/snapshots"; + +export const dynamic = "force-dynamic"; + +type RouteParams = { params: Promise<{ id: string; wid: string }> }; + +export async function GET(_request: Request, { params }: RouteParams) { + try { + const { id, wid } = await params; + const metas = await listSnapshots(id, wid); + return NextResponse.json(metas); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to list snapshots"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} diff --git a/src/app/api/workspaces/[id]/workflows/route.ts b/src/app/api/workspaces/[id]/workflows/route.ts index 17c1f96..ce64893 100644 --- a/src/app/api/workspaces/[id]/workflows/route.ts +++ b/src/app/api/workspaces/[id]/workflows/route.ts @@ -1,3 +1,4 @@ +// DEPRECATED: Temporary shim — will be removed once all clients use SpacetimeDB directly. import { NextResponse } from "next/server"; import { CreateWorkflowSchema } from "@/lib/workspace/schemas"; import { createWorkflow } from "@/lib/workspace/server"; diff --git a/src/app/api/workspaces/route.ts b/src/app/api/workspaces/route.ts index e2fcce0..05a0aac 100644 --- a/src/app/api/workspaces/route.ts +++ b/src/app/api/workspaces/route.ts @@ -1,9 +1,20 @@ +// DEPRECATED: Temporary shim — will be removed once all clients use SpacetimeDB directly. import { NextResponse } from "next/server"; import { CreateWorkspaceSchema } from "@/lib/workspace/schemas"; -import { createWorkspace } from "@/lib/workspace/server"; +import { createWorkspace, listWorkspaces } from "@/lib/workspace/server"; export const dynamic = "force-dynamic"; +export async function GET() { + try { + const workspaces = await listWorkspaces(); + return NextResponse.json({ workspaces }); + } catch (error) { + const message = error instanceof Error ? error.message : "Failed to list workspaces"; + return NextResponse.json({ error: message }, { status: 500 }); + } +} + export async function POST(request: Request) { try { const parsed = CreateWorkspaceSchema.safeParse(await request.json().catch(() => ({}))); diff --git a/src/components/workflow/collaboration/share-button.tsx b/src/components/workflow/collaboration/share-button.tsx index e32549c..00d477f 100644 --- a/src/components/workflow/collaboration/share-button.tsx +++ b/src/components/workflow/collaboration/share-button.tsx @@ -23,15 +23,17 @@ interface ShareButtonProps { export function ShareButton({ shareUrlOverride }: ShareButtonProps = {}) { const getWorkflowJSON = useWorkflowStore((s) => s.getWorkflowJSON); const roomId = useCollabStore((s) => s.roomId); + const syncBackend = useCollabStore((s) => s.syncBackend); const isConnected = useCollabStore((s) => s.isConnected); const isInitializing = useCollabStore((s) => s.isInitializing); const peerCount = useCollabStore((s) => s.peerCount); const [copied, setCopied] = useState(false); const [open, setOpen] = useState(false); - const isActive = roomId !== null; + const isActive = roomId !== null || syncBackend === "spacetimedb"; + const canStopSharing = roomId !== null; - const collabUrl = shareUrlOverride ?? (isActive && roomId ? buildCollabShareUrl(roomId) : ""); + const collabUrl = shareUrlOverride ?? (roomId ? buildCollabShareUrl(roomId) : ""); const handleShare = useCallback(() => { const id = createRoomId(); @@ -56,8 +58,8 @@ export function ShareButton({ shareUrlOverride }: ShareButtonProps = {}) { setTimeout(() => setCopied(false), 2000); }, [collabUrl]); - // Workspace mode: share button always copies workspace URL - if (shareUrlOverride) { + // Workspace mode before the room starts: copy the stable workspace URL. + if (shareUrlOverride && !isActive) { return ( + {canStopSharing && ( + + )} diff --git a/src/components/workflow/header.tsx b/src/components/workflow/header.tsx index 92e0155..2f78e63 100644 --- a/src/components/workflow/header.tsx +++ b/src/components/workflow/header.tsx @@ -4,7 +4,8 @@ import { useCallback, useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { ArrowLeft } from "lucide-react"; import { getGenerationTarget } from "@/lib/generation-targets"; -import { buildWorkspaceCollabShareUrl } from "@/lib/collaboration/config"; +import { buildWorkspaceCollabShareUrl, buildWorkspaceYjsShareUrl } from "@/lib/collaboration/config"; +import { isSpacetimeConfigured } from "@/lib/spacetime/config"; import { BG_SURFACE, BORDER_DEFAULT, TEXT_MUTED } from "@/lib/theme"; import { HelpMenu } from "./shared-header-actions"; import { HeaderBrand } from "./header/brand"; @@ -84,7 +85,9 @@ export default function Header({ workspaceContext }: HeaderProps) { }, [workspaceContext]); const workspaceShareUrl = workspaceContext - ? buildWorkspaceCollabShareUrl(workspaceContext.workspaceId, workspaceContext.workflowId) + ? isSpacetimeConfigured() + ? buildWorkspaceCollabShareUrl(workspaceContext.workspaceId, workspaceContext.workflowId) + : buildWorkspaceYjsShareUrl(workspaceContext.workspaceId, workspaceContext.workflowId) : undefined; return ( diff --git a/src/components/workflow/workflow-editor.tsx b/src/components/workflow/workflow-editor.tsx index faa2186..ab67dcf 100644 --- a/src/components/workflow/workflow-editor.tsx +++ b/src/components/workflow/workflow-editor.tsx @@ -27,6 +27,10 @@ import { useCollaboration } from "./collaboration/use-collaboration"; import { CollabDoc } from "@/lib/collaboration"; import { buildWorkspaceRoomId } from "@/lib/collaboration/config"; import { useWorkspaceAutosave } from "@/hooks/use-workspace-autosave"; +import { isSpacetimeConfigured } from "@/lib/spacetime/config"; +import { spacetimeWorkspaceSync } from "@/lib/spacetime/workspace-sync"; +import { spacetimeBrainSync } from "@/lib/spacetime/brain-sync"; +import { spacetimePresence } from "@/lib/spacetime/presence"; function isEditableTarget(target: EventTarget | null): boolean { if (!(target instanceof HTMLElement)) return false; @@ -68,15 +72,30 @@ export default function WorkflowEditor({ // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // Workspace mode: auto-start Y.js with stable room ID + // Workspace mode: choose exactly one live sync backend. useEffect(() => { if (!isWorkspaceMode || !workspaceId || !workflowId) return; - const roomId = buildWorkspaceRoomId(workspaceId, workflowId); - const doc = CollabDoc.getOrCreate(); - doc.start(roomId, getWorkflowJSON()); + + const useSpacetimeBackend = isSpacetimeConfigured(); + + if (useSpacetimeBackend) { + spacetimeWorkspaceSync.startSync(workspaceId, workflowId, "Anonymous"); + spacetimeBrainSync.startBrainSync(workspaceId); + spacetimePresence.startPresence(workspaceId, workflowId, "Anonymous"); + } else { + const roomId = buildWorkspaceRoomId(workspaceId, workflowId); + const doc = CollabDoc.getOrCreate(); + doc.start(roomId, getWorkflowJSON()); + } return () => { - CollabDoc.getInstance()?.destroy(); + if (useSpacetimeBackend) { + spacetimePresence.stopPresence(); + spacetimeWorkspaceSync.stopSync(); + spacetimeBrainSync.stopBrainSync(); + } else { + CollabDoc.getInstance()?.destroy(); + } }; // Only run once on mount // eslint-disable-next-line react-hooks/exhaustive-deps @@ -85,18 +104,22 @@ export default function WorkflowEditor({ // Standalone mode: collaboration via ?room= URL useCollaboration({ skip: isWorkspaceMode }); - // Auto-save to server in workspace mode + // Auto-save to server in workspace mode (skip when SpacetimeDB handles persistence) + const useSpacetime = isWorkspaceMode && isSpacetimeConfigured(); useWorkspaceAutosave( - isWorkspaceMode ? { workspaceId: workspaceId!, workflowId: workflowId!, displayName: "Anonymous" } : null, + isWorkspaceMode && !useSpacetime ? { workspaceId: workspaceId!, workflowId: workflowId!, displayName: "Anonymous" } : null, ); - // Report local selected node to remote peers via Y.js awareness + // Report local selected node to remote peers via awareness (SpacetimeDB or Y.js) useEffect(() => { const unsub = useWorkflowStore.subscribe((state) => { CollabDoc.getInstance()?.updateAwareness({ selectedNodeId: state.selectedNodeId }); + if (spacetimePresence.isActive()) { + spacetimePresence.updateSelection(state.selectedNodeId ?? null); + } }); return () => unsub(); - }, []); + }, [useSpacetime]); // Listen for sub-workflow open events from properties panel useEffect(() => { diff --git a/src/components/workspace/changes-panel.tsx b/src/components/workspace/changes-panel.tsx new file mode 100644 index 0000000..c3a6b86 --- /dev/null +++ b/src/components/workspace/changes-panel.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { X, Plus, Trash2, PenLine } from "lucide-react"; +import { BG_SURFACE, BORDER_DEFAULT, TEXT_PRIMARY, TEXT_MUTED } from "@/lib/theme"; +import type { WorkflowChanges, ChangeEvent } from "@/lib/workspace/types"; + +// Same 8 hue slots used in awareness-names.ts +const HUE_SLOTS = [ + { color: "#7c3aed", colorLight: "#ede9fe" }, // violet + { color: "#0284c7", colorLight: "#e0f2fe" }, // sky + { color: "#d97706", colorLight: "#fef3c7" }, // amber + { color: "#059669", colorLight: "#d1fae5" }, // emerald + { color: "#e11d48", colorLight: "#ffe4e6" }, // rose + { color: "#4f46e5", colorLight: "#e0e7ff" }, // indigo + { color: "#ea580c", colorLight: "#ffedd5" }, // orange + { color: "#0d9488", colorLight: "#ccfbf1" }, // teal +]; + +function getColorForName(name: string): { color: string; colorLight: string } { + let hash = 0; + for (let i = 0; i < name.length; i++) { + hash += name.charCodeAt(i); + } + return HUE_SLOTS[hash % HUE_SLOTS.length]; +} + +function formatSinceDate(iso: string): string { + try { + const date = new Date(iso); + return date.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); + } catch { + return iso; + } +} + +function EventIcon({ type }: { type: ChangeEvent["type"] }) { + switch (type) { + case "node_added": + return ; + case "node_deleted": + return ; + case "node_renamed": + return ; + } +} + +function eventDescription(event: ChangeEvent): string { + switch (event.type) { + case "node_added": + return `added ${event.nodeName}`; + case "node_deleted": + return `deleted ${event.nodeName}`; + case "node_renamed": + return `renamed ${event.from} → ${event.to}`; + } +} + +interface ChangesPanelProps { + changes: WorkflowChanges[]; + since: string; + onDismiss: () => void; +} + +export function ChangesPanel({ changes, since, onDismiss }: ChangesPanelProps) { + const totalChanges = changes.reduce((sum, wf) => sum + wf.changeCount, 0); + + return ( +
+ {/* Header */} +
+
+

+ {totalChanges} change{totalChanges !== 1 ? "s" : ""} +

+

since {formatSinceDate(since)}

+
+ +
+ + {/* Body */} +
+ {changes.map((wf) => ( +
+

+ {wf.workflowName} +

+
+ {wf.events.map((event, i) => { + const { color } = getColorForName(event.by); + const initial = event.by.charAt(0).toUpperCase(); + return ( +
+ {/* Colored initial badge */} +
+ {initial} +
+
+ + + {event.by}{" "} + {eventDescription(event)} + +
+
+ ); + })} +
+
+ ))} +
+
+ ); +} diff --git a/src/components/workspace/dashboard.tsx b/src/components/workspace/dashboard.tsx index 3191771..e0ae945 100644 --- a/src/components/workspace/dashboard.tsx +++ b/src/components/workspace/dashboard.tsx @@ -1,14 +1,16 @@ "use client"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { Plus, Loader2 } from "lucide-react"; import { useWorkspace } from "@/hooks/use-workspace"; +import { useWorkspaceChanges } from "@/hooks/use-workspace-changes"; import { addRecentWorkspace } from "@/lib/workspace/local-history"; import { BG_APP, TEXT_PRIMARY, TEXT_MUTED, BORDER_DEFAULT } from "@/lib/theme"; import { WorkspaceHeader } from "./workspace-header"; import { WorkflowCard } from "./workflow-card"; import { EmptyState } from "./empty-state"; +import { ChangesPanel } from "./changes-panel"; interface WorkspaceDashboardProps { workspaceId: string; @@ -17,6 +19,18 @@ interface WorkspaceDashboardProps { export function WorkspaceDashboard({ workspaceId }: WorkspaceDashboardProps) { const router = useRouter(); const { workspace, workflows, isLoading, error, refetch } = useWorkspace(workspaceId); + const { changes, isLoading: changesLoading, since, markSeen } = useWorkspaceChanges( + workspaceId, + !isLoading && !!workspace, + ); + const [dismissed, setDismissed] = useState(false); + + // Mark seen once both workspace and changes are loaded + useEffect(() => { + if (workspace && !changesLoading) { + markSeen(); + } + }, [workspace, changesLoading, markSeen]); useEffect(() => { if (workspace) { @@ -61,6 +75,8 @@ export function WorkspaceDashboard({ workspaceId }: WorkspaceDashboardProps) { ); } + const showChangesPanel = !dismissed && !changesLoading && changes.length > 0; + return (
-
+ {showChangesPanel && ( + setDismissed(true)} + /> + )} + +
{workflows.length === 0 ? ( ) : ( diff --git a/src/components/workspace/landing-page.tsx b/src/components/workspace/landing-page.tsx index d21578b..140a610 100644 --- a/src/components/workspace/landing-page.tsx +++ b/src/components/workspace/landing-page.tsx @@ -1,15 +1,50 @@ "use client"; -import { useState } from "react"; +import { useState, useEffect, useCallback } from "react"; import { useRouter } from "next/navigation"; -import { Pencil, Users, Plus, Loader2 } from "lucide-react"; +import { Pencil, Users, Plus, Loader2, X, Clock, FolderOpen, Trash2 } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { BG_APP, BG_SURFACE, BORDER_DEFAULT, TEXT_PRIMARY, TEXT_MUTED } from "@/lib/theme"; +import { removeRecentWorkspace } from "@/lib/workspace/local-history"; import { RecentWorkspaces } from "./recent-workspaces"; +import { toast } from "sonner"; + +interface WorkspaceEntry { + id: string; + name: string; + createdAt: string; + updatedAt: string; +} export function LandingPage() { const router = useRouter(); const [creating, setCreating] = useState(false); + const [showPicker, setShowPicker] = useState(false); + const [pickerLoading, setPickerLoading] = useState(false); + const [pickerWorkspaces, setPickerWorkspaces] = useState([]); + const [deleteTarget, setDeleteTarget] = useState(null); + const [deletingWorkspaceId, setDeletingWorkspaceId] = useState(null); + const [recentRefreshKey, setRecentRefreshKey] = useState(0); + + const fetchWorkspaces = useCallback(async () => { + setPickerLoading(true); + try { + const res = await fetch("/api/workspaces"); + if (res.ok) { + const { workspaces } = await res.json(); + setPickerWorkspaces(workspaces); + } + } finally { + setPickerLoading(false); + } + }, []); + + useEffect(() => { + if (showPicker) { + fetchWorkspaces(); + } + }, [showPicker, fetchWorkspaces]); const handleNewWorkspace = async () => { setCreating(true); @@ -27,6 +62,30 @@ export function LandingPage() { } }; + const handleDeleteWorkspace = async () => { + if (!deleteTarget || deletingWorkspaceId) return; + + const workspaceToDelete = deleteTarget; + setDeletingWorkspaceId(workspaceToDelete.id); + try { + const res = await fetch(`/api/workspaces/${workspaceToDelete.id}`, { + method: "DELETE", + }); + if (!res.ok) throw new Error("Failed to delete workspace"); + removeRecentWorkspace(workspaceToDelete.id); + setPickerWorkspaces((workspaces) => + workspaces.filter((workspace) => workspace.id !== workspaceToDelete.id), + ); + setRecentRefreshKey((key) => key + 1); + setDeleteTarget(null); + toast.success("Workspace deleted"); + } catch { + toast.error("Failed to delete workspace"); + } finally { + setDeletingWorkspaceId(null); + } + }; + return (
@@ -48,12 +107,12 @@ export function LandingPage() {
@@ -74,8 +133,93 @@ export function LandingPage() {
- + {showPicker && ( +
+
+

+ + Select a workspace +

+ +
+ + {pickerLoading ? ( +
+ +
+ ) : pickerWorkspaces.length === 0 ? ( +

+ No workspaces yet. Create one to get started. +

+ ) : ( +
+ {pickerWorkspaces.map((ws) => ( +
+ + +
+ ))} +
+ )} +
+ )} + +
+ + { + if (!open) setDeleteTarget(null); + }} + tone="danger" + title="Delete this workspace?" + description={ + deleteTarget ? ( + <> + This will permanently delete {deleteTarget.name} and + all of its workflows. + + ) : undefined + } + confirmLabel={deletingWorkspaceId ? "Deleting..." : "Delete workspace"} + onConfirm={() => { + void handleDeleteWorkspace(); + }} + /> ); } diff --git a/src/components/workspace/recent-workspaces.tsx b/src/components/workspace/recent-workspaces.tsx index fbca6a9..ea45d14 100644 --- a/src/components/workspace/recent-workspaces.tsx +++ b/src/components/workspace/recent-workspaces.tsx @@ -1,9 +1,12 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useRouter } from "next/navigation"; import { Clock, Workflow } from "lucide-react"; -import { getRecentWorkspaces } from "@/lib/workspace/local-history"; +import { + getRecentWorkspaces, + type RecentWorkspaceEntry, +} from "@/lib/workspace/local-history"; import { TEXT_MUTED, TEXT_SECONDARY, BORDER_DEFAULT } from "@/lib/theme"; function timeAgo(dateStr: string): string { @@ -17,9 +20,21 @@ function timeAgo(dateStr: string): string { return `${days}d ago`; } -export function RecentWorkspaces() { +interface RecentWorkspacesProps { + refreshKey?: number; +} + +export function RecentWorkspaces({ refreshKey = 0 }: RecentWorkspacesProps) { const router = useRouter(); - const [entries] = useState(() => getRecentWorkspaces()); + const [entries, setEntries] = useState([]); + + useEffect(() => { + const loadEntries = window.setTimeout(() => { + setEntries(getRecentWorkspaces()); + }, 0); + + return () => window.clearTimeout(loadEntries); + }, [refreshKey]); if (entries.length === 0) return null; diff --git a/src/components/workspace/workspace-header.tsx b/src/components/workspace/workspace-header.tsx index d279f2b..1d4457b 100644 --- a/src/components/workspace/workspace-header.tsx +++ b/src/components/workspace/workspace-header.tsx @@ -2,9 +2,11 @@ import { useState, useRef, useEffect, type KeyboardEvent } from "react"; import { useRouter } from "next/navigation"; -import { ArrowLeft, Share2, Check, PencilLine } from "lucide-react"; +import { ArrowLeft, Share2, Check, PencilLine, Trash2, Loader2 } from "lucide-react"; import { Button } from "@/components/ui/button"; +import { ConfirmDialog } from "@/components/ui/confirm-dialog"; import { BG_SURFACE, BORDER_DEFAULT, TEXT_PRIMARY, TEXT_MUTED } from "@/lib/theme"; +import { removeRecentWorkspace } from "@/lib/workspace/local-history"; import { toast } from "sonner"; interface WorkspaceHeaderProps { @@ -18,6 +20,8 @@ export function WorkspaceHeader({ workspaceId, name, onNameChange }: WorkspaceHe const [isEditing, setIsEditing] = useState(false); const [editValue, setEditValue] = useState(name); const [copied, setCopied] = useState(false); + const [deleteOpen, setDeleteOpen] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); const inputRef = useRef(null); useEffect(() => { @@ -66,53 +70,100 @@ export function WorkspaceHeader({ workspaceId, name, onNameChange }: WorkspaceHe setTimeout(() => setCopied(false), 2000); }; + const handleDelete = async () => { + if (isDeleting) return; + + setIsDeleting(true); + try { + const res = await fetch(`/api/workspaces/${workspaceId}`, { + method: "DELETE", + }); + if (!res.ok) throw new Error("Failed to delete workspace"); + removeRecentWorkspace(workspaceId); + toast.success("Workspace deleted"); + router.push("/"); + } catch { + toast.error("Failed to delete workspace"); + setIsDeleting(false); + } + }; + return ( -
-
- + <> +
+
+ + +
+ {isEditing ? ( + setEditValue(e.target.value)} + onBlur={handleSave} + onKeyDown={handleKeyDown} + className={`w-full bg-transparent text-lg font-semibold ${TEXT_PRIMARY} outline-none`} + maxLength={100} + /> + ) : ( + + )} +

Workspace

+
+ + -
- {isEditing ? ( - setEditValue(e.target.value)} - onBlur={handleSave} - onKeyDown={handleKeyDown} - className={`w-full bg-transparent text-lg font-semibold ${TEXT_PRIMARY} outline-none`} - maxLength={100} - /> - ) : ( - - )} -

Workspace

+
+
- -
-
+ + This will permanently delete {name} and all of its workflows. + + } + confirmLabel={isDeleting ? "Deleting..." : "Delete workspace"} + onConfirm={() => { + void handleDelete(); + }} + /> + ); } diff --git a/src/hooks/use-workspace-changes.ts b/src/hooks/use-workspace-changes.ts new file mode 100644 index 0000000..3187da5 --- /dev/null +++ b/src/hooks/use-workspace-changes.ts @@ -0,0 +1,73 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import type { WorkflowChanges } from "@/lib/workspace/types"; + +const LAST_SEEN_PREFIX = "nexus:workspace-last-seen:"; + +function getLastSeen(workspaceId: string): string { + if (typeof window === "undefined") return new Date().toISOString(); + const stored = localStorage.getItem(LAST_SEEN_PREFIX + workspaceId); + if (stored) return stored; + // Default to 24 hours ago + return new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString(); +} + +interface UseWorkspaceChangesResult { + changes: WorkflowChanges[]; + isLoading: boolean; + since: string; + markSeen: () => void; +} + +export function useWorkspaceChanges( + workspaceId: string, + isReady: boolean, +): UseWorkspaceChangesResult { + const [changes, setChanges] = useState([]); + const [isLoading, setIsLoading] = useState(true); + const [since, setSince] = useState(""); + + useEffect(() => { + if (!isReady) return; + + const sinceValue = getLastSeen(workspaceId); + setSince(sinceValue); + + let cancelled = false; + + async function fetchChanges() { + setIsLoading(true); + try { + const res = await fetch( + `/api/workspaces/${workspaceId}/changes?since=${encodeURIComponent(sinceValue)}`, + ); + if (!res.ok) { + setChanges([]); + return; + } + const data = await res.json(); + if (!cancelled) { + setChanges(data.changes ?? []); + } + } catch { + if (!cancelled) setChanges([]); + } finally { + if (!cancelled) setIsLoading(false); + } + } + + fetchChanges(); + + return () => { + cancelled = true; + }; + }, [workspaceId, isReady]); + + const markSeen = useCallback(() => { + if (typeof window === "undefined") return; + localStorage.setItem(LAST_SEEN_PREFIX + workspaceId, new Date().toISOString()); + }, [workspaceId]); + + return { changes, isLoading, since, markSeen }; +} diff --git a/src/lib/__tests__/collaboration-config.test.ts b/src/lib/__tests__/collaboration-config.test.ts new file mode 100644 index 0000000..e06bdbf --- /dev/null +++ b/src/lib/__tests__/collaboration-config.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "bun:test"; +import { + buildWorkspaceCollabShareUrl, + buildWorkspaceRoomId, + buildWorkspaceYjsShareUrl, +} from "../collaboration/config"; + +describe("collaboration config", () => { + it("builds a deterministic workspace room id", () => { + expect(buildWorkspaceRoomId("ws-1", "wf-2")).toBe("nexus-ws-ws-1-wf-2"); + }); + + it("builds the stable workspace share URL", () => { + expect(buildWorkspaceCollabShareUrl("ws-1", "wf-2")).toBe("/workspace/ws-1/workflow/wf-2"); + }); + + it("builds the workspace Y.js share URL with the deterministic room query param", () => { + expect(buildWorkspaceYjsShareUrl("ws-1", "wf-2")).toBe( + "/workspace/ws-1/workflow/wf-2?room=nexus-ws-ws-1-wf-2", + ); + }); +}); diff --git a/src/lib/__tests__/spacetime-config.test.ts b/src/lib/__tests__/spacetime-config.test.ts new file mode 100644 index 0000000..43e613b --- /dev/null +++ b/src/lib/__tests__/spacetime-config.test.ts @@ -0,0 +1,56 @@ +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { getSpacetimeUri, getSpacetimeDbName, isSpacetimeConfigured } from "../spacetime/config"; + +describe("SpacetimeDB config", () => { + const origUri = process.env.NEXT_PUBLIC_SPACETIME_URI; + const origDb = process.env.NEXT_PUBLIC_SPACETIME_DB_NAME; + + beforeEach(() => { + delete process.env.NEXT_PUBLIC_SPACETIME_URI; + delete process.env.NEXT_PUBLIC_SPACETIME_DB_NAME; + }); + + afterEach(() => { + if (origUri !== undefined) process.env.NEXT_PUBLIC_SPACETIME_URI = origUri; + else delete process.env.NEXT_PUBLIC_SPACETIME_URI; + if (origDb !== undefined) process.env.NEXT_PUBLIC_SPACETIME_DB_NAME = origDb; + else delete process.env.NEXT_PUBLIC_SPACETIME_DB_NAME; + }); + + it("returns default URI when env var is not set", () => { + expect(getSpacetimeUri()).toBe("ws://localhost:3001"); + }); + + it("returns configured URI from env var", () => { + process.env.NEXT_PUBLIC_SPACETIME_URI = "wss://prod.example.com"; + expect(getSpacetimeUri()).toBe("wss://prod.example.com"); + }); + + it("trims whitespace from URI", () => { + process.env.NEXT_PUBLIC_SPACETIME_URI = " ws://trimmed:3001 "; + expect(getSpacetimeUri()).toBe("ws://trimmed:3001"); + }); + + it("returns default DB name when env var is not set", () => { + expect(getSpacetimeDbName()).toBe("nexus"); + }); + + it("returns configured DB name from env var", () => { + process.env.NEXT_PUBLIC_SPACETIME_DB_NAME = "custom-db"; + expect(getSpacetimeDbName()).toBe("custom-db"); + }); + + it("isSpacetimeConfigured returns false when no URI is set", () => { + expect(isSpacetimeConfigured()).toBe(false); + }); + + it("isSpacetimeConfigured returns true when URI is set", () => { + process.env.NEXT_PUBLIC_SPACETIME_URI = "ws://localhost:3001"; + expect(isSpacetimeConfigured()).toBe(true); + }); + + it("isSpacetimeConfigured returns false for empty/whitespace URI", () => { + process.env.NEXT_PUBLIC_SPACETIME_URI = " "; + expect(isSpacetimeConfigured()).toBe(false); + }); +}); diff --git a/src/lib/__tests__/spacetime-types.test.ts b/src/lib/__tests__/spacetime-types.test.ts new file mode 100644 index 0000000..db53122 --- /dev/null +++ b/src/lib/__tests__/spacetime-types.test.ts @@ -0,0 +1,253 @@ +import { describe, expect, it } from "bun:test"; +import { + spacetimeNodeToWorkflowNode, + spacetimeEdgeToWorkflowEdge, + workflowNodeToOp, + workflowEdgeToOp, + spacetimeToWorkspaceRecord, + spacetimeToWorkflowRecord, + spacetimeToBrainDoc, + brainDocToContentJson, + spacetimeToChangeEvent, +} from "../spacetime/types"; +import type { + SpacetimeWorkflowNode, + SpacetimeWorkflowEdge, + SpacetimeWorkspace, + SpacetimeWorkflow, + SpacetimeBrainDoc, + SpacetimeWorkflowChangeEvent, +} from "../spacetime/types"; +import type { WorkflowNode, WorkflowEdge } from "@/types/workflow"; + +describe("SpacetimeDB type conversions", () => { + describe("spacetimeNodeToWorkflowNode", () => { + it("converts a SpacetimeDB node row to a WorkflowNode", () => { + const row: SpacetimeWorkflowNode = { + workflowId: "wf-1", + nodeId: "node-1", + type: "agent", + positionJson: '{"x":100,"y":200}', + dataJson: '{"type":"agent","name":"Test Agent"}', + updatedAt: "2026-01-01T00:00:00.000Z", + updatedBy: "user", + }; + + const result = spacetimeNodeToWorkflowNode(row); + + expect(result.id).toBe("node-1"); + expect(result.type).toBe("agent"); + expect(result.position).toEqual({ x: 100, y: 200 }); + expect(result.data).toHaveProperty("type", "agent"); + expect(result.data).toHaveProperty("name", "Test Agent"); + }); + }); + + describe("workflowNodeToOp", () => { + it("converts a WorkflowNode to an upsert operation", () => { + const node = { + id: "node-1", + type: "agent", + position: { x: 50, y: 75 }, + data: { type: "agent", name: "My Agent" }, + } as WorkflowNode; + + const op = workflowNodeToOp(node); + + expect(op.op).toBe("upsert_node"); + expect(op.nodeId).toBe("node-1"); + expect(op.type).toBe("agent"); + expect(JSON.parse(op.positionJson!)).toEqual({ x: 50, y: 75 }); + expect(JSON.parse(op.dataJson!)).toEqual({ type: "agent", name: "My Agent" }); + }); + }); + + describe("spacetimeEdgeToWorkflowEdge", () => { + it("converts a SpacetimeDB edge row to a WorkflowEdge", () => { + const row: SpacetimeWorkflowEdge = { + workflowId: "wf-1", + edgeId: "edge-1", + source: "node-1", + target: "node-2", + handlesJson: '{"sourceHandle":"right","targetHandle":"left"}', + dataJson: "{}", + updatedAt: "2026-01-01T00:00:00.000Z", + updatedBy: "user", + }; + + const result = spacetimeEdgeToWorkflowEdge(row); + + expect(result.id).toBe("edge-1"); + expect(result.source).toBe("node-1"); + expect(result.target).toBe("node-2"); + expect(result.sourceHandle).toBe("right"); + expect(result.targetHandle).toBe("left"); + }); + + it("handles null handles", () => { + const row: SpacetimeWorkflowEdge = { + workflowId: "wf-1", + edgeId: "edge-2", + source: "a", + target: "b", + handlesJson: "{}", + dataJson: "{}", + updatedAt: "2026-01-01T00:00:00.000Z", + updatedBy: "user", + }; + + const result = spacetimeEdgeToWorkflowEdge(row); + + expect(result.sourceHandle).toBeNull(); + expect(result.targetHandle).toBeNull(); + }); + }); + + describe("workflowEdgeToOp", () => { + it("converts a WorkflowEdge to an upsert operation", () => { + const edge = { + id: "edge-1", + source: "n1", + target: "n2", + sourceHandle: "out", + targetHandle: "in", + } as WorkflowEdge; + + const op = workflowEdgeToOp(edge); + + expect(op.op).toBe("upsert_edge"); + expect(op.edgeId).toBe("edge-1"); + expect(op.source).toBe("n1"); + expect(op.target).toBe("n2"); + expect(JSON.parse(op.handlesJson!)).toEqual({ + sourceHandle: "out", + targetHandle: "in", + }); + }); + }); + + describe("spacetimeToWorkspaceRecord", () => { + it("converts a SpacetimeDB workspace row to WorkspaceRecord", () => { + const row: SpacetimeWorkspace = { + id: "ws-1", + name: "My Workspace", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-02T00:00:00.000Z", + }; + + const result = spacetimeToWorkspaceRecord(row); + + expect(result).toEqual({ + id: "ws-1", + name: "My Workspace", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-02T00:00:00.000Z", + }); + }); + }); + + describe("spacetimeToWorkflowRecord", () => { + it("converts a SpacetimeDB workflow row to WorkflowRecord", () => { + const row: SpacetimeWorkflow = { + id: "wf-1", + workspaceId: "ws-1", + name: "My Workflow", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-02T00:00:00.000Z", + lastModifiedBy: "user", + }; + + const result = spacetimeToWorkflowRecord(row); + + expect(result).toEqual({ + id: "wf-1", + workspaceId: "ws-1", + name: "My Workflow", + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-02T00:00:00.000Z", + lastModifiedBy: "user", + }); + }); + }); + + describe("spacetimeToBrainDoc", () => { + it("converts a SpacetimeDB brain doc row to KnowledgeDoc", () => { + const row: SpacetimeBrainDoc = { + id: "doc-1", + workspaceId: "ws-1", + title: "My Note", + contentJson: JSON.stringify({ + summary: "A note", + content: "body text", + docType: "note", + tags: ["test"], + associatedWorkflowIds: [], + createdBy: "user", + status: "draft", + metrics: { views: 0, lastViewedAt: null, feedback: [] }, + }), + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-02T00:00:00.000Z", + deletedAt: null, + }; + + const result = spacetimeToBrainDoc(row); + + expect(result.id).toBe("doc-1"); + expect(result.title).toBe("My Note"); + expect(result.summary).toBe("A note"); + expect(result.docType).toBe("note"); + expect(result.createdAt).toBe("2026-01-01T00:00:00.000Z"); + expect(result.updatedAt).toBe("2026-01-02T00:00:00.000Z"); + }); + }); + + describe("brainDocToContentJson", () => { + it("strips id, title, createdAt, updatedAt from the doc", () => { + const doc = { + id: "doc-1", + title: "Title", + summary: "Sum", + content: "Body", + docType: "note" as const, + tags: [], + associatedWorkflowIds: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-02T00:00:00.000Z", + createdBy: "user", + status: "draft" as const, + metrics: { views: 0, lastViewedAt: null, feedback: [] }, + }; + + const json = brainDocToContentJson(doc); + const parsed = JSON.parse(json); + + expect(parsed.id).toBeUndefined(); + expect(parsed.title).toBeUndefined(); + expect(parsed.createdAt).toBeUndefined(); + expect(parsed.updatedAt).toBeUndefined(); + expect(parsed.summary).toBe("Sum"); + expect(parsed.content).toBe("Body"); + expect(parsed.docType).toBe("note"); + }); + }); + + describe("spacetimeToChangeEvent", () => { + it("converts a SpacetimeDB change event to ChangeEvent", () => { + const row: SpacetimeWorkflowChangeEvent = { + workflowId: "wf-1", + eventType: "node_added", + nodeId: "node-1", + details: JSON.stringify({ nodeName: "Agent", by: "user" }), + timestamp: "2026-01-01T00:00:00.000Z", + }; + + const result = spacetimeToChangeEvent(row); + + expect(result.type).toBe("node_added"); + expect(result.nodeName).toBe("Agent"); + expect(result.by).toBe("user"); + expect(result.at).toBe("2026-01-01T00:00:00.000Z"); + }); + }); +}); diff --git a/src/lib/brain/client.ts b/src/lib/brain/client.ts index df7a208..2b8ed9b 100644 --- a/src/lib/brain/client.ts +++ b/src/lib/brain/client.ts @@ -10,6 +10,7 @@ import type { KnowledgeDocVersion, KnowledgeFeedback, } from "@/types/knowledge"; +import { isSpacetimeConfigured } from "@/lib/spacetime/config"; const nanoid = customAlphabet("abcdefghijklmnopqrstuvwxyz0123456789", 12); const BRAIN_TOKEN_KEY = "nexus:brain-token"; @@ -77,7 +78,23 @@ function createMigrationPayload(): KnowledgeBrain | null { }; } +/** + * When SpacetimeDB is configured for workspace mode, the brain-sync bridge + * handles brain document operations directly via SpacetimeDB reducers. + * The REST-based session flow is only needed in non-SpacetimeDB mode. + */ export async function ensureBrainSession(): Promise { + // In SpacetimeDB mode, brain operations go through the sync bridge. + // Return a minimal session so existing callers don't break. + if (isSpacetimeConfigured()) { + const docs = getAllKnowledgeDocs(); + return { + workspaceId: "spacetimedb", + token: "spacetimedb-identity", + docs, + }; + } + const token = getStoredToken() ?? getUrlToken(); const legacyBrain = token ? null : createMigrationPayload(); diff --git a/src/lib/collaboration/collab-doc.ts b/src/lib/collaboration/collab-doc.ts index e019051..8831cb1 100644 --- a/src/lib/collaboration/collab-doc.ts +++ b/src/lib/collaboration/collab-doc.ts @@ -102,8 +102,17 @@ export class CollabDoc { start(roomId: string, initialState?: WorkflowJSON): void { if (typeof window === "undefined") return; + if (this._provider) { + if (this._roomId === roomId) return; + this.destroy(); + CollabDoc._instance = new CollabDoc(); + CollabDoc._instance.start(roomId, initialState); + return; + } + this._roomId = roomId; useCollabStore.getState()._setRoomId(roomId); + useCollabStore.getState()._setSyncBackend("yjs"); useCollabStore.getState()._setInitializing(true); // Set up self identity @@ -210,6 +219,7 @@ export class CollabDoc { useCollabStore.getState()._setConnected(false); useCollabStore.getState()._setInitializing(false); useCollabStore.getState()._setPeerCount(0); + useCollabStore.getState()._setSyncBackend(null); useAwarenessStore.getState()._setPeers([]); CollabDoc._instance = null; diff --git a/src/lib/collaboration/config.ts b/src/lib/collaboration/config.ts index aceca20..205fc8f 100644 --- a/src/lib/collaboration/config.ts +++ b/src/lib/collaboration/config.ts @@ -33,3 +33,14 @@ export function buildWorkspaceCollabShareUrl(workspaceId: string, workflowId: st if (typeof window === "undefined") return `/workspace/${workspaceId}/workflow/${workflowId}`; return `${window.location.origin}/workspace/${workspaceId}/workflow/${workflowId}`; } + +export function buildWorkspaceYjsShareUrl(workspaceId: string, workflowId: string): string { + const roomId = buildWorkspaceRoomId(workspaceId, workflowId); + const basePath = `/workspace/${workspaceId}/workflow/${workflowId}`; + + if (typeof window === "undefined") { + return `${basePath}?room=${roomId}`; + } + + return `${window.location.origin}${basePath}?room=${encodeURIComponent(roomId)}`; +} diff --git a/src/lib/collaboration/index.ts b/src/lib/collaboration/index.ts index 0ca0d15..3bdd75d 100644 --- a/src/lib/collaboration/index.ts +++ b/src/lib/collaboration/index.ts @@ -1,3 +1,10 @@ export { CollabDoc } from "./collab-doc"; export { getOrCreateUserName, saveUserName, generateAnimalName, getColorForClientId } from "./awareness-names"; -export { buildCollabRoomUrl, buildCollabShareUrl, buildWorkspaceRoomId, buildWorkspaceCollabShareUrl, getCollabServerUrl } from "./config"; +export { + buildCollabRoomUrl, + buildCollabShareUrl, + buildWorkspaceRoomId, + buildWorkspaceCollabShareUrl, + buildWorkspaceYjsShareUrl, + getCollabServerUrl, +} from "./config"; diff --git a/src/lib/spacetime/brain-sync.ts b/src/lib/spacetime/brain-sync.ts new file mode 100644 index 0000000..db8d209 --- /dev/null +++ b/src/lib/spacetime/brain-sync.ts @@ -0,0 +1,226 @@ +/** + * SpacetimeDB Brain Document Sync Bridge + * + * Subscribes to brain_doc, brain_doc_version, and brain_feedback rows for + * the current workspace and syncs changes into the Brain Zustand store. + * Replaces REST-based brain operations with SpacetimeDB reducer calls. + */ + +"use client"; + +import { useKnowledgeStore } from "@/store/knowledge"; +import { replaceAllKnowledgeDocs } from "@/lib/knowledge"; +import { getSpacetimeClient } from "./client"; +import type { KnowledgeDoc } from "@/types/knowledge"; +import type { SpacetimeBrainDoc } from "./types"; +import { spacetimeToBrainDoc, brainDocToContentJson } from "./types"; +import { customAlphabet } from "nanoid"; +import type { SubscriptionHandle } from "./module_bindings"; +import type { BrainDoc as BindingBrainDoc } from "./module_bindings/types"; + +const nanoid = customAlphabet("abcdefghijklmnopqrstuvwxyz0123456789", 12); + +// Module-level mutex — prevents feedback loops (mirrors collab-doc.ts) +let _isApplyingRemoteBrain = false; + +class SpacetimeBrainSync { + private _workspaceId: string | null = null; + private _active = false; + private _subscription: SubscriptionHandle | null = null; + private _tableUnsubs: Array<() => void> = []; + private _storeUnsub: (() => void) | null = null; + + // Cache of current brain docs from SpacetimeDB + private _remoteDocs = new Map(); + + // ── Public API ───────────────────────────────────────────────────────── + + isActive(): boolean { + return this._active; + } + + startBrainSync(workspaceId: string): void { + if (this._active) this.stopBrainSync(); + + this._workspaceId = workspaceId; + this._active = true; + + const client = getSpacetimeClient(); + + if (client.isConnected) { + this._setupSubscriptions(); + } else { + const unsub = client.onStateChange((state) => { + if (state === "connected") { + unsub(); + this._setupSubscriptions(); + } + }); + } + + // Watch knowledge store for local changes → SpacetimeDB + this._storeUnsub = useKnowledgeStore.subscribe((_state) => { + if (_isApplyingRemoteBrain) return; + // Local changes are pushed via explicit save/delete calls, + // not via the store subscriber (to avoid complexity with the + // refresh() call pattern in the knowledge store). + }); + } + + stopBrainSync(): void { + this._active = false; + + this._storeUnsub?.(); + this._storeUnsub = null; + + this._teardownSubscriptions(); + + this._remoteDocs.clear(); + this._workspaceId = null; + } + + // ── SpacetimeDB-backed operations (replace REST calls) ───────────────── + + saveBrainDoc(doc: Partial & { title: string }): void { + if (!this._workspaceId) return; + + const id = doc.id ?? nanoid(); + const contentJson = brainDocToContentJson(doc as KnowledgeDoc); + const versionId = doc.id ? nanoid() : undefined; // Create version only for updates + + void getSpacetimeClient() + .callReducer("save_brain_doc", [ + id, + this._workspaceId, + doc.title, + contentJson, + versionId, + ]) + .catch(() => {}); + } + + deleteBrainDoc(docId: string): void { + void getSpacetimeClient().callReducer("delete_brain_doc", [docId]).catch(() => {}); + } + + recordView(docId: string): void { + void getSpacetimeClient().callReducer("record_brain_view", [docId]).catch(() => {}); + } + + addFeedback(docId: string, type: string, comment: string): void { + void getSpacetimeClient() + .callReducer("add_brain_feedback", [ + docId, + type, + comment, + ]) + .catch(() => {}); + } + + restoreVersion(docId: string, versionId: string): void { + const snapshotVersionId = nanoid(); + void getSpacetimeClient() + .callReducer("restore_brain_doc_version", [ + docId, + versionId, + snapshotVersionId, + ]) + .catch(() => {}); + } + + // ── Private: Subscription Setup ──────────────────────────────────────── + + private _setupSubscriptions(): void { + const client = getSpacetimeClient(); + const connection = client.connection; + if (!connection) return; + + this._teardownSubscriptions(); + + const onDocInsert = (_ctx: unknown, row: BindingBrainDoc) => this._upsertRemoteDoc(row); + const onDocUpdate = (_ctx: unknown, _oldRow: BindingBrainDoc, row: BindingBrainDoc) => this._upsertRemoteDoc(row); + const onDocDelete = (_ctx: unknown, row: BindingBrainDoc) => this._deleteRemoteDoc(row); + + connection.db.brainDoc.onInsert(onDocInsert); + connection.db.brainDoc.onUpdate?.(onDocUpdate); + connection.db.brainDoc.onDelete(onDocDelete); + this._tableUnsubs.push( + () => connection.db.brainDoc.removeOnInsert(onDocInsert), + () => connection.db.brainDoc.removeOnUpdate?.(onDocUpdate), + () => connection.db.brainDoc.removeOnDelete(onDocDelete), + ); + + this._subscription = client.subscribe( + [ + `SELECT * FROM brain_doc WHERE workspace_id = '${this._workspaceId}'`, + `SELECT * FROM brain_doc_version WHERE doc_id IN (SELECT id FROM brain_doc WHERE workspace_id = '${this._workspaceId}')`, + `SELECT * FROM brain_feedback WHERE doc_id IN (SELECT id FROM brain_doc WHERE workspace_id = '${this._workspaceId}')`, + ], + () => this._syncFromCache(connection), + ); + } + + private _teardownSubscriptions(): void { + if (this._subscription && !this._subscription.isEnded()) { + this._subscription.unsubscribe(); + } + this._subscription = null; + + for (const unsub of this._tableUnsubs) { + unsub(); + } + this._tableUnsubs = []; + } + + private _syncFromCache(connection: NonNullable["connection"]>): void { + if (!this._active) return; + + this._remoteDocs.clear(); + for (const row of connection.db.brainDoc.iter()) { + if (row.workspaceId === this._workspaceId && !row.deletedAt) { + this._remoteDocs.set(row.id, spacetimeToBrainDoc(row as SpacetimeBrainDoc)); + } + } + this._applyRemoteBrainChange(); + } + + private _upsertRemoteDoc(row: BindingBrainDoc): void { + if (!this._active || row.workspaceId !== this._workspaceId) return; + + if (row.deletedAt) { + this._remoteDocs.delete(row.id); + } else { + this._remoteDocs.set(row.id, spacetimeToBrainDoc(row as SpacetimeBrainDoc)); + } + this._applyRemoteBrainChange(); + } + + private _deleteRemoteDoc(row: BindingBrainDoc): void { + if (!this._active || row.workspaceId !== this._workspaceId) return; + + this._remoteDocs.delete(row.id); + this._applyRemoteBrainChange(); + } + + private _applyRemoteBrainChange(): void { + _isApplyingRemoteBrain = true; + + try { + const docs = Array.from(this._remoteDocs.values()); + + // Write-through to localStorage + replaceAllKnowledgeDocs(docs); + + // Update Zustand state + useKnowledgeStore.setState({ docs }); + + queueMicrotask(() => { + _isApplyingRemoteBrain = false; + }); + } catch { + _isApplyingRemoteBrain = false; + } + } +} + +export const spacetimeBrainSync = new SpacetimeBrainSync(); diff --git a/src/lib/spacetime/client.ts b/src/lib/spacetime/client.ts new file mode 100644 index 0000000..1f4ca16 --- /dev/null +++ b/src/lib/spacetime/client.ts @@ -0,0 +1,268 @@ +/** + * SpacetimeDB Client Connection Manager + * + * Singleton wrapper around the SpacetimeDB DbConnection that handles: + * - Identity token persistence in localStorage + * - Automatic reconnection with exponential backoff + * - Connection lifecycle (connect/disconnect/isConnected) + * - Event callbacks for connection state changes + */ + +"use client"; + +import { getSpacetimeUri, getSpacetimeDbName } from "./config"; +import { DbConnection, type SubscriptionHandle } from "./module_bindings"; + +// ── Identity Token Persistence ───────────────────────────────────────────── + +const IDENTITY_TOKEN_KEY_PREFIX = "nexus:spacetime-identity-"; + +function getIdentityTokenKey(): string { + return `${IDENTITY_TOKEN_KEY_PREFIX}${getSpacetimeDbName()}`; +} + +function loadIdentityToken(): string | null { + if (typeof window === "undefined") return null; + return localStorage.getItem(getIdentityTokenKey()); +} + +function saveIdentityToken(token: string): void { + if (typeof window === "undefined") return; + localStorage.setItem(getIdentityTokenKey(), token); +} + +// ── Connection State ─────────────────────────────────────────────────────── + +export type SpacetimeConnectionState = "disconnected" | "connecting" | "connected"; + +type ConnectionStateListener = (state: SpacetimeConnectionState) => void; +type SubscriptionReadyListener = () => void; + +// ── SpacetimeClient Singleton ────────────────────────────────────────────── + +/** + * Manages a single SpacetimeDB connection for the browser session. + * + * Uses the generated SpacetimeDB v2 bindings for reducer calls and table + * subscriptions. Sync bridges own their table-specific listeners. + */ +class SpacetimeClient { + private static _instance: SpacetimeClient | null = null; + + private _state: SpacetimeConnectionState = "disconnected"; + private _connection: DbConnection | null = null; + private _identity: string | null = null; + private _stateListeners = new Set(); + private _subscriptionReadyListeners = new Set(); + private _reconnectTimer: ReturnType | null = null; + private _reconnectAttempts = 0; + private _maxReconnectAttempts = 10; + private _intentionalDisconnect = false; + + private constructor() {} + + static getInstance(): SpacetimeClient { + if (!SpacetimeClient._instance) { + SpacetimeClient._instance = new SpacetimeClient(); + } + return SpacetimeClient._instance; + } + + // ── Public API ───────────────────────────────────────────────────────── + + get state(): SpacetimeConnectionState { + return this._state; + } + + get identity(): string | null { + return this._identity; + } + + get isConnected(): boolean { + return this._state === "connected"; + } + + get connection(): DbConnection | null { + return this._connection; + } + + onStateChange(listener: ConnectionStateListener): () => void { + this._stateListeners.add(listener); + return () => this._stateListeners.delete(listener); + } + + onSubscriptionReady(listener: SubscriptionReadyListener): () => void { + this._subscriptionReadyListeners.add(listener); + return () => this._subscriptionReadyListeners.delete(listener); + } + + connect(): void { + if (this._state !== "disconnected") return; + + this._intentionalDisconnect = false; + this._setState("connecting"); + + const token = loadIdentityToken(); + + let builder = DbConnection.builder() + .withUri(getSpacetimeUri()) + .withDatabaseName(getSpacetimeDbName()) + .withCompression("gzip") + .onConnect((_connection, identity, authToken) => { + this._identity = identity.toHexString(); + saveIdentityToken(authToken); + this._reconnectAttempts = 0; + this._setState("connected"); + }) + .onDisconnect(() => { + this._connection = null; + this._setState("disconnected"); + + if (!this._intentionalDisconnect) { + this._scheduleReconnect(); + } + }) + .onConnectError(() => { + this._connection = null; + this._setState("disconnected"); + + if (!this._intentionalDisconnect) { + this._scheduleReconnect(); + } + }); + + if (token) { + builder = builder.withToken(token); + } + + this._connection = builder.build(); + } + + disconnect(): void { + this._intentionalDisconnect = true; + this._clearReconnectTimer(); + + if (this._connection) { + this._connection.disconnect(); + this._connection = null; + } + + this._setState("disconnected"); + } + + /** + * Send a reducer call to SpacetimeDB using the generated v2 reducer API. + */ + async callReducer(reducerName: string, args: unknown[]): Promise { + if (!this._connection || this._state !== "connected") { + throw new Error(`Cannot call reducer '${reducerName}': not connected to SpacetimeDB`); + } + + const reducers = this._connection.reducers; + + switch (reducerName) { + case "add_brain_feedback": + return reducers.addBrainFeedback({ docId: String(args[0]), type: String(args[1]), comment: String(args[2]) }); + case "apply_workflow_ops": + return reducers.applyWorkflowOps({ workflowId: String(args[0]), opsJson: String(args[1]), displayName: String(args[2]) }); + case "create_invite": + return reducers.createInvite({ workspaceId: String(args[0]), tokenHash: String(args[1]) }); + case "create_workflow": + return reducers.createWorkflow({ id: String(args[0]), workspaceId: String(args[1]), name: String(args[2]), displayName: String(args[3]) }); + case "create_workspace": + return reducers.createWorkspace({ id: String(args[0]), name: String(args[1]), displayName: String(args[2]) }); + case "delete_brain_doc": + return reducers.deleteBrainDoc({ docId: String(args[0]) }); + case "delete_workflow": + return reducers.deleteWorkflow({ workflowId: String(args[0]) }); + case "delete_workspace": + return reducers.deleteWorkspace({ workspaceId: String(args[0]) }); + case "import_brain_doc": + return reducers.importBrainDoc({ id: String(args[0]), workspaceId: String(args[1]), title: String(args[2]), contentJson: String(args[3]), createdAt: String(args[4]), updatedAt: String(args[5]) }); + case "import_workflow_snapshot": + return reducers.importWorkflowSnapshot({ workflowId: String(args[0]), workspaceId: String(args[1]), name: String(args[2]), nodesJson: String(args[3]), edgesJson: String(args[4]), uiStateJson: String(args[5]), createdAt: String(args[6]), updatedAt: String(args[7]), lastModifiedBy: String(args[8]) }); + case "import_workspace": + return reducers.importWorkspace({ id: String(args[0]), name: String(args[1]), createdAt: String(args[2]), updatedAt: String(args[3]), displayName: String(args[4]) }); + case "join_workspace": + return reducers.joinWorkspace({ tokenHash: String(args[0]), displayName: String(args[1]) }); + case "record_brain_view": + return reducers.recordBrainView({ docId: String(args[0]) }); + case "rename_workflow": + return reducers.renameWorkflow({ workflowId: String(args[0]), newName: String(args[1]) }); + case "rename_workspace": + return reducers.renameWorkspace({ workspaceId: String(args[0]), newName: String(args[1]) }); + case "restore_brain_doc_version": + return reducers.restoreBrainDocVersion({ docId: String(args[0]), versionId: String(args[1]), snapshotVersionId: String(args[2]) }); + case "save_brain_doc": + return reducers.saveBrainDoc({ id: String(args[0]), workspaceId: String(args[1]), title: String(args[2]), contentJson: String(args[3]), versionId: args[4] == null ? undefined : String(args[4]) }); + case "update_presence": + return reducers.updatePresence({ workspaceId: String(args[0]), workflowId: String(args[1]), displayName: String(args[2]), selectedNodeId: args[3] == null ? undefined : String(args[3]) }); + case "update_workflow_ui_state": + return reducers.updateWorkflowUiState({ workflowId: String(args[0]), uiStateJson: String(args[1]) }); + default: + throw new Error(`Unknown SpacetimeDB reducer '${reducerName}'`); + } + } + + /** + * Subscribe to SpacetimeDB table queries. + * Tables are specified as SQL-like query strings. + */ + subscribe(queries: string[], onApplied?: () => void): SubscriptionHandle { + if (!this._connection || this._state !== "connected") { + throw new Error("Cannot subscribe: not connected to SpacetimeDB"); + } + + return this._connection + .subscriptionBuilder() + .onApplied(() => { + onApplied?.(); + for (const listener of this._subscriptionReadyListeners) { + listener(); + } + }) + .onError((ctx) => { + console.error("SpacetimeDB subscription error", ctx.event); + }) + .subscribe(queries); + } + + // ── Private ──────────────────────────────────────────────────────────── + + private _setState(newState: SpacetimeConnectionState): void { + if (this._state === newState) return; + this._state = newState; + for (const listener of this._stateListeners) { + listener(newState); + } + } + + private _scheduleReconnect(): void { + if (this._reconnectAttempts >= this._maxReconnectAttempts) return; + + this._clearReconnectTimer(); + + // Exponential backoff: 1s, 2s, 4s, 8s, ... up to 30s + const delay = Math.min(1000 * Math.pow(2, this._reconnectAttempts), 30_000); + this._reconnectAttempts++; + + this._reconnectTimer = setTimeout(() => { + this._reconnectTimer = null; + this.connect(); + }, delay); + } + + private _clearReconnectTimer(): void { + if (this._reconnectTimer) { + clearTimeout(this._reconnectTimer); + this._reconnectTimer = null; + } + } +} + +export { SpacetimeClient }; + +/** Convenience accessor for the singleton. */ +export function getSpacetimeClient(): SpacetimeClient { + return SpacetimeClient.getInstance(); +} diff --git a/src/lib/spacetime/config.ts b/src/lib/spacetime/config.ts new file mode 100644 index 0000000..39ee255 --- /dev/null +++ b/src/lib/spacetime/config.ts @@ -0,0 +1,33 @@ +/** + * SpacetimeDB connection configuration. + * + * All values are derived from public environment variables so they are + * available in both server and client bundles. + */ + +const DEFAULT_SPACETIME_URI = "ws://localhost:3001"; +const DEFAULT_SPACETIME_DB_NAME = "nexus"; + +/** WebSocket URI for the SpacetimeDB instance. */ +export function getSpacetimeUri(): string { + const configured = process.env.NEXT_PUBLIC_SPACETIME_URI?.trim(); + if (configured) return configured; + + if (typeof window === "undefined") { + return DEFAULT_SPACETIME_URI; + } + + // Derive from current host when no env var is set + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; + return `${protocol}//${window.location.hostname}:3001`; +} + +/** SpacetimeDB database/module name. */ +export function getSpacetimeDbName(): string { + return process.env.NEXT_PUBLIC_SPACETIME_DB_NAME?.trim() || DEFAULT_SPACETIME_DB_NAME; +} + +/** Returns true when SpacetimeDB env vars are configured (non-default). */ +export function isSpacetimeConfigured(): boolean { + return Boolean(process.env.NEXT_PUBLIC_SPACETIME_URI?.trim()); +} diff --git a/src/lib/spacetime/module_bindings/add_brain_feedback_reducer.ts b/src/lib/spacetime/module_bindings/add_brain_feedback_reducer.ts new file mode 100644 index 0000000..a9e555d --- /dev/null +++ b/src/lib/spacetime/module_bindings/add_brain_feedback_reducer.ts @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + docId: __t.string(), + type: __t.string(), + comment: __t.string(), +}; diff --git a/src/lib/spacetime/module_bindings/apply_workflow_ops_reducer.ts b/src/lib/spacetime/module_bindings/apply_workflow_ops_reducer.ts new file mode 100644 index 0000000..de1ba2a --- /dev/null +++ b/src/lib/spacetime/module_bindings/apply_workflow_ops_reducer.ts @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + workflowId: __t.string(), + opsJson: __t.string(), + displayName: __t.string(), +}; diff --git a/src/lib/spacetime/module_bindings/brain_doc_table.ts b/src/lib/spacetime/module_bindings/brain_doc_table.ts new file mode 100644 index 0000000..ed096a9 --- /dev/null +++ b/src/lib/spacetime/module_bindings/brain_doc_table.ts @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + id: __t.string().primaryKey(), + workspaceId: __t.string().name("workspace_id"), + title: __t.string(), + contentJson: __t.string().name("content_json"), + createdAt: __t.string().name("created_at"), + updatedAt: __t.string().name("updated_at"), + deletedAt: __t.option(__t.string()).name("deleted_at"), +}); diff --git a/src/lib/spacetime/module_bindings/brain_doc_version_table.ts b/src/lib/spacetime/module_bindings/brain_doc_version_table.ts new file mode 100644 index 0000000..140bd7e --- /dev/null +++ b/src/lib/spacetime/module_bindings/brain_doc_version_table.ts @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + docId: __t.string().name("doc_id"), + versionId: __t.string().name("version_id"), + contentJson: __t.string().name("content_json"), + createdAt: __t.string().name("created_at"), +}); diff --git a/src/lib/spacetime/module_bindings/brain_feedback_table.ts b/src/lib/spacetime/module_bindings/brain_feedback_table.ts new file mode 100644 index 0000000..c45b35c --- /dev/null +++ b/src/lib/spacetime/module_bindings/brain_feedback_table.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + docId: __t.string().name("doc_id"), + identity: __t.identity(), + type: __t.string(), + comment: __t.string(), + createdAt: __t.string().name("created_at"), +}); diff --git a/src/lib/spacetime/module_bindings/create_invite_reducer.ts b/src/lib/spacetime/module_bindings/create_invite_reducer.ts new file mode 100644 index 0000000..351a8dd --- /dev/null +++ b/src/lib/spacetime/module_bindings/create_invite_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + workspaceId: __t.string(), + tokenHash: __t.string(), +}; diff --git a/src/lib/spacetime/module_bindings/create_workflow_reducer.ts b/src/lib/spacetime/module_bindings/create_workflow_reducer.ts new file mode 100644 index 0000000..d82ad8f --- /dev/null +++ b/src/lib/spacetime/module_bindings/create_workflow_reducer.ts @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + id: __t.string(), + workspaceId: __t.string(), + name: __t.string(), + displayName: __t.string(), +}; diff --git a/src/lib/spacetime/module_bindings/create_workspace_reducer.ts b/src/lib/spacetime/module_bindings/create_workspace_reducer.ts new file mode 100644 index 0000000..85e60f8 --- /dev/null +++ b/src/lib/spacetime/module_bindings/create_workspace_reducer.ts @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + id: __t.string(), + name: __t.string(), + displayName: __t.string(), +}; diff --git a/src/lib/spacetime/module_bindings/delete_brain_doc_reducer.ts b/src/lib/spacetime/module_bindings/delete_brain_doc_reducer.ts new file mode 100644 index 0000000..4f01c76 --- /dev/null +++ b/src/lib/spacetime/module_bindings/delete_brain_doc_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + docId: __t.string(), +}; diff --git a/src/lib/spacetime/module_bindings/delete_workflow_reducer.ts b/src/lib/spacetime/module_bindings/delete_workflow_reducer.ts new file mode 100644 index 0000000..53aef3d --- /dev/null +++ b/src/lib/spacetime/module_bindings/delete_workflow_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + workflowId: __t.string(), +}; diff --git a/src/lib/spacetime/module_bindings/delete_workspace_reducer.ts b/src/lib/spacetime/module_bindings/delete_workspace_reducer.ts new file mode 100644 index 0000000..9d7c63c --- /dev/null +++ b/src/lib/spacetime/module_bindings/delete_workspace_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + workspaceId: __t.string(), +}; diff --git a/src/lib/spacetime/module_bindings/import_brain_doc_reducer.ts b/src/lib/spacetime/module_bindings/import_brain_doc_reducer.ts new file mode 100644 index 0000000..b71127b --- /dev/null +++ b/src/lib/spacetime/module_bindings/import_brain_doc_reducer.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + id: __t.string(), + workspaceId: __t.string(), + title: __t.string(), + contentJson: __t.string(), + createdAt: __t.string(), + updatedAt: __t.string(), +}; diff --git a/src/lib/spacetime/module_bindings/import_workflow_snapshot_reducer.ts b/src/lib/spacetime/module_bindings/import_workflow_snapshot_reducer.ts new file mode 100644 index 0000000..cb04cec --- /dev/null +++ b/src/lib/spacetime/module_bindings/import_workflow_snapshot_reducer.ts @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + workflowId: __t.string(), + workspaceId: __t.string(), + name: __t.string(), + nodesJson: __t.string(), + edgesJson: __t.string(), + uiStateJson: __t.string(), + createdAt: __t.string(), + updatedAt: __t.string(), + lastModifiedBy: __t.string(), +}; diff --git a/src/lib/spacetime/module_bindings/import_workspace_reducer.ts b/src/lib/spacetime/module_bindings/import_workspace_reducer.ts new file mode 100644 index 0000000..ae983f1 --- /dev/null +++ b/src/lib/spacetime/module_bindings/import_workspace_reducer.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + id: __t.string(), + name: __t.string(), + createdAt: __t.string(), + updatedAt: __t.string(), + displayName: __t.string(), +}; diff --git a/src/lib/spacetime/module_bindings/index.ts b/src/lib/spacetime/module_bindings/index.ts new file mode 100644 index 0000000..fce31f5 --- /dev/null +++ b/src/lib/spacetime/module_bindings/index.ts @@ -0,0 +1,293 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +// This was generated using spacetimedb cli version 2.1.0 (commit 6981f48b4bc1a71c8dd9bdfe5a2c343f6370243d). + +/* eslint-disable */ +/* tslint:disable */ +import { + DbConnectionBuilder as __DbConnectionBuilder, + DbConnectionImpl as __DbConnectionImpl, + SubscriptionBuilderImpl as __SubscriptionBuilderImpl, + TypeBuilder as __TypeBuilder, + Uuid as __Uuid, + convertToAccessorMap as __convertToAccessorMap, + makeQueryBuilder as __makeQueryBuilder, + procedureSchema as __procedureSchema, + procedures as __procedures, + reducerSchema as __reducerSchema, + reducers as __reducers, + schema as __schema, + t as __t, + table as __table, + type AlgebraicTypeType as __AlgebraicTypeType, + type DbConnectionConfig as __DbConnectionConfig, + type ErrorContextInterface as __ErrorContextInterface, + type Event as __Event, + type EventContextInterface as __EventContextInterface, + type Infer as __Infer, + type QueryBuilder as __QueryBuilder, + type ReducerEventContextInterface as __ReducerEventContextInterface, + type RemoteModule as __RemoteModule, + type SubscriptionEventContextInterface as __SubscriptionEventContextInterface, + type SubscriptionHandleImpl as __SubscriptionHandleImpl, +} from "spacetimedb"; + +// Import all reducer arg schemas +import AddBrainFeedbackReducer from "./add_brain_feedback_reducer"; +import ApplyWorkflowOpsReducer from "./apply_workflow_ops_reducer"; +import CreateInviteReducer from "./create_invite_reducer"; +import CreateWorkflowReducer from "./create_workflow_reducer"; +import CreateWorkspaceReducer from "./create_workspace_reducer"; +import DeleteBrainDocReducer from "./delete_brain_doc_reducer"; +import DeleteWorkflowReducer from "./delete_workflow_reducer"; +import DeleteWorkspaceReducer from "./delete_workspace_reducer"; +import ImportBrainDocReducer from "./import_brain_doc_reducer"; +import ImportWorkflowSnapshotReducer from "./import_workflow_snapshot_reducer"; +import ImportWorkspaceReducer from "./import_workspace_reducer"; +import JoinWorkspaceReducer from "./join_workspace_reducer"; +import RecordBrainViewReducer from "./record_brain_view_reducer"; +import RenameWorkflowReducer from "./rename_workflow_reducer"; +import RenameWorkspaceReducer from "./rename_workspace_reducer"; +import RestoreBrainDocVersionReducer from "./restore_brain_doc_version_reducer"; +import SaveBrainDocReducer from "./save_brain_doc_reducer"; +import UpdatePresenceReducer from "./update_presence_reducer"; +import UpdateWorkflowUiStateReducer from "./update_workflow_ui_state_reducer"; + +// Import all procedure arg schemas + +// Import all table schema definitions +import BrainDocRow from "./brain_doc_table"; +import BrainDocVersionRow from "./brain_doc_version_table"; +import BrainFeedbackRow from "./brain_feedback_table"; +import PresenceRow from "./presence_table"; +import WorkflowRow from "./workflow_table"; +import WorkflowChangeEventRow from "./workflow_change_event_table"; +import WorkflowEdgeRow from "./workflow_edge_table"; +import WorkflowNodeRow from "./workflow_node_table"; +import WorkflowUiStateRow from "./workflow_ui_state_table"; +import WorkspaceRow from "./workspace_table"; +import WorkspaceInviteRow from "./workspace_invite_table"; +import WorkspaceMemberRow from "./workspace_member_table"; + +/** Type-only namespace exports for generated type groups. */ + +/** The schema information for all tables in this module. This is defined the same was as the tables would have been defined in the server. */ +const tablesSchema = __schema({ + brainDoc: __table({ + name: 'brain_doc', + indexes: [ + { accessor: 'id', name: 'brain_doc_id_idx_btree', algorithm: 'btree', columns: [ + 'id', + ] }, + { accessor: 'byWorkspaceId', name: 'brain_doc_workspace_id_idx_btree', algorithm: 'btree', columns: [ + 'workspaceId', + ] }, + ], + constraints: [ + { name: 'brain_doc_id_key', constraint: 'unique', columns: ['id'] }, + ], + }, BrainDocRow), + brainDocVersion: __table({ + name: 'brain_doc_version', + indexes: [ + { accessor: 'byDocId', name: 'brain_doc_version_doc_id_idx_btree', algorithm: 'btree', columns: [ + 'docId', + ] }, + ], + constraints: [ + ], + }, BrainDocVersionRow), + brainFeedback: __table({ + name: 'brain_feedback', + indexes: [ + { accessor: 'byDocId', name: 'brain_feedback_doc_id_idx_btree', algorithm: 'btree', columns: [ + 'docId', + ] }, + ], + constraints: [ + ], + }, BrainFeedbackRow), + presence: __table({ + name: 'presence', + indexes: [ + { accessor: 'byIdentity', name: 'presence_identity_idx_btree', algorithm: 'btree', columns: [ + 'identity', + ] }, + { accessor: 'byWorkspaceId', name: 'presence_workspace_id_idx_btree', algorithm: 'btree', columns: [ + 'workspaceId', + ] }, + ], + constraints: [ + ], + }, PresenceRow), + workflow: __table({ + name: 'workflow', + indexes: [ + { accessor: 'id', name: 'workflow_id_idx_btree', algorithm: 'btree', columns: [ + 'id', + ] }, + { accessor: 'byWorkspaceId', name: 'workflow_workspace_id_idx_btree', algorithm: 'btree', columns: [ + 'workspaceId', + ] }, + ], + constraints: [ + { name: 'workflow_id_key', constraint: 'unique', columns: ['id'] }, + ], + }, WorkflowRow), + workflowChangeEvent: __table({ + name: 'workflow_change_event', + indexes: [ + { accessor: 'byWorkflowId', name: 'workflow_change_event_workflow_id_idx_btree', algorithm: 'btree', columns: [ + 'workflowId', + ] }, + ], + constraints: [ + ], + }, WorkflowChangeEventRow), + workflowEdge: __table({ + name: 'workflow_edge', + indexes: [ + { accessor: 'byWorkflowId', name: 'workflow_edge_workflow_id_idx_btree', algorithm: 'btree', columns: [ + 'workflowId', + ] }, + ], + constraints: [ + ], + }, WorkflowEdgeRow), + workflowNode: __table({ + name: 'workflow_node', + indexes: [ + { accessor: 'byWorkflowId', name: 'workflow_node_workflow_id_idx_btree', algorithm: 'btree', columns: [ + 'workflowId', + ] }, + ], + constraints: [ + ], + }, WorkflowNodeRow), + workflowUiState: __table({ + name: 'workflow_ui_state', + indexes: [ + { accessor: 'workflowId', name: 'workflow_ui_state_workflow_id_idx_btree', algorithm: 'btree', columns: [ + 'workflowId', + ] }, + ], + constraints: [ + { name: 'workflow_ui_state_workflow_id_key', constraint: 'unique', columns: ['workflowId'] }, + ], + }, WorkflowUiStateRow), + workspace: __table({ + name: 'workspace', + indexes: [ + { accessor: 'id', name: 'workspace_id_idx_btree', algorithm: 'btree', columns: [ + 'id', + ] }, + ], + constraints: [ + { name: 'workspace_id_key', constraint: 'unique', columns: ['id'] }, + ], + }, WorkspaceRow), + workspaceInvite: __table({ + name: 'workspace_invite', + indexes: [ + { accessor: 'byTokenHash', name: 'workspace_invite_token_hash_idx_btree', algorithm: 'btree', columns: [ + 'tokenHash', + ] }, + { accessor: 'byWorkspaceId', name: 'workspace_invite_workspace_id_idx_btree', algorithm: 'btree', columns: [ + 'workspaceId', + ] }, + ], + constraints: [ + ], + }, WorkspaceInviteRow), + workspaceMember: __table({ + name: 'workspace_member', + indexes: [ + { accessor: 'byIdentity', name: 'workspace_member_identity_idx_btree', algorithm: 'btree', columns: [ + 'identity', + ] }, + { accessor: 'byWorkspaceId', name: 'workspace_member_workspace_id_idx_btree', algorithm: 'btree', columns: [ + 'workspaceId', + ] }, + ], + constraints: [ + ], + }, WorkspaceMemberRow), +}); + +/** The schema information for all reducers in this module. This is defined the same way as the reducers would have been defined in the server, except the body of the reducer is omitted in code generation. */ +const reducersSchema = __reducers( + __reducerSchema("add_brain_feedback", AddBrainFeedbackReducer), + __reducerSchema("apply_workflow_ops", ApplyWorkflowOpsReducer), + __reducerSchema("create_invite", CreateInviteReducer), + __reducerSchema("create_workflow", CreateWorkflowReducer), + __reducerSchema("create_workspace", CreateWorkspaceReducer), + __reducerSchema("delete_brain_doc", DeleteBrainDocReducer), + __reducerSchema("delete_workflow", DeleteWorkflowReducer), + __reducerSchema("delete_workspace", DeleteWorkspaceReducer), + __reducerSchema("import_brain_doc", ImportBrainDocReducer), + __reducerSchema("import_workflow_snapshot", ImportWorkflowSnapshotReducer), + __reducerSchema("import_workspace", ImportWorkspaceReducer), + __reducerSchema("join_workspace", JoinWorkspaceReducer), + __reducerSchema("record_brain_view", RecordBrainViewReducer), + __reducerSchema("rename_workflow", RenameWorkflowReducer), + __reducerSchema("rename_workspace", RenameWorkspaceReducer), + __reducerSchema("restore_brain_doc_version", RestoreBrainDocVersionReducer), + __reducerSchema("save_brain_doc", SaveBrainDocReducer), + __reducerSchema("update_presence", UpdatePresenceReducer), + __reducerSchema("update_workflow_ui_state", UpdateWorkflowUiStateReducer), +); + +/** The schema information for all procedures in this module. This is defined the same way as the procedures would have been defined in the server. */ +const proceduresSchema = __procedures( +); + +/** The remote SpacetimeDB module schema, both runtime and type information. */ +const REMOTE_MODULE = { + versionInfo: { + cliVersion: "2.1.0" as const, + }, + tables: tablesSchema.schemaType.tables, + reducers: reducersSchema.reducersType.reducers, + ...proceduresSchema, +} satisfies __RemoteModule< + typeof tablesSchema.schemaType, + typeof reducersSchema.reducersType, + typeof proceduresSchema +>; + +/** The tables available in this remote SpacetimeDB module. Each table reference doubles as a query builder. */ +export const tables: __QueryBuilder = __makeQueryBuilder(tablesSchema.schemaType); + +/** The reducers available in this remote SpacetimeDB module. */ +export const reducers = __convertToAccessorMap(reducersSchema.reducersType.reducers); + +/** The context type returned in callbacks for all possible events. */ +export type EventContext = __EventContextInterface; +/** The context type returned in callbacks for reducer events. */ +export type ReducerEventContext = __ReducerEventContextInterface; +/** The context type returned in callbacks for subscription events. */ +export type SubscriptionEventContext = __SubscriptionEventContextInterface; +/** The context type returned in callbacks for error events. */ +export type ErrorContext = __ErrorContextInterface; +/** The subscription handle type to manage active subscriptions created from a {@link SubscriptionBuilder}. */ +export type SubscriptionHandle = __SubscriptionHandleImpl; + +/** Builder class to configure a new subscription to the remote SpacetimeDB instance. */ +export class SubscriptionBuilder extends __SubscriptionBuilderImpl {} + +/** Builder class to configure a new database connection to the remote SpacetimeDB instance. */ +export class DbConnectionBuilder extends __DbConnectionBuilder {} + +/** The typed database connection to manage connections to the remote SpacetimeDB instance. This class has type information specific to the generated module. */ +export class DbConnection extends __DbConnectionImpl { + /** Creates a new {@link DbConnectionBuilder} to configure and connect to the remote SpacetimeDB instance. */ + static builder = (): DbConnectionBuilder => { + return new DbConnectionBuilder(REMOTE_MODULE, (config: __DbConnectionConfig) => new DbConnection(config)); + }; + + /** Creates a new {@link SubscriptionBuilder} to configure a subscription to the remote SpacetimeDB instance. */ + override subscriptionBuilder = (): SubscriptionBuilder => { + return new SubscriptionBuilder(this); + }; +} diff --git a/src/lib/spacetime/module_bindings/join_workspace_reducer.ts b/src/lib/spacetime/module_bindings/join_workspace_reducer.ts new file mode 100644 index 0000000..883dd78 --- /dev/null +++ b/src/lib/spacetime/module_bindings/join_workspace_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + tokenHash: __t.string(), + displayName: __t.string(), +}; diff --git a/src/lib/spacetime/module_bindings/presence_table.ts b/src/lib/spacetime/module_bindings/presence_table.ts new file mode 100644 index 0000000..a42e33c --- /dev/null +++ b/src/lib/spacetime/module_bindings/presence_table.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + workspaceId: __t.string().name("workspace_id"), + workflowId: __t.string().name("workflow_id"), + identity: __t.identity(), + displayName: __t.string().name("display_name"), + selectedNodeId: __t.option(__t.string()).name("selected_node_id"), + lastSeenAt: __t.string().name("last_seen_at"), +}); diff --git a/src/lib/spacetime/module_bindings/record_brain_view_reducer.ts b/src/lib/spacetime/module_bindings/record_brain_view_reducer.ts new file mode 100644 index 0000000..4f01c76 --- /dev/null +++ b/src/lib/spacetime/module_bindings/record_brain_view_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + docId: __t.string(), +}; diff --git a/src/lib/spacetime/module_bindings/rename_workflow_reducer.ts b/src/lib/spacetime/module_bindings/rename_workflow_reducer.ts new file mode 100644 index 0000000..8fb5333 --- /dev/null +++ b/src/lib/spacetime/module_bindings/rename_workflow_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + workflowId: __t.string(), + newName: __t.string(), +}; diff --git a/src/lib/spacetime/module_bindings/rename_workspace_reducer.ts b/src/lib/spacetime/module_bindings/rename_workspace_reducer.ts new file mode 100644 index 0000000..51b9b34 --- /dev/null +++ b/src/lib/spacetime/module_bindings/rename_workspace_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + workspaceId: __t.string(), + newName: __t.string(), +}; diff --git a/src/lib/spacetime/module_bindings/restore_brain_doc_version_reducer.ts b/src/lib/spacetime/module_bindings/restore_brain_doc_version_reducer.ts new file mode 100644 index 0000000..41e0666 --- /dev/null +++ b/src/lib/spacetime/module_bindings/restore_brain_doc_version_reducer.ts @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + docId: __t.string(), + versionId: __t.string(), + snapshotVersionId: __t.string(), +}; diff --git a/src/lib/spacetime/module_bindings/save_brain_doc_reducer.ts b/src/lib/spacetime/module_bindings/save_brain_doc_reducer.ts new file mode 100644 index 0000000..583f9f0 --- /dev/null +++ b/src/lib/spacetime/module_bindings/save_brain_doc_reducer.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + id: __t.string(), + workspaceId: __t.string(), + title: __t.string(), + contentJson: __t.string(), + versionId: __t.option(__t.string()), +}; diff --git a/src/lib/spacetime/module_bindings/types.ts b/src/lib/spacetime/module_bindings/types.ts new file mode 100644 index 0000000..e911c66 --- /dev/null +++ b/src/lib/spacetime/module_bindings/types.ts @@ -0,0 +1,122 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export const BrainDoc = __t.object("BrainDoc", { + id: __t.string(), + workspaceId: __t.string(), + title: __t.string(), + contentJson: __t.string(), + createdAt: __t.string(), + updatedAt: __t.string(), + deletedAt: __t.option(__t.string()), +}); +export type BrainDoc = __Infer; + +export const BrainDocVersion = __t.object("BrainDocVersion", { + docId: __t.string(), + versionId: __t.string(), + contentJson: __t.string(), + createdAt: __t.string(), +}); +export type BrainDocVersion = __Infer; + +export const BrainFeedback = __t.object("BrainFeedback", { + docId: __t.string(), + identity: __t.identity(), + type: __t.string(), + comment: __t.string(), + createdAt: __t.string(), +}); +export type BrainFeedback = __Infer; + +export const Presence = __t.object("Presence", { + workspaceId: __t.string(), + workflowId: __t.string(), + identity: __t.identity(), + displayName: __t.string(), + selectedNodeId: __t.option(__t.string()), + lastSeenAt: __t.string(), +}); +export type Presence = __Infer; + +export const Workflow = __t.object("Workflow", { + id: __t.string(), + workspaceId: __t.string(), + name: __t.string(), + createdAt: __t.string(), + updatedAt: __t.string(), + lastModifiedBy: __t.string(), +}); +export type Workflow = __Infer; + +export const WorkflowChangeEvent = __t.object("WorkflowChangeEvent", { + workflowId: __t.string(), + eventType: __t.string(), + nodeId: __t.option(__t.string()), + details: __t.string(), + timestamp: __t.string(), +}); +export type WorkflowChangeEvent = __Infer; + +export const WorkflowEdge = __t.object("WorkflowEdge", { + workflowId: __t.string(), + edgeId: __t.string(), + source: __t.string(), + target: __t.string(), + handlesJson: __t.string(), + dataJson: __t.string(), + updatedAt: __t.string(), + updatedBy: __t.string(), +}); +export type WorkflowEdge = __Infer; + +export const WorkflowNode = __t.object("WorkflowNode", { + workflowId: __t.string(), + nodeId: __t.string(), + type: __t.string(), + positionJson: __t.string(), + dataJson: __t.string(), + updatedAt: __t.string(), + updatedBy: __t.string(), +}); +export type WorkflowNode = __Infer; + +export const WorkflowUiState = __t.object("WorkflowUiState", { + workflowId: __t.string(), + uiStateJson: __t.string(), +}); +export type WorkflowUiState = __Infer; + +export const Workspace = __t.object("Workspace", { + id: __t.string(), + name: __t.string(), + createdAt: __t.string(), + updatedAt: __t.string(), +}); +export type Workspace = __Infer; + +export const WorkspaceInvite = __t.object("WorkspaceInvite", { + workspaceId: __t.string(), + tokenHash: __t.string(), + createdAt: __t.string(), + revokedAt: __t.option(__t.string()), +}); +export type WorkspaceInvite = __Infer; + +export const WorkspaceMember = __t.object("WorkspaceMember", { + workspaceId: __t.string(), + identity: __t.identity(), + displayName: __t.string(), + role: __t.string(), + joinedAt: __t.string(), +}); +export type WorkspaceMember = __Infer; diff --git a/src/lib/spacetime/module_bindings/types/procedures.ts b/src/lib/spacetime/module_bindings/types/procedures.ts new file mode 100644 index 0000000..4d38205 --- /dev/null +++ b/src/lib/spacetime/module_bindings/types/procedures.ts @@ -0,0 +1,8 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { type Infer as __Infer } from "spacetimedb"; + +// Import all procedure arg schemas diff --git a/src/lib/spacetime/module_bindings/types/reducers.ts b/src/lib/spacetime/module_bindings/types/reducers.ts new file mode 100644 index 0000000..ddda7d0 --- /dev/null +++ b/src/lib/spacetime/module_bindings/types/reducers.ts @@ -0,0 +1,47 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { type Infer as __Infer } from "spacetimedb"; + +// Import all reducer arg schemas +import AddBrainFeedbackReducer from "../add_brain_feedback_reducer"; +import ApplyWorkflowOpsReducer from "../apply_workflow_ops_reducer"; +import CreateInviteReducer from "../create_invite_reducer"; +import CreateWorkflowReducer from "../create_workflow_reducer"; +import CreateWorkspaceReducer from "../create_workspace_reducer"; +import DeleteBrainDocReducer from "../delete_brain_doc_reducer"; +import DeleteWorkflowReducer from "../delete_workflow_reducer"; +import DeleteWorkspaceReducer from "../delete_workspace_reducer"; +import ImportBrainDocReducer from "../import_brain_doc_reducer"; +import ImportWorkflowSnapshotReducer from "../import_workflow_snapshot_reducer"; +import ImportWorkspaceReducer from "../import_workspace_reducer"; +import JoinWorkspaceReducer from "../join_workspace_reducer"; +import RecordBrainViewReducer from "../record_brain_view_reducer"; +import RenameWorkflowReducer from "../rename_workflow_reducer"; +import RenameWorkspaceReducer from "../rename_workspace_reducer"; +import RestoreBrainDocVersionReducer from "../restore_brain_doc_version_reducer"; +import SaveBrainDocReducer from "../save_brain_doc_reducer"; +import UpdatePresenceReducer from "../update_presence_reducer"; +import UpdateWorkflowUiStateReducer from "../update_workflow_ui_state_reducer"; + +export type AddBrainFeedbackParams = __Infer; +export type ApplyWorkflowOpsParams = __Infer; +export type CreateInviteParams = __Infer; +export type CreateWorkflowParams = __Infer; +export type CreateWorkspaceParams = __Infer; +export type DeleteBrainDocParams = __Infer; +export type DeleteWorkflowParams = __Infer; +export type DeleteWorkspaceParams = __Infer; +export type ImportBrainDocParams = __Infer; +export type ImportWorkflowSnapshotParams = __Infer; +export type ImportWorkspaceParams = __Infer; +export type JoinWorkspaceParams = __Infer; +export type RecordBrainViewParams = __Infer; +export type RenameWorkflowParams = __Infer; +export type RenameWorkspaceParams = __Infer; +export type RestoreBrainDocVersionParams = __Infer; +export type SaveBrainDocParams = __Infer; +export type UpdatePresenceParams = __Infer; +export type UpdateWorkflowUiStateParams = __Infer; diff --git a/src/lib/spacetime/module_bindings/update_presence_reducer.ts b/src/lib/spacetime/module_bindings/update_presence_reducer.ts new file mode 100644 index 0000000..c677097 --- /dev/null +++ b/src/lib/spacetime/module_bindings/update_presence_reducer.ts @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + workspaceId: __t.string(), + workflowId: __t.string(), + displayName: __t.string(), + selectedNodeId: __t.option(__t.string()), +}; diff --git a/src/lib/spacetime/module_bindings/update_workflow_ui_state_reducer.ts b/src/lib/spacetime/module_bindings/update_workflow_ui_state_reducer.ts new file mode 100644 index 0000000..9cf773c --- /dev/null +++ b/src/lib/spacetime/module_bindings/update_workflow_ui_state_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default { + workflowId: __t.string(), + uiStateJson: __t.string(), +}; diff --git a/src/lib/spacetime/module_bindings/workflow_change_event_table.ts b/src/lib/spacetime/module_bindings/workflow_change_event_table.ts new file mode 100644 index 0000000..73dde46 --- /dev/null +++ b/src/lib/spacetime/module_bindings/workflow_change_event_table.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + workflowId: __t.string().name("workflow_id"), + eventType: __t.string().name("event_type"), + nodeId: __t.option(__t.string()).name("node_id"), + details: __t.string(), + timestamp: __t.string(), +}); diff --git a/src/lib/spacetime/module_bindings/workflow_edge_table.ts b/src/lib/spacetime/module_bindings/workflow_edge_table.ts new file mode 100644 index 0000000..549a333 --- /dev/null +++ b/src/lib/spacetime/module_bindings/workflow_edge_table.ts @@ -0,0 +1,22 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + workflowId: __t.string().name("workflow_id"), + edgeId: __t.string().name("edge_id"), + source: __t.string(), + target: __t.string(), + handlesJson: __t.string().name("handles_json"), + dataJson: __t.string().name("data_json"), + updatedAt: __t.string().name("updated_at"), + updatedBy: __t.string().name("updated_by"), +}); diff --git a/src/lib/spacetime/module_bindings/workflow_node_table.ts b/src/lib/spacetime/module_bindings/workflow_node_table.ts new file mode 100644 index 0000000..ccef862 --- /dev/null +++ b/src/lib/spacetime/module_bindings/workflow_node_table.ts @@ -0,0 +1,21 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + workflowId: __t.string().name("workflow_id"), + nodeId: __t.string().name("node_id"), + type: __t.string(), + positionJson: __t.string().name("position_json"), + dataJson: __t.string().name("data_json"), + updatedAt: __t.string().name("updated_at"), + updatedBy: __t.string().name("updated_by"), +}); diff --git a/src/lib/spacetime/module_bindings/workflow_table.ts b/src/lib/spacetime/module_bindings/workflow_table.ts new file mode 100644 index 0000000..ba4876a --- /dev/null +++ b/src/lib/spacetime/module_bindings/workflow_table.ts @@ -0,0 +1,20 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + id: __t.string().primaryKey(), + workspaceId: __t.string().name("workspace_id"), + name: __t.string(), + createdAt: __t.string().name("created_at"), + updatedAt: __t.string().name("updated_at"), + lastModifiedBy: __t.string().name("last_modified_by"), +}); diff --git a/src/lib/spacetime/module_bindings/workflow_ui_state_table.ts b/src/lib/spacetime/module_bindings/workflow_ui_state_table.ts new file mode 100644 index 0000000..094983b --- /dev/null +++ b/src/lib/spacetime/module_bindings/workflow_ui_state_table.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + workflowId: __t.string().primaryKey().name("workflow_id"), + uiStateJson: __t.string().name("ui_state_json"), +}); diff --git a/src/lib/spacetime/module_bindings/workspace_invite_table.ts b/src/lib/spacetime/module_bindings/workspace_invite_table.ts new file mode 100644 index 0000000..c800163 --- /dev/null +++ b/src/lib/spacetime/module_bindings/workspace_invite_table.ts @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + workspaceId: __t.string().name("workspace_id"), + tokenHash: __t.string().name("token_hash"), + createdAt: __t.string().name("created_at"), + revokedAt: __t.option(__t.string()).name("revoked_at"), +}); diff --git a/src/lib/spacetime/module_bindings/workspace_member_table.ts b/src/lib/spacetime/module_bindings/workspace_member_table.ts new file mode 100644 index 0000000..07594ac --- /dev/null +++ b/src/lib/spacetime/module_bindings/workspace_member_table.ts @@ -0,0 +1,19 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + workspaceId: __t.string().name("workspace_id"), + identity: __t.identity(), + displayName: __t.string().name("display_name"), + role: __t.string(), + joinedAt: __t.string().name("joined_at"), +}); diff --git a/src/lib/spacetime/module_bindings/workspace_table.ts b/src/lib/spacetime/module_bindings/workspace_table.ts new file mode 100644 index 0000000..9a24cd1 --- /dev/null +++ b/src/lib/spacetime/module_bindings/workspace_table.ts @@ -0,0 +1,18 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from "spacetimedb"; + +export default __t.row({ + id: __t.string().primaryKey(), + name: __t.string(), + createdAt: __t.string().name("created_at"), + updatedAt: __t.string().name("updated_at"), +}); diff --git a/src/lib/spacetime/presence.ts b/src/lib/spacetime/presence.ts new file mode 100644 index 0000000..e07d4b2 --- /dev/null +++ b/src/lib/spacetime/presence.ts @@ -0,0 +1,224 @@ +/** + * SpacetimeDB Presence Layer + * + * Manages user presence/awareness via SpacetimeDB presence rows. + * Replaces Y.js awareness for workspace mode. + * + * - Subscribes to presence rows for the current workspace + * - Throttles local selection updates (~500ms) + * - Maps SpacetimeDB identities to display names via workspace_member rows + * - Server-side cleanup on disconnect via __identity_disconnected__ + */ + +"use client"; + +import throttle from "lodash.throttle"; +import { useAwarenessStore } from "@/store/collaboration/awareness-store"; +import { useCollabStore } from "@/store/collaboration/collab-store"; +import { getSpacetimeClient } from "./client"; +import { getColorForClientId } from "@/lib/collaboration/awareness-names"; +import type { SpacetimePresence } from "./types"; +import type { SubscriptionHandle } from "./module_bindings"; +import type { Presence as BindingPresence } from "./module_bindings/types"; + +class SpacetimePresenceManager { + private _workspaceId: string | null = null; + private _workflowId: string | null = null; + private _displayName = "Anonymous"; + private _active = false; + private _subscription: SubscriptionHandle | null = null; + private _tableUnsubs: Array<() => void> = []; + + // Cache of remote presence rows + private _remotePresence = new Map(); + + // Throttled presence update + private _updatePresenceThrottled = throttle( + (selectedNodeId: string | null) => this._sendPresenceUpdate(selectedNodeId), + 500, + ); + + // ── Public API ───────────────────────────────────────────────────────── + + isActive(): boolean { + return this._active; + } + + startPresence( + workspaceId: string, + workflowId: string, + displayName?: string, + ): void { + if (this._active) this.stopPresence(); + + this._workspaceId = workspaceId; + this._workflowId = workflowId; + this._displayName = displayName ?? "Anonymous"; + this._active = true; + + const client = getSpacetimeClient(); + + if (client.isConnected) { + this._setupSubscriptions(); + } else { + const unsub = client.onStateChange((state) => { + if (state === "connected") { + unsub(); + this._setupSubscriptions(); + } + }); + } + } + + stopPresence(): void { + this._active = false; + + this._updatePresenceThrottled.cancel(); + + this._teardownSubscriptions(); + + this._remotePresence.clear(); + useAwarenessStore.getState()._setPeers([]); + useCollabStore.getState()._setPeerCount(0); + + this._workspaceId = null; + this._workflowId = null; + } + + /** Update the local user's selected node (throttled). */ + updateSelection(selectedNodeId: string | null): void { + if (!this._active) return; + this._updatePresenceThrottled(selectedNodeId); + } + + // ── Private: Subscription Setup ──────────────────────────────────────── + + private _setupSubscriptions(): void { + const client = getSpacetimeClient(); + const connection = client.connection; + if (!connection) return; + + this._teardownSubscriptions(); + + const onInsert = (_ctx: unknown, row: BindingPresence) => this._upsertPresence(row); + const onUpdate = (_ctx: unknown, _oldRow: BindingPresence, row: BindingPresence) => this._upsertPresence(row); + const onDelete = (_ctx: unknown, row: BindingPresence) => this._deletePresence(row); + + connection.db.presence.onInsert(onInsert); + connection.db.presence.onUpdate?.(onUpdate); + connection.db.presence.onDelete(onDelete); + this._tableUnsubs.push( + () => connection.db.presence.removeOnInsert(onInsert), + () => connection.db.presence.removeOnUpdate?.(onUpdate), + () => connection.db.presence.removeOnDelete(onDelete), + ); + + this._subscription = client.subscribe( + [`SELECT * FROM presence WHERE workspace_id = '${this._workspaceId}'`], + () => this._syncFromCache(connection), + ); + + // Send initial presence + this._sendPresenceUpdate(null); + } + + // ── Private: Send presence update ────────────────────────────────────── + + private _sendPresenceUpdate(selectedNodeId: string | null): void { + if (!this._active || !this._workspaceId || !this._workflowId) return; + + try { + void getSpacetimeClient() + .callReducer("update_presence", [ + this._workspaceId, + this._workflowId, + this._displayName, + selectedNodeId, + ]) + .catch(() => { + // Ignore if not connected + }); + } catch { + // Ignore if not connected + } + } + + private _teardownSubscriptions(): void { + if (this._subscription && !this._subscription.isEnded()) { + this._subscription.unsubscribe(); + } + this._subscription = null; + + for (const unsub of this._tableUnsubs) { + unsub(); + } + this._tableUnsubs = []; + } + + private _syncFromCache(connection: NonNullable["connection"]>): void { + if (!this._active) return; + + this._remotePresence.clear(); + for (const row of connection.db.presence.iter()) { + this._upsertPresence(row); + } + this._updatePeerStore(); + } + + private _upsertPresence(row: BindingPresence): void { + if (!this._active || row.workspaceId !== this._workspaceId) return; + + const identity = row.identity.toHexString(); + if (identity === getSpacetimeClient().identity) return; + + this._remotePresence.set(identity, { + workspaceId: row.workspaceId, + workflowId: row.workflowId, + identity, + displayName: row.displayName, + selectedNodeId: row.selectedNodeId ?? null, + lastSeenAt: row.lastSeenAt, + }); + this._updatePeerStore(); + } + + private _deletePresence(row: BindingPresence): void { + const identity = row.identity.toHexString(); + this._remotePresence.delete(identity); + this._updatePeerStore(); + } + + private _updatePeerStore(): void { + const peers = Array.from(this._remotePresence.values()) + .filter((p) => p.workflowId === this._workflowId) + .map((p) => { + // Use a stable hash of the identity string for color + const colorSeed = hashCode(p.identity); + const colors = getColorForClientId(colorSeed); + + return { + clientId: colorSeed, + user: { + name: p.displayName, + color: colors.color, + colorLight: colors.colorLight, + }, + selectedNodeId: p.selectedNodeId ?? null, + }; + }); + + useAwarenessStore.getState()._setPeers(peers); + useCollabStore.getState()._setPeerCount(peers.length); + } +} + +/** Simple string hash for stable color generation. */ +function hashCode(s: string): number { + let hash = 0; + for (let i = 0; i < s.length; i++) { + hash = ((hash << 5) - hash + s.charCodeAt(i)) | 0; + } + return Math.abs(hash); +} + +export const spacetimePresence = new SpacetimePresenceManager(); diff --git a/src/lib/spacetime/types.ts b/src/lib/spacetime/types.ts new file mode 100644 index 0000000..87cc5d3 --- /dev/null +++ b/src/lib/spacetime/types.ts @@ -0,0 +1,241 @@ +/** + * TypeScript types for SpacetimeDB row shapes and reducer payloads. + * + * These mirror the SpacetimeDB table schemas defined in spacetime/nexus/src/lib.ts + * and provide the type bridge between SpacetimeDB rows and the existing Zustand + * store types (WorkflowNode, WorkflowEdge, WorkspaceRecord, WorkflowRecord, etc.). + */ + +import type { WorkflowNode, WorkflowEdge } from "@/types/workflow"; +import type { WorkspaceRecord, WorkflowRecord, ChangeEventType, ChangeEvent } from "@/lib/workspace/types"; +import type { KnowledgeDoc } from "@/types/knowledge"; + +// ── SpacetimeDB Row Types ────────────────────────────────────────────────── + +export interface SpacetimeWorkspace { + id: string; + name: string; + createdAt: string; + updatedAt: string; +} + +export interface SpacetimeWorkspaceMember { + workspaceId: string; + identity: string; // hex-encoded identity + displayName: string; + role: "owner" | "editor" | "viewer"; + joinedAt: string; +} + +export interface SpacetimeWorkflow { + id: string; + workspaceId: string; + name: string; + createdAt: string; + updatedAt: string; + lastModifiedBy: string; +} + +export interface SpacetimeWorkflowNode { + workflowId: string; + nodeId: string; + type: string; + positionJson: string; + dataJson: string; + updatedAt: string; + updatedBy: string; +} + +export interface SpacetimeWorkflowEdge { + workflowId: string; + edgeId: string; + source: string; + target: string; + handlesJson: string; + dataJson: string; + updatedAt: string; + updatedBy: string; +} + +export interface SpacetimeWorkflowUiState { + workflowId: string; + uiStateJson: string; +} + +export interface SpacetimeBrainDoc { + id: string; + workspaceId: string; + title: string; + contentJson: string; + createdAt: string; + updatedAt: string; + deletedAt: string | null; +} + +export interface SpacetimeBrainDocVersion { + docId: string; + versionId: string; + contentJson: string; + createdAt: string; +} + +export interface SpacetimeBrainFeedback { + docId: string; + identity: string; + type: string; + comment: string; + createdAt: string; +} + +export interface SpacetimeWorkflowChangeEvent { + workflowId: string; + eventType: string; + nodeId: string | null; + details: string; + timestamp: string; +} + +export interface SpacetimePresence { + workspaceId: string; + workflowId: string; + identity: string; + displayName: string; + selectedNodeId: string | null; + lastSeenAt: string; +} + +// ── Batch Operation Types ────────────────────────────────────────────────── + +export type WorkflowOpType = + | "upsert_node" + | "delete_node" + | "upsert_edge" + | "delete_edge"; + +export interface WorkflowOp { + op: WorkflowOpType; + nodeId?: string; + type?: string; + positionJson?: string; + dataJson?: string; + edgeId?: string; + source?: string; + target?: string; + handlesJson?: string; +} + +// ── Type Conversion Utilities ────────────────────────────────────────────── + +/** Convert a SpacetimeDB workflow node row to a React Flow WorkflowNode. */ +export function spacetimeNodeToWorkflowNode(row: SpacetimeWorkflowNode): WorkflowNode { + const position = JSON.parse(row.positionJson) as { x: number; y: number }; + const data = JSON.parse(row.dataJson); + return { + id: row.nodeId, + type: row.type, + position, + data, + } as WorkflowNode; +} + +/** Convert a React Flow WorkflowNode to SpacetimeDB upsert operation. */ +export function workflowNodeToOp(node: WorkflowNode): WorkflowOp { + return { + op: "upsert_node", + nodeId: node.id, + type: node.type ?? "default", + positionJson: JSON.stringify(node.position), + dataJson: JSON.stringify(node.data), + }; +} + +/** Convert a SpacetimeDB workflow edge row to a React Flow WorkflowEdge. */ +export function spacetimeEdgeToWorkflowEdge(row: SpacetimeWorkflowEdge): WorkflowEdge { + const handles = JSON.parse(row.handlesJson) as { + sourceHandle?: string | null; + targetHandle?: string | null; + }; + const data = row.dataJson !== "{}" ? JSON.parse(row.dataJson) : undefined; + return { + id: row.edgeId, + source: row.source, + target: row.target, + sourceHandle: handles.sourceHandle ?? null, + targetHandle: handles.targetHandle ?? null, + ...(data ? { data } : {}), + } as WorkflowEdge; +} + +/** Convert a React Flow WorkflowEdge to SpacetimeDB upsert operation. */ +export function workflowEdgeToOp(edge: WorkflowEdge): WorkflowOp { + return { + op: "upsert_edge", + edgeId: edge.id, + source: edge.source, + target: edge.target, + handlesJson: JSON.stringify({ + sourceHandle: edge.sourceHandle ?? null, + targetHandle: edge.targetHandle ?? null, + }), + dataJson: edge.data ? JSON.stringify(edge.data) : "{}", + }; +} + +/** Convert SpacetimeDB workspace row to WorkspaceRecord. */ +export function spacetimeToWorkspaceRecord(row: SpacetimeWorkspace): WorkspaceRecord { + return { + id: row.id, + name: row.name, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +/** Convert SpacetimeDB workflow row to WorkflowRecord. */ +export function spacetimeToWorkflowRecord(row: SpacetimeWorkflow): WorkflowRecord { + return { + id: row.id, + workspaceId: row.workspaceId, + name: row.name, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + lastModifiedBy: row.lastModifiedBy, + }; +} + +/** Convert SpacetimeDB brain doc row to KnowledgeDoc. */ +export function spacetimeToBrainDoc(row: SpacetimeBrainDoc): KnowledgeDoc { + const content = JSON.parse(row.contentJson); + return { + id: row.id, + title: row.title, + ...content, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + } as KnowledgeDoc; +} + +/** Convert KnowledgeDoc to SpacetimeDB brain doc content JSON. */ +export function brainDocToContentJson(doc: Partial): string { + const { id: _id, title: _title, createdAt: _ca, updatedAt: _ua, ...content } = doc as KnowledgeDoc; + return JSON.stringify(content); +} + +/** Convert SpacetimeDB change event row to ChangeEvent. */ +export function spacetimeToChangeEvent(row: SpacetimeWorkflowChangeEvent): ChangeEvent { + const details = JSON.parse(row.details) as { + nodeName?: string; + from?: string; + to?: string; + by?: string; + edgeId?: string; + }; + return { + type: row.eventType as ChangeEventType, + nodeName: details.nodeName ?? details.edgeId ?? "", + from: details.from, + to: details.to, + by: details.by ?? "unknown", + at: row.timestamp, + }; +} diff --git a/src/lib/spacetime/workspace-sync.ts b/src/lib/spacetime/workspace-sync.ts new file mode 100644 index 0000000..d38078e --- /dev/null +++ b/src/lib/spacetime/workspace-sync.ts @@ -0,0 +1,451 @@ +/** + * SpacetimeDB Workspace Sync Bridge + * + * Bidirectional sync between SpacetimeDB table subscriptions and the Zustand + * workflow store. Mirrors the _isApplyingRemote loop-prevention pattern from + * collab-doc.ts. + * + * Flow: + * Remote row change → set _isApplyingRemote → update Zustand → clear flag + * Zustand change → check flag → skip if remote → else call reducer + */ + +"use client"; + +import throttle from "lodash.throttle"; +import { useWorkflowStore } from "@/store/workflow"; +import { useCollabStore } from "@/store/collaboration/collab-store"; +import { getSpacetimeClient } from "./client"; +import { WorkflowNodeType } from "@/types/workflow"; +import type { WorkflowNode, WorkflowEdge } from "@/types/workflow"; +import type { + SpacetimeWorkflowNode, + SpacetimeWorkflowEdge, + WorkflowOp, +} from "./types"; +import type { SubscriptionHandle } from "./module_bindings"; +import type { + Workflow as BindingWorkflow, + WorkflowNode as BindingWorkflowNode, + WorkflowEdge as BindingWorkflowEdge, +} from "./module_bindings/types"; +import { + spacetimeNodeToWorkflowNode, + spacetimeEdgeToWorkflowEdge, + workflowNodeToOp, + workflowEdgeToOp, +} from "./types"; + +// ── Transient property stripper (mirrors collab-doc.ts) ──────────────────── + +function cleanNodeForSync(node: WorkflowNode): WorkflowNode { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { measured, selected, dragging, deletable, ...rest } = node; + + if (rest.data?.type === WorkflowNodeType.SubWorkflow && rest.data.subNodes) { + return { + ...rest, + data: { + ...rest.data, + subNodes: (rest.data.subNodes as WorkflowNode[]).map(cleanNodeForSync), + subEdges: (rest.data.subEdges as WorkflowEdge[]).map(cleanEdgeForSync), + }, + } as WorkflowNode; + } + + return rest as WorkflowNode; +} + +function cleanEdgeForSync(edge: WorkflowEdge): WorkflowEdge { + const { type: _type, style: _style, animated: _animated, selected: _selected, ...rest } = edge; + return rest as WorkflowEdge; +} + +// ── Module-level mutex (mirrors collab-doc.ts pattern) ───────────────────── + +let _isApplyingRemote = false; + +// ── Workspace Sync Bridge ────────────────────────────────────────────────── + +class SpacetimeWorkspaceSync { + private _workspaceId: string | null = null; + private _workflowId: string | null = null; + private _storeUnsub: (() => void) | null = null; + private _connectionUnsub: (() => void) | null = null; + private _subscription: SubscriptionHandle | null = null; + private _tableUnsubs: Array<() => void> = []; + private _displayName = "Anonymous"; + private _active = false; + + // Reference cache — avoids sending reducer calls when nothing changed + private _lastSyncedNodes: WorkflowNode[] = []; + private _lastSyncedEdges: WorkflowEdge[] = []; + private _lastSyncedName = ""; + + // Batch queue for coalescing rapid changes + private _pendingOps: WorkflowOp[] = []; + private _flushThrottled = throttle(() => this._flushOps(), 200); + + // ── Public API ───────────────────────────────────────────────────────── + + isActive(): boolean { + return this._active; + } + + startSync(workspaceId: string, workflowId: string, displayName?: string): void { + if (this._active) this.stopSync(); + + this._workspaceId = workspaceId; + this._workflowId = workflowId; + this._displayName = displayName ?? "Anonymous"; + this._active = true; + + const client = getSpacetimeClient(); + + this._connectionUnsub = client.onStateChange((state) => { + if (useCollabStore.getState().syncBackend === "yjs") return; + useCollabStore.getState()._setSyncBackend("spacetimedb"); + useCollabStore.getState()._setConnected(state === "connected"); + useCollabStore.getState()._setInitializing(state === "connecting"); + }); + + // Connect if not already + if (!client.isConnected) { + if (useCollabStore.getState().syncBackend !== "yjs") { + useCollabStore.getState()._setSyncBackend("spacetimedb"); + useCollabStore.getState()._setInitializing(true); + } + client.connect(); + } + + // Wait for connection, then subscribe + if (client.isConnected) { + this._setupSubscriptions(); + } else { + const unsub = client.onStateChange((state) => { + if (state === "connected") { + unsub(); + this._setupSubscriptions(); + } + }); + } + + // Subscribe to Zustand store changes → SpacetimeDB + this._storeUnsub = useWorkflowStore.subscribe((state) => { + if (_isApplyingRemote) return; + if (!this._active) return; + + const nodesChanged = state.nodes !== this._lastSyncedNodes; + const edgesChanged = state.edges !== this._lastSyncedEdges; + const nameChanged = state.name !== this._lastSyncedName; + + if (!nodesChanged && !edgesChanged && !nameChanged) return; + + // Diff and queue operations + if (nodesChanged) { + this._diffNodes(this._lastSyncedNodes, state.nodes); + } + if (edgesChanged) { + this._diffEdges(this._lastSyncedEdges, state.edges); + } + if (nameChanged && this._workflowId) { + try { + void getSpacetimeClient() + .callReducer("rename_workflow", [ + this._workflowId, + state.name, + ]) + .catch(() => { + // Ignore if not connected + }); + } catch { + // Ignore if not connected + } + } + + this._lastSyncedNodes = state.nodes; + this._lastSyncedEdges = state.edges; + this._lastSyncedName = state.name; + + this._flushThrottled(); + }); + } + + stopSync(): void { + this._active = false; + + this._storeUnsub?.(); + this._storeUnsub = null; + + this._connectionUnsub?.(); + this._connectionUnsub = null; + + this._teardownSubscriptions(); + + this._flushThrottled.cancel(); + this._pendingOps = []; + + this._lastSyncedNodes = []; + this._lastSyncedEdges = []; + this._lastSyncedName = ""; + + this._workspaceId = null; + this._workflowId = null; + + if (useCollabStore.getState().syncBackend !== "yjs") { + useCollabStore.getState()._setConnected(false); + useCollabStore.getState()._setInitializing(false); + useCollabStore.getState()._setSyncBackend(null); + } + } + + // ── Private: Subscription Setup ──────────────────────────────────────── + + private _setupSubscriptions(): void { + const client = getSpacetimeClient(); + const connection = client.connection; + if (!connection) return; + + this._teardownSubscriptions(); + this._registerTableListeners(connection); + + // Subscribe to relevant tables + this._subscription = client.subscribe( + [ + `SELECT * FROM workflow WHERE workspace_id = '${this._workspaceId}'`, + `SELECT * FROM workflow_node WHERE workflow_id = '${this._workflowId}'`, + `SELECT * FROM workflow_edge WHERE workflow_id = '${this._workflowId}'`, + `SELECT * FROM workflow_ui_state WHERE workflow_id = '${this._workflowId}'`, + `SELECT * FROM workflow_change_event WHERE workflow_id = '${this._workflowId}'`, + `SELECT * FROM workspace_member WHERE workspace_id = '${this._workspaceId}'`, + ], + () => this._syncFromCache(connection), + ); + + if (useCollabStore.getState().syncBackend !== "yjs") { + useCollabStore.getState()._setSyncBackend("spacetimedb"); + useCollabStore.getState()._setConnected(true); + useCollabStore.getState()._setInitializing(false); + } + + // Initialize reference cache from current store state + const state = useWorkflowStore.getState(); + this._lastSyncedNodes = state.nodes; + this._lastSyncedEdges = state.edges; + this._lastSyncedName = state.name; + } + + private _teardownSubscriptions(): void { + if (this._subscription && !this._subscription.isEnded()) { + this._subscription.unsubscribe(); + } + this._subscription = null; + + for (const unsub of this._tableUnsubs) { + unsub(); + } + this._tableUnsubs = []; + } + + private _registerTableListeners(connection: NonNullable["connection"]>): void { + const onNodeInsert = (_ctx: unknown, row: BindingWorkflowNode) => this._upsertRemoteNode(row); + const onNodeUpdate = (_ctx: unknown, _oldRow: BindingWorkflowNode, row: BindingWorkflowNode) => this._upsertRemoteNode(row); + const onNodeDelete = (_ctx: unknown, row: BindingWorkflowNode) => this._deleteRemoteNode(row); + const onEdgeInsert = (_ctx: unknown, row: BindingWorkflowEdge) => this._upsertRemoteEdge(row); + const onEdgeUpdate = (_ctx: unknown, _oldRow: BindingWorkflowEdge, row: BindingWorkflowEdge) => this._upsertRemoteEdge(row); + const onEdgeDelete = (_ctx: unknown, row: BindingWorkflowEdge) => this._deleteRemoteEdge(row); + const onWorkflowInsert = (_ctx: unknown, row: BindingWorkflow) => this._upsertRemoteWorkflow(row); + const onWorkflowUpdate = (_ctx: unknown, _oldRow: BindingWorkflow, row: BindingWorkflow) => this._upsertRemoteWorkflow(row); + + connection.db.workflowNode.onInsert(onNodeInsert); + connection.db.workflowNode.onUpdate?.(onNodeUpdate); + connection.db.workflowNode.onDelete(onNodeDelete); + connection.db.workflowEdge.onInsert(onEdgeInsert); + connection.db.workflowEdge.onUpdate?.(onEdgeUpdate); + connection.db.workflowEdge.onDelete(onEdgeDelete); + connection.db.workflow.onInsert(onWorkflowInsert); + connection.db.workflow.onUpdate?.(onWorkflowUpdate); + + this._tableUnsubs.push( + () => connection.db.workflowNode.removeOnInsert(onNodeInsert), + () => connection.db.workflowNode.removeOnUpdate?.(onNodeUpdate), + () => connection.db.workflowNode.removeOnDelete(onNodeDelete), + () => connection.db.workflowEdge.removeOnInsert(onEdgeInsert), + () => connection.db.workflowEdge.removeOnUpdate?.(onEdgeUpdate), + () => connection.db.workflowEdge.removeOnDelete(onEdgeDelete), + () => connection.db.workflow.removeOnInsert(onWorkflowInsert), + () => connection.db.workflow.removeOnUpdate?.(onWorkflowUpdate), + ); + } + + private _syncFromCache(connection: NonNullable["connection"]>): void { + if (!this._active) return; + + const nodes = Array.from(connection.db.workflowNode.iter()) + .filter((row) => row.workflowId === this._workflowId) + .map((row) => spacetimeNodeToWorkflowNode(row as SpacetimeWorkflowNode)); + const edges = Array.from(connection.db.workflowEdge.iter()) + .filter((row) => row.workflowId === this._workflowId) + .map((row) => spacetimeEdgeToWorkflowEdge(row as SpacetimeWorkflowEdge)); + const workflow = Array.from(connection.db.workflow.iter()).find((row) => row.id === this._workflowId); + + this._applyRemoteGraphChange(nodes, edges); + if (workflow && workflow.name !== this._lastSyncedName) { + this._applyRemoteNameChange(workflow.name); + } + } + + private _upsertRemoteNode(row: BindingWorkflowNode): void { + if (!this._active || row.workflowId !== this._workflowId) return; + + const currentNodes = new Map(this._lastSyncedNodes.map((node) => [node.id, node])); + currentNodes.set(row.nodeId, spacetimeNodeToWorkflowNode(row as SpacetimeWorkflowNode)); + this._applyRemoteGraphChange(Array.from(currentNodes.values()), null); + } + + private _deleteRemoteNode(row: BindingWorkflowNode): void { + if (!this._active || row.workflowId !== this._workflowId) return; + + const currentNodes = new Map(this._lastSyncedNodes.map((node) => [node.id, node])); + currentNodes.delete(row.nodeId); + this._applyRemoteGraphChange(Array.from(currentNodes.values()), null); + } + + private _upsertRemoteEdge(row: BindingWorkflowEdge): void { + if (!this._active || row.workflowId !== this._workflowId) return; + + const currentEdges = new Map(this._lastSyncedEdges.map((edge) => [edge.id, edge])); + currentEdges.set(row.edgeId, spacetimeEdgeToWorkflowEdge(row as SpacetimeWorkflowEdge)); + this._applyRemoteGraphChange(null, Array.from(currentEdges.values())); + } + + private _deleteRemoteEdge(row: BindingWorkflowEdge): void { + if (!this._active || row.workflowId !== this._workflowId) return; + + const currentEdges = new Map(this._lastSyncedEdges.map((edge) => [edge.id, edge])); + currentEdges.delete(row.edgeId); + this._applyRemoteGraphChange(null, Array.from(currentEdges.values())); + } + + private _upsertRemoteWorkflow(row: BindingWorkflow): void { + if (!this._active || row.id !== this._workflowId || row.name === this._lastSyncedName) return; + this._applyRemoteNameChange(row.name); + } + + // ── Private: Apply remote changes to Zustand (with loop prevention) ──── + + private _applyRemoteGraphChange( + nodes: WorkflowNode[] | null, + edges: WorkflowEdge[] | null, + ): void { + _isApplyingRemote = true; + + try { + const temporal = useWorkflowStore.temporal.getState(); + temporal.pause(); + + const patch: Partial<{ nodes: WorkflowNode[]; edges: WorkflowEdge[] }> = {}; + + if (nodes) { + patch.nodes = nodes; + this._lastSyncedNodes = nodes; + } + if (edges) { + patch.edges = edges; + this._lastSyncedEdges = edges; + } + + useWorkflowStore.setState(patch); + + queueMicrotask(() => { + temporal.resume(); + _isApplyingRemote = false; + }); + } catch { + _isApplyingRemote = false; + } + } + + private _applyRemoteNameChange(name: string): void { + _isApplyingRemote = true; + + try { + this._lastSyncedName = name; + useWorkflowStore.setState({ name }); + + queueMicrotask(() => { + _isApplyingRemote = false; + }); + } catch { + _isApplyingRemote = false; + } + } + + // ── Private: Diff local changes into batch operations ────────────────── + + private _diffNodes(prev: WorkflowNode[], next: WorkflowNode[]): void { + const prevMap = new Map(prev.map((n) => [n.id, n])); + const nextMap = new Map(next.map((n) => [n.id, n])); + + // Deleted nodes + for (const id of prevMap.keys()) { + if (!nextMap.has(id)) { + this._pendingOps.push({ op: "delete_node", nodeId: id }); + } + } + + // Added or changed nodes + for (const [id, node] of nextMap) { + const existing = prevMap.get(id); + const cleaned = cleanNodeForSync(node); + if (!existing || JSON.stringify(cleanNodeForSync(existing)) !== JSON.stringify(cleaned)) { + this._pendingOps.push(workflowNodeToOp(cleaned)); + } + } + } + + private _diffEdges(prev: WorkflowEdge[], next: WorkflowEdge[]): void { + const prevMap = new Map(prev.map((e) => [e.id, e])); + const nextMap = new Map(next.map((e) => [e.id, e])); + + for (const id of prevMap.keys()) { + if (!nextMap.has(id)) { + this._pendingOps.push({ op: "delete_edge", edgeId: id }); + } + } + + for (const [id, edge] of nextMap) { + const existing = prevMap.get(id); + const cleaned = cleanEdgeForSync(edge); + if (!existing || JSON.stringify(cleanEdgeForSync(existing)) !== JSON.stringify(cleaned)) { + this._pendingOps.push(workflowEdgeToOp(cleaned)); + } + } + } + + // ── Private: Flush batched operations to SpacetimeDB ─────────────────── + + private _flushOps(): void { + if (this._pendingOps.length === 0 || !this._workflowId) return; + + const ops = this._pendingOps.splice(0); + + try { + void getSpacetimeClient() + .callReducer("apply_workflow_ops", [ + this._workflowId, + JSON.stringify(ops), + this._displayName, + ]) + .catch(() => { + // If not connected, ops are lost — they'll be re-synced on reconnect + }); + } catch { + // If not connected, ops are lost — they'll be re-synced on reconnect + } + } +} + +// ── Module-level singleton ───────────────────────────────────────────────── + +export const spacetimeWorkspaceSync = new SpacetimeWorkspaceSync(); diff --git a/src/lib/workspace/local-history.ts b/src/lib/workspace/local-history.ts index 94713ca..7eab1e3 100644 --- a/src/lib/workspace/local-history.ts +++ b/src/lib/workspace/local-history.ts @@ -34,3 +34,14 @@ export function addRecentWorkspace(entry: RecentWorkspaceEntry): void { // localStorage may be unavailable } } + +export function removeRecentWorkspace(id: string): void { + if (typeof window === "undefined") return; + try { + const existing = getRecentWorkspaces(); + const updated = existing.filter((entry) => entry.id !== id); + localStorage.setItem(STORAGE_KEY, JSON.stringify(updated)); + } catch { + // localStorage may be unavailable + } +} diff --git a/src/lib/workspace/server.ts b/src/lib/workspace/server.ts index fc8d361..03e3f9f 100644 --- a/src/lib/workspace/server.ts +++ b/src/lib/workspace/server.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { customAlphabet } from "nanoid"; import { getWorkspaceConfig } from "./config"; +import { writeSnapshot } from "./snapshots"; import type { WorkspaceManifest, WorkspaceRecord, WorkflowRecord } from "./types"; import type { WorkflowJSON } from "@/types/workflow"; @@ -31,7 +32,13 @@ async function writeJsonFile(filePath: string, value: unknown): Promise { } function workspaceDir(id: string): string { - return path.join(getWorkspaceConfig().dataDir, id); + const dataDir = path.resolve(getWorkspaceConfig().dataDir); + const dir = path.resolve(dataDir, id); + const relative = path.relative(dataDir, dir); + if (!relative || relative.startsWith("..") || path.isAbsolute(relative)) { + throw new Error("Invalid workspace id"); + } + return dir; } function manifestPath(id: string): string { @@ -59,6 +66,26 @@ function createDefaultWorkflowJSON(name: string): WorkflowJSON { }; } +export async function listWorkspaces(): Promise { + const dataDir = getWorkspaceConfig().dataDir; + try { + const entries = await fs.readdir(dataDir, { withFileTypes: true }); + const workspaces: WorkspaceRecord[] = []; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const mPath = path.join(dataDir, entry.name, MANIFEST_FILE); + const manifest = await readJsonFile(mPath, null); + if (manifest?.workspace) { + workspaces.push(manifest.workspace); + } + } + workspaces.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); + return workspaces; + } catch { + return []; + } +} + export async function createWorkspace(name: string): Promise { const id = nanoid(); const now = nowIso(); @@ -94,6 +121,14 @@ export async function updateWorkspace( return manifest.workspace; } +export async function deleteWorkspace(id: string): Promise { + const manifest = await getWorkspace(id); + if (!manifest) return false; + + await fs.rm(workspaceDir(id), { recursive: true, force: true }); + return true; +} + export async function createWorkflow( workspaceId: string, name: string, @@ -151,6 +186,7 @@ export async function saveWorkflow( await writeJsonFile(workflowPath(workspaceId, workflowId), data); await writeJsonFile(manifestPath(workspaceId), manifest); + await writeSnapshot(workspaceId, workflowId, data, lastModifiedBy); return true; } diff --git a/src/lib/workspace/snapshots.ts b/src/lib/workspace/snapshots.ts new file mode 100644 index 0000000..08faeea --- /dev/null +++ b/src/lib/workspace/snapshots.ts @@ -0,0 +1,222 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { getWorkspaceConfig } from "./config"; +import { getWorkspace } from "./server"; +import type { SnapshotMeta, SnapshotFile, ChangeEvent, WorkflowChanges, ChangesResponse } from "./types"; +import type { WorkflowJSON } from "@/types/workflow"; + +function workspaceDir(id: string): string { + return path.join(getWorkspaceConfig().dataDir, id); +} + +export function snapshotsDir(workspaceId: string, workflowId: string): string { + return path.join(workspaceDir(workspaceId), "snapshots", workflowId); +} + +function toUrlSafeTimestamp(iso: string): string { + return iso.replace(/:/g, "-"); +} + +function fromUrlSafeTimestamp(safe: string): string { + // Format: 2026-04-10T12-30-00.000Z → 2026-04-10T12:30:00.000Z + // Only replace dashes that appear after the T (time portion) + const tIndex = safe.indexOf("T"); + if (tIndex < 0) return safe; + const datePart = safe.slice(0, tIndex); + const timePart = safe.slice(tIndex).replace(/-/g, ":"); + return datePart + timePart; +} + +export async function writeSnapshot( + workspaceId: string, + workflowId: string, + data: WorkflowJSON, + savedBy: string, +): Promise { + const timestamp = new Date().toISOString(); + const dir = snapshotsDir(workspaceId, workflowId); + await fs.mkdir(dir, { recursive: true }); + + const snapshot: SnapshotFile = { timestamp, workflowId, workspaceId, savedBy, data }; + const filename = `${toUrlSafeTimestamp(timestamp)}.json`; + const filePath = path.join(dir, filename); + const tmpPath = filePath + ".tmp"; + + await fs.writeFile(tmpPath, JSON.stringify(snapshot, null, 2), "utf8"); + await fs.rename(tmpPath, filePath); +} + +export async function listSnapshots( + workspaceId: string, + workflowId: string, +): Promise { + const dir = snapshotsDir(workspaceId, workflowId); + let entries: string[]; + try { + entries = await fs.readdir(dir); + } catch { + return []; + } + + const metas: SnapshotMeta[] = []; + for (const entry of entries) { + if (!entry.endsWith(".json") || entry.endsWith(".tmp")) continue; + const safeName = entry.replace(".json", ""); + const timestamp = fromUrlSafeTimestamp(safeName); + // Read savedBy from the file + try { + const raw = await fs.readFile(path.join(dir, entry), "utf8"); + const snap = JSON.parse(raw) as SnapshotFile; + metas.push({ timestamp, savedBy: snap.savedBy }); + } catch { + // skip corrupt files + } + } + + metas.sort((a, b) => a.timestamp.localeCompare(b.timestamp)); + return metas; +} + +export async function getSnapshot( + workspaceId: string, + workflowId: string, + timestamp: string, +): Promise { + const dir = snapshotsDir(workspaceId, workflowId); + const filename = `${toUrlSafeTimestamp(timestamp)}.json`; + try { + const raw = await fs.readFile(path.join(dir, filename), "utf8"); + return JSON.parse(raw) as SnapshotFile; + } catch { + return null; + } +} + +interface NodeInfo { + id: string; + label: string; +} + +function extractNodes(data: WorkflowJSON): Map { + const map = new Map(); + for (const node of data.nodes) { + map.set(node.id, { + id: node.id, + label: (node.data as Record)?.label as string ?? node.id, + }); + } + return map; +} + +function diffNodeSets( + older: Map, + newer: Map, + savedBy: string, + timestamp: string, +): ChangeEvent[] { + const events: ChangeEvent[] = []; + + // node_added: in newer but not older + for (const [id, info] of newer) { + if (!older.has(id)) { + events.push({ type: "node_added", nodeName: info.label, by: savedBy, at: timestamp }); + } + } + + // node_deleted: in older but not newer + for (const [id, info] of older) { + if (!newer.has(id)) { + events.push({ type: "node_deleted", nodeName: info.label, by: savedBy, at: timestamp }); + } + } + + // node_renamed: same id, different label + for (const [id, newInfo] of newer) { + const oldInfo = older.get(id); + if (oldInfo && oldInfo.label !== newInfo.label) { + events.push({ + type: "node_renamed", + nodeName: newInfo.label, + from: oldInfo.label, + to: newInfo.label, + by: savedBy, + at: timestamp, + }); + } + } + + return events; +} + +export async function computeChanges( + workspaceId: string, + since: string, +): Promise { + const manifest = await getWorkspace(workspaceId); + if (!manifest) return { changes: [] }; + + const results: WorkflowChanges[] = []; + + for (const wfRecord of manifest.workflows) { + const allMetas = await listSnapshots(workspaceId, wfRecord.id); + if (allMetas.length === 0) continue; + + // Find snapshots after `since` + const afterSince = allMetas.filter((m) => m.timestamp > since); + if (afterSince.length === 0) continue; + + // Find the baseline: the last snapshot at or before `since` + const beforeSince = allMetas.filter((m) => m.timestamp <= since); + const baselineMeta = beforeSince.length > 0 ? beforeSince[beforeSince.length - 1] : null; + + // Build ordered list: [baseline, ...afterSince] + const snapshotsToWalk: SnapshotFile[] = []; + + if (baselineMeta) { + const baseSnap = await getSnapshot(workspaceId, wfRecord.id, baselineMeta.timestamp); + if (baseSnap) snapshotsToWalk.push(baseSnap); + } + + for (const meta of afterSince) { + const snap = await getSnapshot(workspaceId, wfRecord.id, meta.timestamp); + if (snap) snapshotsToWalk.push(snap); + } + + if (snapshotsToWalk.length === 0) continue; + + const events: ChangeEvent[] = []; + + if (!baselineMeta && snapshotsToWalk.length > 0) { + // No baseline — first snapshot's nodes are all "added" + const first = snapshotsToWalk[0]; + const emptyMap = new Map(); + const firstNodes = extractNodes(first.data); + events.push(...diffNodeSets(emptyMap, firstNodes, first.savedBy, first.timestamp)); + + // Walk remaining pairs + for (let i = 1; i < snapshotsToWalk.length; i++) { + const older = extractNodes(snapshotsToWalk[i - 1].data); + const newer = extractNodes(snapshotsToWalk[i].data); + events.push(...diffNodeSets(older, newer, snapshotsToWalk[i].savedBy, snapshotsToWalk[i].timestamp)); + } + } else { + // Walk adjacent pairs starting from baseline + for (let i = 1; i < snapshotsToWalk.length; i++) { + const older = extractNodes(snapshotsToWalk[i - 1].data); + const newer = extractNodes(snapshotsToWalk[i].data); + events.push(...diffNodeSets(older, newer, snapshotsToWalk[i].savedBy, snapshotsToWalk[i].timestamp)); + } + } + + if (events.length > 0) { + results.push({ + workflowId: wfRecord.id, + workflowName: wfRecord.name, + changeCount: events.length, + events, + }); + } + } + + return { changes: results }; +} diff --git a/src/lib/workspace/types.ts b/src/lib/workspace/types.ts index b718ea7..6e0ba5a 100644 --- a/src/lib/workspace/types.ts +++ b/src/lib/workspace/types.ts @@ -19,3 +19,40 @@ export interface WorkspaceManifest { workspace: WorkspaceRecord; workflows: WorkflowRecord[]; } + +// Snapshot types +export interface SnapshotMeta { + timestamp: string; + savedBy: string; +} + +export interface SnapshotFile { + timestamp: string; + workflowId: string; + workspaceId: string; + savedBy: string; + data: import("@/types/workflow").WorkflowJSON; +} + +// Change event types +export type ChangeEventType = "node_added" | "node_deleted" | "node_renamed"; + +export interface ChangeEvent { + type: ChangeEventType; + nodeName: string; + from?: string; + to?: string; + by: string; + at: string; +} + +export interface WorkflowChanges { + workflowId: string; + workflowName: string; + changeCount: number; + events: ChangeEvent[]; +} + +export interface ChangesResponse { + changes: WorkflowChanges[]; +} diff --git a/src/store/collaboration/collab-store.ts b/src/store/collaboration/collab-store.ts index 079124d..40d3ed1 100644 --- a/src/store/collaboration/collab-store.ts +++ b/src/store/collaboration/collab-store.ts @@ -5,16 +5,20 @@ import { customAlphabet } from "nanoid"; const nanoid = customAlphabet("abcdefghijklmnopqrstuvwxyz0123456789", 21); +export type SyncBackend = "yjs" | "spacetimedb" | null; + export interface CollabState { roomId: string | null; isConnected: boolean; isInitializing: boolean; peerCount: number; - // Internal setters — used by CollabDoc + syncBackend: SyncBackend; + // Internal setters — used by CollabDoc and SpacetimeDB sync bridges _setRoomId: (id: string | null) => void; _setConnected: (v: boolean) => void; _setInitializing: (v: boolean) => void; _setPeerCount: (n: number) => void; + _setSyncBackend: (backend: SyncBackend) => void; } export const useCollabStore = create()((set) => ({ @@ -22,10 +26,12 @@ export const useCollabStore = create()((set) => ({ isConnected: false, isInitializing: false, peerCount: 0, + syncBackend: null, _setRoomId: (id) => set({ roomId: id }), _setConnected: (v) => set({ isConnected: v }), _setInitializing: (v) => set({ isInitializing: v }), _setPeerCount: (n) => set({ peerCount: n }), + _setSyncBackend: (backend) => set({ syncBackend: backend }), })); /** Generate a new room ID and push it to the URL. Returns the room ID. */ diff --git a/tsconfig.json b/tsconfig.json index 3754d31..206dca1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -32,5 +32,5 @@ ".next/dev/types/**/*.ts", "**/*.mts" ], - "exclude": ["node_modules"] + "exclude": ["node_modules", "spacetime"] }