From 9e380c617fbd8b4daae3fcf03cc1cdbfff1f9cac Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 20 Sep 2026 16:07:42 +0000 Subject: [PATCH 1/3] feat(workspace): prove collaboration durability --- .../2026-09-20_131317-debate-workspace.md | 12 +- package-lock.json | 734 ++++++++++++++++++ package.json | 12 + services/collaboration/README.md | 40 + services/collaboration/src/crash.test.ts | 154 ++++ services/collaboration/src/server.test.ts | 513 ++++++++++++ services/collaboration/src/server.ts | 182 +++++ .../collaboration/src/test/crash-server.ts | 123 +++ services/collaboration/src/test/document.ts | 25 + services/collaboration/vitest.config.ts | 10 + src/lib/workspace/editor-schema.ts | 19 + src/lib/workspace/schema.ts | 26 + src/test/workspace/collaboration.test.ts | 99 +++ tsconfig.app.json | 5 +- 14 files changed, 1946 insertions(+), 8 deletions(-) create mode 100644 services/collaboration/README.md create mode 100644 services/collaboration/src/crash.test.ts create mode 100644 services/collaboration/src/server.test.ts create mode 100644 services/collaboration/src/server.ts create mode 100644 services/collaboration/src/test/crash-server.ts create mode 100644 services/collaboration/src/test/document.ts create mode 100644 services/collaboration/vitest.config.ts create mode 100644 src/lib/workspace/editor-schema.ts create mode 100644 src/lib/workspace/schema.ts create mode 100644 src/test/workspace/collaboration.test.ts diff --git a/.hermes/plans/2026-09-20_131317-debate-workspace.md b/.hermes/plans/2026-09-20_131317-debate-workspace.md index debca1891..40d1c8713 100644 --- a/.hermes/plans/2026-09-20_131317-debate-workspace.md +++ b/.hermes/plans/2026-09-20_131317-debate-workspace.md @@ -255,12 +255,12 @@ Each numbered slice is independently reviewable. Before implementation, write it **P0.1 Editor and durability spike.** Proposed new paths: `src/lib/workspace/editor-schema.ts`, `src/test/workspace/collaboration.test.ts`, `services/collaboration/package.json`, `services/collaboration/src/server.ts`, and `services/collaboration/src/server.test.ts`. Modify existing `package.json` and `package-lock.json` only after dependency/license review. Keep the spike isolated from production routes. -- [ ] Resolve current compatible Tiptap/Yjs/Hocuspocus versions from official docs and package manifests, pin exact versions, inspect licenses, and verify React 18/Node 22 compatibility. Record rejected alternatives and extension licensing. -- [ ] Write a two-client actual-Y.Doc test that edits the same paragraph, changes list formatting, inserts a link, disconnects, reconnects, and checks identical structured results. Write a local undo test proving a teammate's text survives. -- [ ] Add a gateway test that forces database append failure, duplicates an update, and kills the process before and after durable ack. Assert no Saved acknowledgement before commit and no acknowledged loss after restart. -- [ ] Prove the full update interception path, including sync handshake. Record hook order from the selected version and ensure rejected mutations never appear to other clients. -- [ ] Add a restore-epoch test where an offline client's old tail returns. Assert the selected version remains current and old edits enter recovery. -- [ ] Run `npx vitest run src/test/workspace/collaboration.test.ts`; add and run a proposed root `test:collaboration` npm script for the real Node-service tests. No spike is accepted on a mocked Yjs merge. +- [x] Resolve current compatible Tiptap/Yjs/Hocuspocus versions from official docs and package manifests, pin exact versions, inspect licenses, and verify React 18/Node 22 compatibility. Record rejected alternatives and extension licensing. +- [x] Write a two-client actual-Y.Doc test that edits the same paragraph, changes list formatting, inserts a link, disconnects, reconnects, and checks identical structured results. Write a local undo test proving a teammate's text survives. +- [x] Add a gateway test that forces database append failure, duplicates an update, and kills the process before and after durable ack. Assert no Saved acknowledgement before commit and no acknowledged loss after restart. +- [x] Prove the full update interception path, including sync handshake. Record hook order from the selected version and ensure rejected mutations never appear to other clients. +- [x] Add a restore-epoch test where an offline client's old tail returns. Assert the selected version remains current and old edits enter recovery. +- [x] Run `npx vitest run src/test/workspace/collaboration.test.ts`; add and run a proposed root `test:collaboration` npm script for the real Node-service tests. No spike is accepted on a mocked Yjs merge. **P0.2 Suggestions and import/export fidelity spike.** Proposed new paths: `src/test/workspace/review.test.ts`, `src/test/workspace/document-conversion.test.ts`, and small fixture files under `src/test/workspace/fixtures/` during implementation. diff --git a/package-lock.json b/package-lock.json index d46abfc81..d1b3c95e4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,8 @@ "@capacitor/ios": "^8.3.4", "@capacitor/keyboard": "^8.0.3", "@capacitor/status-bar": "^8.0.2", + "@hocuspocus/provider": "4.7.0", + "@hocuspocus/server": "4.7.0", "@hookform/resolvers": "^3.10.0", "@jam.dev/sdk": "^1.0.1", "@radix-ui/react-accordion": "^1.2.11", @@ -37,8 +39,15 @@ "@sentry/react": "^10.65.0", "@supabase/supabase-js": "^2.49.1", "@tanstack/react-query": "^5.83.0", + "@tiptap/core": "3.31.3", + "@tiptap/extension-collaboration": "3.31.3", + "@tiptap/extension-table": "3.31.3", + "@tiptap/pm": "3.31.3", + "@tiptap/starter-kit": "3.31.3", + "@tiptap/y-tiptap": "3.0.9", "@vercel/analytics": "^2.0.1", "@vercel/speed-insights": "^2.0.0", + "async-mutex": "0.5.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^3.6.0", @@ -67,6 +76,8 @@ "sonner": "^1.7.4", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", + "y-protocols": "1.0.7", + "yjs": "13.6.32", "zod": "^4.3.6", "zustand": "^5.0.10" }, @@ -943,6 +954,50 @@ "integrity": "sha512-I7xWjLs2YSVMc5gGx1Z3ZG1lgFpITPndpi8Ku55GeEIKpACCPQNS/OTqQbxgTCfq0Ncvcc+CrFov96itVh6Qvw==", "license": "MIT" }, + "node_modules/@hocuspocus/common": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@hocuspocus/common/-/common-4.7.0.tgz", + "integrity": "sha512-KyS6dXoIcWsd+v3gwd6nnZW04M77df5XstPNkOwqoDxwuncygobEN7xsf0x2PmdvlQdQyRaLm2SF4r1GZZGRnA==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.117" + } + }, + "node_modules/@hocuspocus/provider": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@hocuspocus/provider/-/provider-4.7.0.tgz", + "integrity": "sha512-BOAqoj6g4EZQ9pTwIiR7bNl1QkMAnrr8EY69jMcdhSLFtTrsKaE8hsDIEncPFnGq+khWRFCAPEJSM7F9HC7fvQ==", + "license": "MIT", + "dependencies": { + "@hocuspocus/common": "^4.7.0", + "@lifeomic/attempt": "^3.1.0", + "lib0": "^0.2.117" + }, + "peerDependencies": { + "y-protocols": "^1.0.6", + "yjs": "^13.6.8" + } + }, + "node_modules/@hocuspocus/server": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@hocuspocus/server/-/server-4.7.0.tgz", + "integrity": "sha512-rlHSxGXEkYvHgQa9Q8prjMHChIl7daZlXL1pezifS8j+svLHQPKLfevNBxFKc+ZW5pLpUiUJGU++O8eJlSt3Ew==", + "license": "MIT", + "dependencies": { + "@hocuspocus/common": "^4.7.0", + "async-mutex": "^0.5.0", + "crossws": "^0.4.4", + "kleur": "^4.1.5", + "lib0": "^0.2.117" + }, + "engines": { + "node": ">=22" + }, + "peerDependencies": { + "y-protocols": "^1.0.6", + "yjs": "^13.6.8" + } + }, "node_modules/@hookform/resolvers": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-3.10.0.tgz", @@ -1210,6 +1265,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lifeomic/attempt": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@lifeomic/attempt/-/attempt-3.1.0.tgz", + "integrity": "sha512-QZqem4QuAnAyzfz+Gj5/+SLxqwCAw2qmt7732ZXodr6VDWGeYLG6w1i/vYLa55JQM9wRuBKLmXmiZ2P0LtE5rw==", + "license": "MIT" + }, "node_modules/@napi-rs/canvas": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.8.tgz", @@ -3840,6 +3901,425 @@ } } }, + "node_modules/@tiptap/core": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.31.3.tgz", + "integrity": "sha512-Cz50pvciQrxdSxgTkHOVz0uD0Yl/8Xt0QatGD6ILm47jW8EzyHR9RkUGs/D5IqzXKuVPntfw1ttaT926vXfiRg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/pm": "3.31.3" + } + }, + "node_modules/@tiptap/extension-blockquote": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.31.3.tgz", + "integrity": "sha512-fyY2XMbyDDDfOTQ1Qdrnqa1qwC9DWE4n7AfE0EKQI0G8MfLV8RaDlLDcOZDJ9JbMPY7/Gx7EjyK4NKxSW9n2hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" + } + }, + "node_modules/@tiptap/extension-bold": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.31.3.tgz", + "integrity": "sha512-dIuYhKk8TitKU/FeDpoTeWZhU42YgDN5npgWNjAmMmRktPdoxnH3/wGSiwXlqZgJWjehN7kPWDePWpJeAImGpQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extension-bullet-list": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.31.3.tgz", + "integrity": "sha512-qEyyoPapPef4LO8XKaN83bxtaNzkJ4kFn/IxLnEKd4BJ3Mvi4MH2yJYlyDqwFnMoev4pMA1zBHRAt/C0THRmZw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.31.3" + } + }, + "node_modules/@tiptap/extension-code": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.31.3.tgz", + "integrity": "sha512-SzxOqchrD2AcN3uT67PjKmRFEMOU3vNNiwNaamZJUbZrI6Hmy+bvdHJrc3jrIddyyCrAtsnAfRI0cmorW1jGfg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extension-code-block": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.31.3.tgz", + "integrity": "sha512-nvknt4FhyJQjYcvxptmeUlFsIAc8ibua3E5BN4Pim374/9RWepH4cdE9X0/qUTtguHElLx0iKtSIY3rW2qGTFA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" + } + }, + "node_modules/@tiptap/extension-collaboration": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-collaboration/-/extension-collaboration-3.31.3.tgz", + "integrity": "sha512-JAwOXjeeDYghrPcK57dtvxwn06zcz50mxSSk4PaSdLxM8tkw8+udx+51ewqKKd3WlFof4zyMwUEbO/DGaUdEvw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3", + "@tiptap/y-tiptap": "^3.0.7", + "yjs": "^13" + } + }, + "node_modules/@tiptap/extension-document": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.31.3.tgz", + "integrity": "sha512-EexgmqnyDNyGlISxo7SMrp5MygpJYmqD+0cY5jB6L1U6L4CpKKRWUt8OO1sWzEHDE1+TTvwt+WIFoIWAziOtEA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extension-dropcursor": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.31.3.tgz", + "integrity": "sha512-NWomSfu5CSC7VacnMSDzKT8qm66SzMfZwVPEtwY5bPpRTJgTiT1rNK0neDrrzfMN27MfylGyKWWf7Q5Qf8w/fg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.31.3" + } + }, + "node_modules/@tiptap/extension-gapcursor": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.31.3.tgz", + "integrity": "sha512-EBXKb1FrVStsNYCcRGtd9jmzveCvR+eqgg1rVqoONrqFK6U7bga6LN+1dMKroP1kliDVgveYkP3vRYxqw+rFqg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extensions": "3.31.3" + } + }, + "node_modules/@tiptap/extension-hard-break": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.31.3.tgz", + "integrity": "sha512-QAdCvNO4+yW9ATwsrej11NTkDYFqPLIEQr3ARNrKOK1qaiS7A0fia2SEukb/hrkP3A6mbozhoQt2r2RGUf/DpQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extension-heading": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.31.3.tgz", + "integrity": "sha512-rk5VHMAeQcg06SLauN6EGdD2jc0O2qY8QkZYPd0LxNvLblw2BxBx+lxUQSYwLAT9Ie5914gKIK2YbRyO2Ts3ig==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extension-horizontal-rule": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.31.3.tgz", + "integrity": "sha512-YnHGy2KShRwvCseAmmxl9VP7R0qaj8QMp3DA6DJWZqp7r5gLGvDkAqhedxqqefqsE4Y43hjkBdjtB9Ce78LkIw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" + } + }, + "node_modules/@tiptap/extension-italic": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.31.3.tgz", + "integrity": "sha512-ibGvdvAPyfxBMUVNRI43eb9h2/Jka1MRG5GtnGqbcCX/2+Y/y0EOfFrPQPkuYphiWQYyu9+FzPKMB/pjuaLuKQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extension-link": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.31.3.tgz", + "integrity": "sha512-986wOQzTL9Zr5lf84LCLpm+YOms8A0K39/8DVoqRfebqcOe0/eq4bnztmAlfabOd+kJY92g3AgZERFUx/w+dcw==", + "license": "MIT", + "dependencies": { + "linkifyjs": "^4.3.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" + } + }, + "node_modules/@tiptap/extension-list": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.31.3.tgz", + "integrity": "sha512-LoveGnC0FVdCV4jUNBaG1ZA+KWE07+adzV3kGy6uUYFcJEjbVUHTnPDrBOob3IwOSO3sCwIkvQb6EVYeXn/4yg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" + } + }, + "node_modules/@tiptap/extension-list-item": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.31.3.tgz", + "integrity": "sha512-4QlKOriJJMvg95QJTEsy9BUYPBQ6UvJyb8WURRwdUtQUkkqb8h32lg/eyQUv2FzW9IT1AdUyNZfVaxH64kRfQA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.31.3" + } + }, + "node_modules/@tiptap/extension-list-keymap": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.31.3.tgz", + "integrity": "sha512-If8UOEdDZbPJU6iYTvLtH6DOp2KBy6BKxg9UELL1AevVetGHEF/7lW8hP50Gn1tHMVpPRqmhSzVrJRpEJJgb/Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.31.3" + } + }, + "node_modules/@tiptap/extension-ordered-list": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.31.3.tgz", + "integrity": "sha512-mp3g11NgA/PYu8rj7J7Ez3l4qBy6WfTSmHIG4PZvEGG5w2oUAIkgb9DU7nPPzjmeme27oazFYZw+AtZA0+u4tw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/extension-list": "3.31.3" + } + }, + "node_modules/@tiptap/extension-paragraph": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.31.3.tgz", + "integrity": "sha512-+iPku7wJfy5hbNDNLX8dveFtYVsZMmh7vztjuq8hT3mipSC4IByDehDbU8fzUCjfXiEzmI7mQn7c8LGmHULuzA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extension-strike": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.31.3.tgz", + "integrity": "sha512-G29bhKttYwcKHT+BI6emWVFol3RO/gUXxQVcmr/iT8LXXy7j8J6HFUnsKM+Kg5YlP1rxMRgsa65dbrQVahZi0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extension-table": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-3.31.3.tgz", + "integrity": "sha512-7cnVPHhdiGGeauYqca6JVyPLTqZbqFEEk9nn2e2E8+fBo6zVtV07AktJBqth/XEzjZxcUmGxjoeuWYAisWjUHg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" + } + }, + "node_modules/@tiptap/extension-text": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.31.3.tgz", + "integrity": "sha512-gdsWtF+taeaCu6V+5Ct10fGo0ACUy1GnYtbb+mcathBt8OqbT+Ws60p/yEmKesBDz2Hn+B5IcWy6+2BBZl5ZTg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extension-underline": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.31.3.tgz", + "integrity": "sha512-HghdJaOwRqYzsAxqSyNyb+IWyOMcdCl8IoiBETA9BZCJAqdXzFLcuWp7CqCqJPam9dgkWosSoHqTCAO4nTBfpw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3" + } + }, + "node_modules/@tiptap/extensions": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.31.3.tgz", + "integrity": "sha512-8sJNPGGUe8f3aDojcOW5cfVL7I5NrBbE0UWxG08qoi9Tea6qWbvQJsCR9tsrOapr/DaLr3kpbGZ9s1gEEfcNcA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + }, + "peerDependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/pm": "3.31.3" + } + }, + "node_modules/@tiptap/pm": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.31.3.tgz", + "integrity": "sha512-sZime0SWsz/k62W2WvHx5Ig7G2h7kVhrrmnqy+wEgIHfDwEfOlelRjaWCiBCFlF7dxGUntJusCh9FxlLhni0Ag==", + "license": "MIT", + "dependencies": { + "prosemirror-changeset": "^2.4.1", + "prosemirror-commands": "^1.7.1", + "prosemirror-dropcursor": "^1.8.2", + "prosemirror-gapcursor": "^1.4.1", + "prosemirror-history": "^1.5.0", + "prosemirror-inputrules": "^1.5.1", + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.11", + "prosemirror-schema-list": "^1.5.1", + "prosemirror-state": "^1.4.4", + "prosemirror-tables": "^1.8.5", + "prosemirror-transform": "^1.12.0", + "prosemirror-view": "^1.42.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/starter-kit": { + "version": "3.31.3", + "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.31.3.tgz", + "integrity": "sha512-WKof9RewdmGHvWJ1wn0/HVNG2mV+HOgVRyJkKekuM9fgr6BZAAH/xZsWE1eon+94JnQ+KtK2ThydXQM/qc6b2A==", + "license": "MIT", + "dependencies": { + "@tiptap/core": "3.31.3", + "@tiptap/extension-blockquote": "3.31.3", + "@tiptap/extension-bold": "3.31.3", + "@tiptap/extension-bullet-list": "3.31.3", + "@tiptap/extension-code": "3.31.3", + "@tiptap/extension-code-block": "3.31.3", + "@tiptap/extension-document": "3.31.3", + "@tiptap/extension-dropcursor": "3.31.3", + "@tiptap/extension-gapcursor": "3.31.3", + "@tiptap/extension-hard-break": "3.31.3", + "@tiptap/extension-heading": "3.31.3", + "@tiptap/extension-horizontal-rule": "3.31.3", + "@tiptap/extension-italic": "3.31.3", + "@tiptap/extension-link": "3.31.3", + "@tiptap/extension-list": "3.31.3", + "@tiptap/extension-list-item": "3.31.3", + "@tiptap/extension-list-keymap": "3.31.3", + "@tiptap/extension-ordered-list": "3.31.3", + "@tiptap/extension-paragraph": "3.31.3", + "@tiptap/extension-strike": "3.31.3", + "@tiptap/extension-text": "3.31.3", + "@tiptap/extension-underline": "3.31.3", + "@tiptap/extensions": "3.31.3", + "@tiptap/pm": "3.31.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/ueberdosis" + } + }, + "node_modules/@tiptap/y-tiptap": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@tiptap/y-tiptap/-/y-tiptap-3.0.9.tgz", + "integrity": "sha512-7/El8NQ8R5V5MkdrOUdfj9IgZacpt0H071xNimX7B0AnYiWiKefQnMKd41neQYzo2MOXbWdN3iZ+7Z7BruzOSA==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.100" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "peerDependencies": { + "prosemirror-model": "^1.7.1", + "prosemirror-state": "^1.2.3", + "prosemirror-view": "^1.9.10", + "y-protocols": "^1.0.1", + "yjs": "^13.5.38" + } + }, "node_modules/@tootallnate/once": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.1.tgz", @@ -5009,6 +5489,15 @@ "node": ">=8" } }, + "node_modules/async-mutex": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/async-mutex/-/async-mutex-0.5.0.tgz", + "integrity": "sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.4.0" + } + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -5570,6 +6059,20 @@ "node": ">= 8" } }, + "node_modules/crossws": { + "version": "0.4.12", + "resolved": "https://registry.npmjs.org/crossws/-/crossws-0.4.12.tgz", + "integrity": "sha512-aypfsr6t0uNvkqaZc6zvBfXzC6pLI0/sIulpkV6RwCVtZqG5ebBzv4weImKK0VNCj91Wl9F5j7p5WU4MNrybng==", + "license": "MIT", + "peerDependencies": { + "srvx": ">=0.11.5" + }, + "peerDependenciesMeta": { + "srvx": { + "optional": true + } + } + }, "node_modules/css-line-break": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", @@ -7407,6 +7910,16 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/isomorphic.js": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", + "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", + "license": "MIT", + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -7640,6 +8153,27 @@ "node": ">= 0.8.0" } }, + "node_modules/lib0": { + "version": "0.2.117", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.117.tgz", + "integrity": "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==", + "license": "MIT", + "dependencies": { + "isomorphic.js": "^0.2.4" + }, + "bin": { + "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js", + "0gentesthtml": "bin/gentesthtml.js", + "0serve": "bin/0serve.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/lie": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", @@ -7667,6 +8201,12 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "license": "MIT" }, + "node_modules/linkifyjs": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz", + "integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==", + "license": "MIT" + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -8720,6 +9260,12 @@ "node": ">= 0.8.0" } }, + "node_modules/orderedmap": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz", + "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==", + "license": "MIT" + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -9346,6 +9892,145 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/prosemirror-changeset": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.3.tgz", + "integrity": "sha512-J8jqUZ1xybXKY44v3K0c8m8ZTIqJPVT34t2UVyxv4VEWoNd5C9t5Y52GNuMriRJ3IpNxOSE2R60vN4qBrTMcyQ==", + "license": "MIT", + "dependencies": { + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-commands": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.2.tgz", + "integrity": "sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.10.2" + } + }, + "node_modules/prosemirror-dropcursor": { + "version": "1.8.3", + "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.3.tgz", + "integrity": "sha512-FoYbsJR8gK+DGlqhNoE29Loa38eIZPzQRIb1VMaDNBoo4OLP6vVof/jR8qFY/6XvUd6Dhug8MDCHl2a/h8RTfQ==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0", + "prosemirror-view": "^1.1.0" + } + }, + "node_modules/prosemirror-gapcursor": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz", + "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.0.0", + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-view": "^1.0.0" + } + }, + "node_modules/prosemirror-history": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz", + "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.2.2", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.31.0", + "rope-sequence": "^1.3.0" + } + }, + "node_modules/prosemirror-inputrules": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz", + "integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.0.0" + } + }, + "node_modules/prosemirror-keymap": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz", + "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==", + "license": "MIT", + "dependencies": { + "prosemirror-state": "^1.0.0", + "w3c-keyname": "^2.2.0" + } + }, + "node_modules/prosemirror-model": { + "version": "1.25.11", + "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.11.tgz", + "integrity": "sha512-QWg9RhnpLlogAmp3p96uEFrE5txQpFynd4vhBAELkwgOCWQs/X0yCzB3/hrHqiPwf91RG5KyWq6553zs9JqIOQ==", + "license": "MIT", + "dependencies": { + "orderedmap": "^2.0.0" + } + }, + "node_modules/prosemirror-schema-list": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz", + "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.7.3" + } + }, + "node_modules/prosemirror-state": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", + "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.0.0", + "prosemirror-transform": "^1.0.0", + "prosemirror-view": "^1.27.0" + } + }, + "node_modules/prosemirror-tables": { + "version": "1.8.5", + "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz", + "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==", + "license": "MIT", + "dependencies": { + "prosemirror-keymap": "^1.2.3", + "prosemirror-model": "^1.25.4", + "prosemirror-state": "^1.4.4", + "prosemirror-transform": "^1.10.5", + "prosemirror-view": "^1.41.4" + } + }, + "node_modules/prosemirror-transform": { + "version": "1.12.1", + "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.1.tgz", + "integrity": "sha512-t4F5615FycnCqsX7ShTUs8+jfnwcf46kuFRvSl/3qFx2QTZ4DjgowesLHQXcFP7G//TjesqZG3WtgL7vdyVJyA==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.21.0" + } + }, + "node_modules/prosemirror-view": { + "version": "1.42.4", + "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.4.tgz", + "integrity": "sha512-H/LErnE8Vms1GYkvhfj6G3K9rc2p+o5EHGmTwvPOl0f21wPPxlMVRB8ICOseH+COb2oPKFEoufbgY6yet/dR4w==", + "license": "MIT", + "dependencies": { + "prosemirror-model": "^1.25.8", + "prosemirror-state": "^1.0.0", + "prosemirror-transform": "^1.1.0" + } + }, "node_modules/psl": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", @@ -10017,6 +10702,12 @@ "fsevents": "~2.3.2" } }, + "node_modules/rope-sequence": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz", + "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==", + "license": "MIT" + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -11837,6 +12528,12 @@ "node": ">=0.10.0" } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/w3c-xmlserializer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", @@ -12080,6 +12777,26 @@ "dev": true, "license": "MIT" }, + "node_modules/y-protocols": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/y-protocols/-/y-protocols-1.0.7.tgz", + "integrity": "sha512-YSVsLoXxO67J6eE/nV4AtFtT3QEotZf5sK5BHxFBXso7VDUT3Tx07IfA6hsu5Q5OmBdMkQVmFZ9QOA7fikWvnw==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.85" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + }, + "peerDependencies": { + "yjs": "^13.0.0" + } + }, "node_modules/yallist": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", @@ -12099,6 +12816,23 @@ "fd-slicer": "~1.1.0" } }, + "node_modules/yjs": { + "version": "13.6.32", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.32.tgz", + "integrity": "sha512-lfiJIIC4Xayt5ItynE407ehlE03pCjeOc4hkR4yxxvvNJ4kuiN25B0g+Qp8XagYz361LLL7DCzR5bvFJ81QKtQ==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.99" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index 7b2502bf9..00ebb22be 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "typecheck": "tsc --noEmit -p tsconfig.app.json", "preview": "vite preview", "test": "vitest run", + "test:collaboration": "vitest run --config services/collaboration/vitest.config.ts", "test:watch": "vitest", "test:edge": "deno test --allow-read --allow-env=DEMO_CODE_PEPPER,EXA_API_KEY,SUPABASE_URL,SUPABASE_SERVICE_ROLE_KEY,SPARRING_INTEGRITY_SECRET,OPENROUTER_API_KEY --allow-net=127.0.0.1 supabase/functions/", "test:db": "supabase test db && bash scripts/test-daily-quota.sh", @@ -34,6 +35,8 @@ "@capacitor/ios": "^8.3.4", "@capacitor/keyboard": "^8.0.3", "@capacitor/status-bar": "^8.0.2", + "@hocuspocus/provider": "4.7.0", + "@hocuspocus/server": "4.7.0", "@hookform/resolvers": "^3.10.0", "@jam.dev/sdk": "^1.0.1", "@radix-ui/react-accordion": "^1.2.11", @@ -55,8 +58,15 @@ "@sentry/react": "^10.65.0", "@supabase/supabase-js": "^2.49.1", "@tanstack/react-query": "^5.83.0", + "@tiptap/core": "3.31.3", + "@tiptap/extension-collaboration": "3.31.3", + "@tiptap/extension-table": "3.31.3", + "@tiptap/pm": "3.31.3", + "@tiptap/starter-kit": "3.31.3", + "@tiptap/y-tiptap": "3.0.9", "@vercel/analytics": "^2.0.1", "@vercel/speed-insights": "^2.0.0", + "async-mutex": "0.5.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^3.6.0", @@ -85,6 +95,8 @@ "sonner": "^1.7.4", "tailwind-merge": "^2.6.0", "tailwindcss-animate": "^1.0.7", + "y-protocols": "1.0.7", + "yjs": "13.6.32", "zod": "^4.3.6", "zustand": "^5.0.10" }, diff --git a/services/collaboration/README.md b/services/collaboration/README.md new file mode 100644 index 000000000..3a4d42e20 --- /dev/null +++ b/services/collaboration/README.md @@ -0,0 +1,40 @@ +# Collaboration service spike + +**Status:** Phase 0 evidence only. No production route or deployment uses this service. + +## Selected stack + +- Tiptap 3.31.3 +- Hocuspocus 4.7.0 +- Yjs 13.6.32 +- y-protocols 1.0.7 +- async-mutex 0.5.0 + +These exact versions are MIT, support Node 22, and work with DebateLab's React 18 client. Tiptap 3 uses `@tiptap/y-tiptap`; the spike does not install redundant `y-prosemirror`, React provider bindings, or IndexedDB support. + +## Proven write order + +Both reconnect `SyncStep2` and live `Update` messages use the same public Hocuspocus hook path: + +1. authenticate the room's document and epoch; +2. acquire the per-document lock; +3. apply the candidate to a bounded scratch Y.Doc and validate the editor schema; +4. append the update with authenticated author, epoch, SHA-256 mutation ID, and server sequence; +5. let Hocuspocus apply and broadcast synchronously; +6. verify the live document contains the update; +7. send the stateless durable acknowledgement and release the lock. + +The UI must use the custom acknowledgement for Saved. Hocuspocus `synced` and its boolean sync status do not prove database persistence. A reconnecting client queries pending mutation IDs and receives the original durable sequence. + +The tests reject append failures, invalid schema, stale epochs, and frames above 2 MiB before broadcast or acknowledgement. They also kill the service after commit before acknowledgement and after acknowledgement, then reload the stored update in a new process. + +## Remaining production boundary + +The in-memory mutex proves single-process serialization. P2.1 must replace it with the planned dedicated-session PostgreSQL advisory lock and terminate writable sessions when that database connection is lost. P2.2 owns account-scoped IndexedDB recovery and presence. + +Run: + +```sh +npm run test:collaboration +npx vitest run src/test/workspace/collaboration.test.ts +``` diff --git a/services/collaboration/src/crash.test.ts b/services/collaboration/src/crash.test.ts new file mode 100644 index 000000000..a9703bdea --- /dev/null +++ b/services/collaboration/src/crash.test.ts @@ -0,0 +1,154 @@ +// @vitest-environment node + +import { fork, type ChildProcess } from "node:child_process"; +import { once } from "node:events"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { HocuspocusProvider } from "@hocuspocus/provider"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import * as Y from "yjs"; +import { z } from "zod"; +import { documentText, insertParagraph } from "./test/document"; + +const childMessage = z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("ready"), port: z.number().int().positive() }), + z.object({ + kind: z.literal("persisted"), + mutationId: z.string().min(1), + sequence: z.number().int().positive(), + }), +]); + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + return { promise, resolve }; +} + +async function startServer(storePath: string, pauseAfterAppend: boolean) { + const persisted = deferred<{ mutationId: string; sequence: number }>(); + const child = fork( + fileURLToPath(new URL("./test/crash-server.ts", import.meta.url)), + [], + { + env: { + ...process.env, + COLLAB_PAUSE_AFTER_APPEND: String(pauseAfterAppend), + COLLAB_STORE_PATH: storePath, + }, + execArgv: ["--import", "tsx"], + stdio: ["ignore", "ignore", "inherit", "ipc"], + }, + ); + + const port = await new Promise((resolve, reject) => { + child.on("message", (input) => { + const message = childMessage.safeParse(input); + if (!message.success) return; + if (message.data.kind === "ready") resolve(message.data.port); + if (message.data.kind === "persisted") { + persisted.resolve(message.data); + } + }); + child.once("exit", (code) => { + reject(new Error(`Crash server exited before ready with code ${code}`)); + }); + }); + + return { child, persisted: persisted.promise, port }; +} + +async function kill(child: ChildProcess) { + const exited = once(child, "exit"); + child.kill("SIGKILL"); + await exited; +} + +describe("collaboration crash recovery", () => { + const cleanup: Array<() => void | Promise> = []; + + afterEach(async () => { + await Promise.all(cleanup.splice(0).map((dispose) => dispose())); + }); + + it( + "recovers updates killed after commit before ack and after ack", + async () => { + const directory = await mkdtemp(join(tmpdir(), "debatelab-collab-")); + const storePath = join(directory, "updates.jsonl"); + cleanup.push(() => rm(directory, { force: true, recursive: true })); + + const first = await startServer(storePath, true); + cleanup.push(() => { + if (!first.child.killed) first.child.kill("SIGKILL"); + }); + const document = new Y.Doc(); + const firstAcks: string[] = []; + const provider = new HocuspocusProvider({ + document, + name: "speech-crash@1", + onStateless: ({ payload }) => firstAcks.push(payload), + token: "author", + url: `ws://127.0.0.1:${first.port}`, + }); + cleanup.push(() => provider.destroy()); + + await vi.waitFor(() => expect(provider.isSynced).toBe(true)); + insertParagraph(document, "Survives restart"); + const persisted = await first.persisted; + expect(firstAcks).toEqual([]); + await kill(first.child); + provider.destroy(); + + const second = await startServer(storePath, false); + cleanup.push(() => { + if (!second.child.killed) second.child.kill("SIGKILL"); + }); + const secondAcks: string[] = []; + const reconnected = new HocuspocusProvider({ + document, + name: "speech-crash@1", + onStateless: ({ payload }) => secondAcks.push(payload), + token: "author", + url: `ws://127.0.0.1:${second.port}`, + }); + cleanup.push(() => reconnected.destroy()); + + await vi.waitFor(() => expect(reconnected.isSynced).toBe(true)); + reconnected.sendStateless( + JSON.stringify({ + epoch: 1, + kind: "durable-ack-query", + mutationIds: [persisted.mutationId], + }), + ); + await vi.waitFor(() => expect(secondAcks).toHaveLength(1)); + expect(JSON.parse(secondAcks[0]).sequence).toBe(persisted.sequence); + await kill(second.child); + reconnected.destroy(); + + const third = await startServer(storePath, false); + cleanup.push(() => { + if (!third.child.killed) third.child.kill("SIGKILL"); + }); + const freshDocument = new Y.Doc(); + const fresh = new HocuspocusProvider({ + document: freshDocument, + name: "speech-crash@1", + token: "author", + url: `ws://127.0.0.1:${third.port}`, + }); + cleanup.push(() => fresh.destroy()); + + await vi.waitFor(() => { + expect(fresh.isSynced).toBe(true); + expect(documentText(freshDocument)).toContain("Survives restart"); + }); + }, + 15_000, + ); +}); diff --git a/services/collaboration/src/server.test.ts b/services/collaboration/src/server.test.ts new file mode 100644 index 000000000..fb84095be --- /dev/null +++ b/services/collaboration/src/server.test.ts @@ -0,0 +1,513 @@ +// @vitest-environment node + +import { HocuspocusProvider } from "@hocuspocus/provider"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import * as Y from "yjs"; +import { + createCollaborationServer, + type DurableUpdate, + type DurableUpdateStore, +} from "./server"; +import { appendText, documentText, insertParagraph } from "./test/document"; + +function deferred() { + let resolve!: (value: T | PromiseLike) => void; + const promise = new Promise((resolvePromise) => { + resolve = resolvePromise; + }); + + return { promise, resolve }; +} + +class BlockingStore implements DurableUpdateStore { + readonly appendStarted = deferred(); + readonly releaseAppend = deferred(); + readonly updates: DurableUpdate[] = []; + + async load() { + return this.updates; + } + + async find(documentId: string, epoch: number, mutationId: string) { + return this.updates.find( + (update) => + update.documentId === documentId && + update.epoch === epoch && + update.mutationId === mutationId, + ); + } + + async append(update: Omit) { + this.appendStarted.resolve(); + await this.releaseAppend.promise; + + const existing = this.updates.find( + (existingUpdate) => + existingUpdate.documentId === update.documentId && + existingUpdate.epoch === update.epoch && + existingUpdate.mutationId === update.mutationId, + ); + if (existing) return existing; + + const durable = { ...update, sequence: this.updates.length + 1 }; + this.updates.push(durable); + return durable; + } +} + +class MemoryStore implements DurableUpdateStore { + readonly updates: DurableUpdate[] = []; + + async load() { + return this.updates; + } + + async find(documentId: string, epoch: number, mutationId: string) { + return this.updates.find( + (update) => + update.documentId === documentId && + update.epoch === epoch && + update.mutationId === mutationId, + ); + } + + async append(update: Omit) { + const existing = this.updates.find( + (existingUpdate) => + existingUpdate.documentId === update.documentId && + existingUpdate.epoch === update.epoch && + existingUpdate.mutationId === update.mutationId, + ); + if (existing) return existing; + + const durable = { ...update, sequence: this.updates.length + 1 }; + this.updates.push(durable); + return durable; + } +} + +describe("collaboration durability", () => { + const cleanup: Array<() => void | Promise> = []; + + afterEach(async () => { + await Promise.all(cleanup.splice(0).map((dispose) => dispose())); + }); + + it("does not broadcast or acknowledge an update before append commits", async () => { + const store = new BlockingStore(); + const server = createCollaborationServer({ + authenticate: async ({ documentName, token }) => ({ + authorId: token, + documentId: documentName.split("@")[0], + epoch: Number(documentName.split("@")[1]), + }), + store, + }); + await server.listen(); + cleanup.push(() => server.destroy()); + + const url = `ws://127.0.0.1:${server.address.port}`; + const documentName = "speech-1@1"; + const peerDocument = new Y.Doc(); + const authorDocument = new Y.Doc(); + const acknowledgements: string[] = []; + const peer = new HocuspocusProvider({ + document: peerDocument, + name: documentName, + token: "peer", + url, + }); + const author = new HocuspocusProvider({ + document: authorDocument, + name: documentName, + onStateless: ({ payload }) => acknowledgements.push(payload), + token: "author", + url, + }); + cleanup.push(() => peer.destroy(), () => author.destroy()); + + await vi.waitFor(() => { + expect(peer.isSynced).toBe(true); + expect(author.isSynced).toBe(true); + }); + + insertParagraph(authorDocument, "Durable claim"); + await store.appendStarted.promise; + + expect(documentText(peerDocument)).toBe(""); + expect(acknowledgements).toEqual([]); + + store.releaseAppend.resolve(); + + await vi.waitFor(() => { + expect(documentText(peerDocument)).toContain("Durable claim"); + expect(acknowledgements).toHaveLength(1); + }); + + expect(JSON.parse(acknowledgements[0])).toMatchObject({ + epoch: 1, + kind: "durable-ack", + sequence: 1, + }); + }); + + it("persists reconnect SyncStep2 and live Update messages through one gate", async () => { + const store = new MemoryStore(); + const server = createCollaborationServer({ + authenticate: async ({ documentName, token }) => ({ + authorId: token, + documentId: documentName.split("@")[0], + epoch: Number(documentName.split("@")[1]), + }), + store, + }); + await server.listen(); + cleanup.push(() => server.destroy()); + + const document = new Y.Doc(); + insertParagraph(document, "Offline"); + const acknowledgements: string[] = []; + const provider = new HocuspocusProvider({ + document, + name: "speech-2@1", + onStateless: ({ payload }) => acknowledgements.push(payload), + token: "author", + url: `ws://127.0.0.1:${server.address.port}`, + }); + cleanup.push(() => provider.destroy()); + + await vi.waitFor(() => expect(acknowledgements).toHaveLength(1)); + expect(store.updates[0].syncType).toBe(1); + + appendText(document, " edit"); + + await vi.waitFor(() => expect(acknowledgements).toHaveLength(2)); + expect(store.updates[1].syncType).toBe(2); + }); + + it("returns the original sequence when the same update is delivered twice", async () => { + const store = new MemoryStore(); + const server = createCollaborationServer({ + authenticate: async ({ documentName, token }) => ({ + authorId: token, + documentId: documentName.split("@")[0], + epoch: Number(documentName.split("@")[1]), + }), + store, + }); + await server.listen(); + cleanup.push(() => server.destroy()); + + const document = new Y.Doc(); + const acknowledgements: string[] = []; + let updateMessage: Uint8Array | undefined; + const provider = new HocuspocusProvider({ + document, + name: "speech-3@1", + onOutgoingMessage: ({ message }) => { + if (message.description === "A document update") { + updateMessage = message.toUint8Array(); + } + }, + onStateless: ({ payload }) => acknowledgements.push(payload), + token: "author", + url: `ws://127.0.0.1:${server.address.port}`, + }); + cleanup.push(() => provider.destroy()); + + await vi.waitFor(() => expect(provider.isSynced).toBe(true)); + insertParagraph(document, "One append"); + await vi.waitFor(() => expect(acknowledgements).toHaveLength(1)); + if (!updateMessage) throw new Error("Expected an outbound update message"); + + provider.configuration.websocketProvider.send(updateMessage); + + await vi.waitFor(() => expect(acknowledgements).toHaveLength(2)); + expect(store.updates).toHaveLength(1); + expect(JSON.parse(acknowledgements[1]).sequence).toBe( + JSON.parse(acknowledgements[0]).sequence, + ); + }); + + it("does not apply, broadcast, or acknowledge an update when append fails", async () => { + const appendAttempted = deferred(); + const store: DurableUpdateStore = { + load: async () => [], + find: async () => undefined, + append: async () => { + appendAttempted.resolve(); + throw new Error("database unavailable"); + }, + }; + const server = createCollaborationServer({ + authenticate: async ({ documentName, token }) => ({ + authorId: token, + documentId: documentName.split("@")[0], + epoch: Number(documentName.split("@")[1]), + }), + store, + }); + await server.listen(); + cleanup.push(() => server.destroy()); + + const peerDocument = new Y.Doc(); + const authorDocument = new Y.Doc(); + const acknowledgements: string[] = []; + const peer = new HocuspocusProvider({ + document: peerDocument, + name: "speech-4@1", + token: "peer", + url: `ws://127.0.0.1:${server.address.port}`, + }); + const author = new HocuspocusProvider({ + document: authorDocument, + name: "speech-4@1", + onStateless: ({ payload }) => acknowledgements.push(payload), + token: "author", + url: `ws://127.0.0.1:${server.address.port}`, + }); + const errorLog = vi.spyOn(console, "error").mockImplementation(() => undefined); + cleanup.push( + () => peer.destroy(), + () => author.destroy(), + () => errorLog.mockRestore(), + ); + + await vi.waitFor(() => { + expect(peer.isSynced).toBe(true); + expect(author.isSynced).toBe(true); + }); + insertParagraph(authorDocument, "Rejected"); + await appendAttempted.promise; + await vi.waitFor(() => expect(author.isSynced).toBe(false)); + + expect(documentText(peerDocument)).toBe(""); + expect(acknowledgements).toEqual([]); + }); + + it("replays a durable acknowledgement requested after reconnect", async () => { + const store = new MemoryStore(); + const server = createCollaborationServer({ + authenticate: async ({ documentName, token }) => ({ + authorId: token, + documentId: documentName.split("@")[0], + epoch: Number(documentName.split("@")[1]), + }), + store, + }); + await server.listen(); + cleanup.push(() => server.destroy()); + + const acknowledgements: string[] = []; + const provider = new HocuspocusProvider({ + document: new Y.Doc(), + name: "speech-5@1", + onStateless: ({ payload }) => acknowledgements.push(payload), + token: "author", + url: `ws://127.0.0.1:${server.address.port}`, + }); + cleanup.push(() => provider.destroy()); + + await vi.waitFor(() => expect(provider.isSynced).toBe(true)); + insertParagraph(provider.document, "Persisted"); + await vi.waitFor(() => expect(acknowledgements).toHaveLength(1)); + const original = JSON.parse(acknowledgements[0]); + acknowledgements.length = 0; + + provider.sendStateless( + JSON.stringify({ + epoch: 1, + kind: "durable-ack-query", + mutationIds: [original.mutationId], + }), + ); + + await vi.waitFor(() => expect(acknowledgements).toHaveLength(1)); + expect(JSON.parse(acknowledgements[0])).toEqual(original); + }); + + it("rejects an old epoch while leaving its local draft recoverable", async () => { + const store = new MemoryStore(); + const server = createCollaborationServer({ + authenticate: async ({ documentName, token }) => { + const epoch = Number(documentName.split("@")[1]); + if (epoch !== 2) throw new Error("stale epoch"); + return { + authorId: token, + documentId: documentName.split("@")[0], + epoch, + }; + }, + store, + }); + await server.listen(); + cleanup.push(() => server.destroy()); + + const oldDocument = new Y.Doc(); + insertParagraph(oldDocument, "Offline recovery draft"); + const rejected = deferred(); + const oldProvider = new HocuspocusProvider({ + document: oldDocument, + name: "speech-6@1", + onAuthenticationFailed: () => rejected.resolve(), + token: "author", + url: `ws://127.0.0.1:${server.address.port}`, + }); + cleanup.push(() => oldProvider.destroy()); + await rejected.promise; + + const restoredDocument = new Y.Doc(); + const restoredProvider = new HocuspocusProvider({ + document: restoredDocument, + name: "speech-6@2", + token: "author", + url: `ws://127.0.0.1:${server.address.port}`, + }); + cleanup.push(() => restoredProvider.destroy()); + await vi.waitFor(() => expect(restoredProvider.isSynced).toBe(true)); + + expect(documentText(oldDocument)).toContain("Offline recovery draft"); + expect(documentText(restoredDocument)).toBe(""); + expect(store.updates).toEqual([]); + }); + + it("rejects updates outside the workspace editor schema", async () => { + const store = new MemoryStore(); + const server = createCollaborationServer({ + authenticate: async ({ documentName, token }) => ({ + authorId: token, + documentId: documentName.split("@")[0], + epoch: Number(documentName.split("@")[1]), + }), + store, + }); + await server.listen(); + cleanup.push(() => server.destroy()); + + const document = new Y.Doc(); + const acknowledgements: string[] = []; + const provider = new HocuspocusProvider({ + document, + name: "speech-7@1", + onStateless: ({ payload }) => acknowledgements.push(payload), + token: "author", + url: `ws://127.0.0.1:${server.address.port}`, + }); + const errorLog = vi.spyOn(console, "error").mockImplementation(() => undefined); + cleanup.push(() => provider.destroy(), () => errorLog.mockRestore()); + + await vi.waitFor(() => expect(provider.isSynced).toBe(true)); + document.getMap("unexpected").set("payload", "not editor content"); + await vi.waitFor(() => expect(provider.isSynced).toBe(false)); + + expect(store.updates).toEqual([]); + expect(acknowledgements).toEqual([]); + }); + + it("serializes concurrent writers for one document", async () => { + const firstStarted = deferred(); + const releaseFirst = deferred(); + const releaseSecond = deferred(); + let calls = 0; + let secondStarted = false; + const updates: DurableUpdate[] = []; + cleanup.push(() => { + releaseFirst.resolve(); + releaseSecond.resolve(); + }); + const store: DurableUpdateStore = { + load: async () => updates, + find: async () => undefined, + append: async (update) => { + calls += 1; + if (calls === 1) { + firstStarted.resolve(); + await releaseFirst.promise; + } else { + secondStarted = true; + await releaseSecond.promise; + } + const durable = { ...update, sequence: calls }; + updates.push(durable); + return durable; + }, + }; + const server = createCollaborationServer({ + authenticate: async ({ documentName, token }) => ({ + authorId: token, + documentId: documentName.split("@")[0], + epoch: Number(documentName.split("@")[1]), + }), + store, + }); + await server.listen(); + cleanup.push(() => server.destroy()); + + const leftDocument = new Y.Doc(); + const rightDocument = new Y.Doc(); + const left = new HocuspocusProvider({ + document: leftDocument, + name: "speech-8@1", + token: "left", + url: `ws://127.0.0.1:${server.address.port}`, + }); + const right = new HocuspocusProvider({ + document: rightDocument, + name: "speech-8@1", + token: "right", + url: `ws://127.0.0.1:${server.address.port}`, + }); + cleanup.push(() => left.destroy(), () => right.destroy()); + await vi.waitFor(() => { + expect(left.isSynced).toBe(true); + expect(right.isSynced).toBe(true); + }); + + insertParagraph(leftDocument, "Left"); + insertParagraph(rightDocument, "Right"); + await firstStarted.promise; + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(secondStarted).toBe(false); + + releaseFirst.resolve(); + await vi.waitFor(() => expect(secondStarted).toBe(true)); + releaseSecond.resolve(); + await vi.waitFor(() => expect(updates).toHaveLength(2)); + expect(updates.map(({ sequence }) => sequence)).toEqual([1, 2]); + }); + + it("rejects an update larger than 2 MiB before append", async () => { + const store = new MemoryStore(); + const server = createCollaborationServer({ + authenticate: async ({ documentName, token }) => ({ + authorId: token, + documentId: documentName.split("@")[0], + epoch: Number(documentName.split("@")[1]), + }), + store, + }); + await server.listen(); + cleanup.push(() => server.destroy()); + + const document = new Y.Doc(); + const acknowledgements: string[] = []; + const provider = new HocuspocusProvider({ + document, + name: "speech-9@1", + onStateless: ({ payload }) => acknowledgements.push(payload), + token: "author", + url: `ws://127.0.0.1:${server.address.port}`, + }); + const errorLog = vi.spyOn(console, "error").mockImplementation(() => undefined); + cleanup.push(() => provider.destroy(), () => errorLog.mockRestore()); + + await vi.waitFor(() => expect(provider.isSynced).toBe(true)); + insertParagraph(document, "x".repeat(2 * 1024 * 1024)); + await vi.waitFor(() => expect(provider.isSynced).toBe(false), { + timeout: 5_000, + }); + + expect(store.updates).toEqual([]); + expect(acknowledgements).toEqual([]); + }); +}); diff --git a/services/collaboration/src/server.ts b/services/collaboration/src/server.ts new file mode 100644 index 000000000..b8ba57663 --- /dev/null +++ b/services/collaboration/src/server.ts @@ -0,0 +1,182 @@ +import { createHash } from "node:crypto"; +import { Server, type Connection } from "@hocuspocus/server"; +import { Mutex } from "async-mutex"; +import { + messageYjsSyncStep2, + messageYjsUpdate, +} from "y-protocols/sync"; +import * as Y from "yjs"; +import { z } from "zod"; +import { validateWorkspaceDocument } from "../../../src/lib/workspace/schema"; + +const MAX_UPDATE_BYTES = 2 * 1024 * 1024; +const MAX_ACK_QUERY_BYTES = 16 * 1024; +const acknowledgementQuery = z.object({ + epoch: z.number().int().nonnegative(), + kind: z.literal("durable-ack-query"), + mutationIds: z.array(z.string().regex(/^[A-Za-z0-9_-]{43}$/)).max(100), +}); + +export interface DurableUpdate { + authorId: string; + bytes: Uint8Array; + documentId: string; + epoch: number; + mutationId: string; + sequence: number; + syncType: number; +} + +export interface DurableUpdateStore { + append(update: Omit): Promise; + find( + documentId: string, + epoch: number, + mutationId: string, + ): Promise; + load(documentId: string, epoch: number): Promise; +} + +interface AuthenticatedDocument { + authorId: string; + documentId: string; + epoch: number; +} + +interface CollaborationServerOptions { + authenticate(input: { + documentName: string; + token: string; + }): Promise; + store: DurableUpdateStore; +} + +function sendAcknowledgement( + connection: Connection, + update: DurableUpdate, +) { + connection.sendStateless( + JSON.stringify({ + epoch: update.epoch, + kind: "durable-ack", + mutationId: update.mutationId, + sequence: update.sequence, + }), + ); +} + +export function createCollaborationServer( + options: CollaborationServerOptions, +): Server { + const pending = new WeakMap< + Connection, + { durable: DurableUpdate; release: () => void } + >(); + const locks = new Map(); + + return new Server({ + address: "127.0.0.1", + flushDelay: false, + port: 0, + quiet: true, + stopOnSignals: false, + onAuthenticate: ({ documentName, token }) => + options.authenticate({ documentName, token }), + async onLoadDocument({ context }) { + const document = new Y.Doc(); + const updates = await options.store.load(context.documentId, context.epoch); + for (const update of [...updates].sort( + (a, b) => a.sequence - b.sequence, + )) { + Y.applyUpdate(document, update.bytes); + } + return document; + }, + async onStateless({ connection, payload }) { + if (payload.length > MAX_ACK_QUERY_BYTES) return; + let input: unknown; + try { + input = JSON.parse(payload); + } catch { + return; + } + const query = acknowledgementQuery.safeParse(input); + const context = connection.context; + if (!query.success || query.data.epoch !== context.epoch) return; + + for (const mutationId of query.data.mutationIds) { + const update = await options.store.find( + context.documentId, + context.epoch, + mutationId, + ); + if (update?.authorId === context.authorId) { + sendAcknowledgement(connection, update); + } + } + }, + async beforeSync({ connection, context, document, payload, type }) { + if (type !== messageYjsSyncStep2 && type !== messageYjsUpdate) return; + if (payload.byteLength > MAX_UPDATE_BYTES) { + throw new Error("Collaboration update exceeds 2 MiB"); + } + const decoded = Y.decodeUpdate(payload); + if (decoded.structs.length === 0 && decoded.ds.clients.size === 0) return; + + const key = `${context.documentId}@${context.epoch}`; + const mutex = locks.get(key) ?? new Mutex(); + locks.set(key, mutex); + const release = await mutex.acquire(); + try { + const candidate = new Y.Doc(); + try { + Y.applyUpdate(candidate, Y.encodeStateAsUpdate(document)); + Y.applyUpdate(candidate, payload); + validateWorkspaceDocument(candidate); + if (Y.encodeStateAsUpdate(candidate).byteLength > MAX_UPDATE_BYTES) { + throw new Error("Collaboration document exceeds spike limit"); + } + } finally { + candidate.destroy(); + } + + const mutationId = createHash("sha256") + .update(payload) + .digest("base64url"); + const durable = await options.store.append({ + authorId: context.authorId, + bytes: payload, + documentId: context.documentId, + epoch: context.epoch, + mutationId, + syncType: type, + }); + pending.set(connection, { durable, release }); + } catch (error) { + release(); + if (!mutex.isLocked()) locks.delete(key); + throw error; + } + }, + async afterHandleMessage({ connection, document }) { + const accepted = pending.get(connection); + pending.delete(connection); + if (!accepted) return; + const key = `${accepted.durable.documentId}@${accepted.durable.epoch}`; + try { + if ( + Y.snapshotContainsUpdate( + Y.snapshot(document), + accepted.durable.bytes, + ) + ) { + sendAcknowledgement(connection, accepted.durable); + } + } finally { + accepted.release(); + const mutex = locks.get(key); + if (mutex && !mutex.isLocked()) locks.delete(key); + } + }, + }); +} diff --git a/services/collaboration/src/test/crash-server.ts b/services/collaboration/src/test/crash-server.ts new file mode 100644 index 000000000..50b7e191d --- /dev/null +++ b/services/collaboration/src/test/crash-server.ts @@ -0,0 +1,123 @@ +import { open, readFile } from "node:fs/promises"; +import { z } from "zod"; +import { + createCollaborationServer, + type DurableUpdate, + type DurableUpdateStore, +} from "../server"; + +const storedUpdate = z.object({ + authorId: z.string(), + bytes: z.string(), + documentId: z.string(), + epoch: z.number().int(), + mutationId: z.string(), + sequence: z.number().int(), + syncType: z.number().int(), +}); + +function isMissingFile(error: unknown) { + return error instanceof Error && "code" in error && error.code === "ENOENT"; +} + +class FileUpdateStore implements DurableUpdateStore { + constructor( + private readonly path: string, + private readonly pauseAfterAppend: boolean, + ) {} + + private async read(): Promise { + let contents: string; + try { + contents = await readFile(this.path, "utf8"); + } catch (error) { + if (isMissingFile(error)) return []; + throw error; + } + + return contents + .split("\n") + .filter(Boolean) + .map((line) => storedUpdate.parse(JSON.parse(line))) + .map((update) => ({ + ...update, + bytes: Uint8Array.from(Buffer.from(update.bytes, "base64")), + })); + } + + async load(documentId: string, epoch: number) { + return (await this.read()).filter( + (update) => + update.documentId === documentId && update.epoch === epoch, + ); + } + + async find(documentId: string, epoch: number, mutationId: string) { + return (await this.read()).find( + (update) => + update.documentId === documentId && + update.epoch === epoch && + update.mutationId === mutationId, + ); + } + + async append(update: Omit) { + const existing = await this.find( + update.documentId, + update.epoch, + update.mutationId, + ); + if (existing) return existing; + + const current = await this.load(update.documentId, update.epoch); + const durable = { + ...update, + sequence: Math.max(0, ...current.map(({ sequence }) => sequence)) + 1, + }; + const file = await open(this.path, "a"); + try { + await file.appendFile( + `${JSON.stringify({ + ...durable, + bytes: Buffer.from(durable.bytes).toString("base64"), + })}\n`, + ); + await file.sync(); + } finally { + await file.close(); + } + + process.send?.({ + kind: "persisted", + mutationId: durable.mutationId, + sequence: durable.sequence, + }); + if (this.pauseAfterAppend) { + await new Promise(() => undefined); + } + return durable; + } +} + +const storePath = process.env.COLLAB_STORE_PATH; +if (!storePath) throw new Error("COLLAB_STORE_PATH is required"); + +const store = new FileUpdateStore( + storePath, + process.env.COLLAB_PAUSE_AFTER_APPEND === "true", +); +const server = createCollaborationServer({ + authenticate: async ({ documentName, token }) => { + const separator = documentName.lastIndexOf("@"); + const documentId = documentName.slice(0, separator); + const epoch = Number(documentName.slice(separator + 1)); + if (!documentId || !Number.isInteger(epoch) || epoch < 0) { + throw new Error("Invalid document identity"); + } + return { authorId: token, documentId, epoch }; + }, + store, +}); + +await server.listen(); +process.send?.({ kind: "ready", port: server.address.port }); diff --git a/services/collaboration/src/test/document.ts b/services/collaboration/src/test/document.ts new file mode 100644 index 000000000..b10d9ad26 --- /dev/null +++ b/services/collaboration/src/test/document.ts @@ -0,0 +1,25 @@ +import * as Y from "yjs"; + +export function insertParagraph(document: Y.Doc, text: string) { + const paragraph = new Y.XmlElement("paragraph"); + const content = new Y.XmlText(); + content.insert(0, text); + paragraph.insert(0, [content]); + document.getXmlFragment("content").insert(0, [paragraph]); +} + +export function appendText(document: Y.Doc, text: string) { + const paragraph = document.getXmlFragment("content").get(0); + if (!(paragraph instanceof Y.XmlElement)) { + throw new Error("Expected a paragraph"); + } + const content = paragraph.get(0); + if (!(content instanceof Y.XmlText)) { + throw new Error("Expected paragraph text"); + } + content.insert(content.length, text); +} + +export function documentText(document: Y.Doc) { + return document.getXmlFragment("content").toString(); +} diff --git a/services/collaboration/vitest.config.ts b/services/collaboration/vitest.config.ts new file mode 100644 index 000000000..dc312f018 --- /dev/null +++ b/services/collaboration/vitest.config.ts @@ -0,0 +1,10 @@ +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + root: fileURLToPath(new URL(".", import.meta.url)), + test: { + environment: "node", + include: ["src/**/*.{test,spec}.ts"], + }, +}); diff --git a/src/lib/workspace/editor-schema.ts b/src/lib/workspace/editor-schema.ts new file mode 100644 index 000000000..13108c9af --- /dev/null +++ b/src/lib/workspace/editor-schema.ts @@ -0,0 +1,19 @@ +import { Editor } from "@tiptap/core"; +import Collaboration from "@tiptap/extension-collaboration"; +import type * as Y from "yjs"; +import { + WORKSPACE_CONTENT_FIELD, + workspaceSchemaExtensions, +} from "./schema"; + +export function createWorkspaceEditor(document: Y.Doc): Editor { + return new Editor({ + extensions: [ + ...workspaceSchemaExtensions(), + Collaboration.configure({ + document, + field: WORKSPACE_CONTENT_FIELD, + }), + ], + }); +} diff --git a/src/lib/workspace/schema.ts b/src/lib/workspace/schema.ts new file mode 100644 index 000000000..0ff27a41b --- /dev/null +++ b/src/lib/workspace/schema.ts @@ -0,0 +1,26 @@ +import { getSchema } from "@tiptap/core"; +import { TableKit } from "@tiptap/extension-table"; +import StarterKit from "@tiptap/starter-kit"; +import { yXmlFragmentToProsemirrorJSON } from "@tiptap/y-tiptap"; +import type * as Y from "yjs"; + +export const WORKSPACE_CONTENT_FIELD = "content"; + +export function workspaceSchemaExtensions() { + return [StarterKit.configure({ undoRedo: false }), TableKit]; +} + +const workspaceSchema = getSchema(workspaceSchemaExtensions()); + +export function validateWorkspaceDocument(document: Y.Doc) { + for (const name of document.share.keys()) { + if (name !== WORKSPACE_CONTENT_FIELD) { + throw new Error(`Unexpected shared type: ${name}`); + } + } + + const json = yXmlFragmentToProsemirrorJSON( + document.getXmlFragment(WORKSPACE_CONTENT_FIELD), + ); + workspaceSchema.nodeFromJSON(json).check(); +} diff --git a/src/test/workspace/collaboration.test.ts b/src/test/workspace/collaboration.test.ts new file mode 100644 index 000000000..7186c5c87 --- /dev/null +++ b/src/test/workspace/collaboration.test.ts @@ -0,0 +1,99 @@ +import { afterEach, describe, expect, it } from "vitest"; +import * as Y from "yjs"; +import { createWorkspaceEditor } from "@/lib/workspace/editor-schema"; + +function connectDocuments(left: Y.Doc, right: Y.Doc) { + const leftOrigin = Symbol("left"); + const rightOrigin = Symbol("right"); + const leftUpdates: Uint8Array[] = []; + const rightUpdates: Uint8Array[] = []; + + left.on("update", (update, origin) => { + if (origin !== rightOrigin) leftUpdates.push(update); + }); + right.on("update", (update, origin) => { + if (origin !== leftOrigin) rightUpdates.push(update); + }); + + return () => { + if (leftUpdates.length) { + Y.applyUpdate(right, Y.mergeUpdates(leftUpdates.splice(0)), leftOrigin); + } + if (rightUpdates.length) { + Y.applyUpdate(left, Y.mergeUpdates(rightUpdates.splice(0)), rightOrigin); + } + }; +} + +describe("workspace editor collaboration", () => { + const cleanup: Array<() => void> = []; + + afterEach(() => { + cleanup.splice(0).forEach((dispose) => dispose()); + }); + + it("converges structured paragraph, list, and link edits after reconnect", () => { + const leftDocument = new Y.Doc(); + const rightDocument = new Y.Doc(); + const left = createWorkspaceEditor(leftDocument); + const right = createWorkspaceEditor(rightDocument); + const exchange = connectDocuments(leftDocument, rightDocument); + cleanup.push( + () => left.destroy(), + () => right.destroy(), + () => leftDocument.destroy(), + () => rightDocument.destroy(), + ); + + left.commands.setContent("

Opening claim

"); + exchange(); + + left.commands.insertContentAt(1, "For "); + right.commands.insertContentAt(1, "Against "); + exchange(); + + left.commands.selectAll(); + expect(left.commands.toggleBulletList()).toBe(true); + exchange(); + + right.commands.selectAll(); + expect(right.commands.setLink({ href: "https://example.com/evidence" })).toBe( + true, + ); + exchange(); + + expect(left.getJSON()).toEqual(right.getJSON()); + expect(left.getJSON().content?.[0]?.type).toBe("bulletList"); + expect(JSON.stringify(left.getJSON())).toContain( + "https://example.com/evidence", + ); + }); + + it("undoes only the current editor's last change", () => { + const leftDocument = new Y.Doc(); + const rightDocument = new Y.Doc(); + const left = createWorkspaceEditor(leftDocument); + const right = createWorkspaceEditor(rightDocument); + const exchange = connectDocuments(leftDocument, rightDocument); + cleanup.push( + () => left.destroy(), + () => right.destroy(), + () => leftDocument.destroy(), + () => rightDocument.destroy(), + ); + + left.commands.setContent("

Base

"); + exchange(); + left.commands.insertContentAt(5, " Alice"); + exchange(); + right.commands.insertContentAt(11, " Bob"); + exchange(); + + expect(left.commands.undo()).toBe(true); + exchange(); + + expect(left.getText()).toContain("Bob"); + expect(left.getText()).not.toContain("Alice"); + expect(left.getJSON()).toEqual(right.getJSON()); + }); +}); diff --git a/tsconfig.app.json b/tsconfig.app.json index 0e3745d94..9af2cd15d 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -33,6 +33,7 @@ } }, "include": [ - "src" + "src", + "services/collaboration/src" ] -} \ No newline at end of file +} From 4e34268769ffe68a7e379453384834f86ae429b0 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 20 Sep 2026 16:10:56 +0000 Subject: [PATCH 2/3] ci(workspace): run collaboration service tests --- .github/workflows/deploy.yml | 3 +++ AGENTS.md | 1 + docs/development.md | 10 ++++++++++ docs/testing.md | 15 ++++++++++++--- package.json | 2 +- 5 files changed, 27 insertions(+), 4 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index d4718be56..c0f5b6f35 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -48,6 +48,9 @@ jobs: - name: Test run: npm test -- --coverage + - name: Collaboration service tests + run: npm run test:collaboration + - name: Edge function tests run: npm run test:edge diff --git a/AGENTS.md b/AGENTS.md index 901429906..47588b50d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,7 @@ npm run docs:check npm run lint npm run typecheck npm test -- --coverage +npm run test:collaboration npm run test:edge npm run build CI=1 PORT=18080 PLAYWRIGHT_BASE_URL=http://127.0.0.1:18080 npm run test:e2e diff --git a/docs/development.md b/docs/development.md index 23389f2b9..f5b2997bb 100644 --- a/docs/development.md +++ b/docs/development.md @@ -77,6 +77,16 @@ Follow existing functional React, Zustand, Zod, and shadcn/Radix patterns. Reuse Run the closest unit or component test while changing behavior. Run lint, type checking, and build before handing work off. +## Collaboration service spike + +The Phase 0 service evidence lives under `services/collaboration/`. Run: + +```bash +npm run test:collaboration +``` + +The spike is not a deployed service. Its in-memory writer lock is single-process evidence; the database-backed service and advisory-lock lifecycle belong to P2.1. + ## Edge Function workflow Edge Functions live under [`supabase/functions/`](../supabase/functions/). Shared server code belongs in `_shared` only when at least two functions use the same boundary or policy. diff --git a/docs/testing.md b/docs/testing.md index cd099f21b..a826a766e 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -48,6 +48,14 @@ npx vitest run src/test/security/url-validation.test.ts For a bug, watch the reproduction fail for the expected reason before implementing the fix. +## Collaboration service tests + +```bash +npm run test:collaboration +``` + +This runs the isolated Node service spike with real Hocuspocus providers and Yjs documents. It covers durable acknowledgement ordering, reconnect sync, duplicate delivery, schema and size rejection, writer serialization, stale epochs, and process-kill recovery. It does not prove PostgreSQL advisory locks or a deployed collaboration service. + ## Edge Function tests ```bash @@ -91,9 +99,10 @@ The gate runs, in order: 2. lint 3. type checking 4. selected-scope coverage -5. native Edge Function tests -6. production build -7. desktop and mobile Chromium +5. collaboration service tests +6. native Edge Function tests +7. production build +8. desktop and mobile Chromium It does not start local Supabase, run pgTAP, run WebKit, or use protected staging. diff --git a/package.json b/package.json index 00ebb22be..0153e6276 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "test:e2e": "playwright test --project=desktop-chromium --project=mobile-chromium", "test:e2e:webkit": "playwright test --project=webkit", "test:e2e:staging": "playwright test --project=staging", - "verify": "npm run docs:check && npm run lint && npm run typecheck && npm test -- --coverage && npm run test:edge && npm run build && CI=1 PORT=18080 PLAYWRIGHT_BASE_URL=http://127.0.0.1:18080 npm run test:e2e" + "verify": "npm run docs:check && npm run lint && npm run typecheck && npm test -- --coverage && npm run test:collaboration && npm run test:edge && npm run build && CI=1 PORT=18080 PLAYWRIGHT_BASE_URL=http://127.0.0.1:18080 npm run test:e2e" }, "dependencies": { "@capacitor/app": "^8.1.0", From 18ccfed79f6491adfbcb08cdbbfb09d3aced4ac1 Mon Sep 17 00:00:00 2001 From: Adam Date: Sun, 20 Sep 2026 16:35:23 +0000 Subject: [PATCH 3/3] test(workspace): prove collaboration review cases --- services/collaboration/src/crash.test.ts | 10 +- services/collaboration/src/server.test.ts | 105 +++++++++---- src/test/workspace/collaboration.test.ts | 176 ++++++++++++++-------- 3 files changed, 198 insertions(+), 93 deletions(-) diff --git a/services/collaboration/src/crash.test.ts b/services/collaboration/src/crash.test.ts index a9703bdea..a4df40901 100644 --- a/services/collaboration/src/crash.test.ts +++ b/services/collaboration/src/crash.test.ts @@ -10,7 +10,7 @@ import { HocuspocusProvider } from "@hocuspocus/provider"; import { afterEach, describe, expect, it, vi } from "vitest"; import * as Y from "yjs"; import { z } from "zod"; -import { documentText, insertParagraph } from "./test/document"; +import { appendText, documentText, insertParagraph } from "./test/document"; const childMessage = z.discriminatedUnion("kind", [ z.object({ kind: z.literal("ready"), port: z.number().int().positive() }), @@ -128,6 +128,13 @@ describe("collaboration crash recovery", () => { ); await vi.waitFor(() => expect(secondAcks).toHaveLength(1)); expect(JSON.parse(secondAcks[0]).sequence).toBe(persisted.sequence); + + appendText(document, " After acknowledgement"); + await vi.waitFor(() => expect(secondAcks).toHaveLength(2)); + expect(JSON.parse(secondAcks[1])).toMatchObject({ + kind: "durable-ack", + sequence: persisted.sequence + 1, + }); await kill(second.child); reconnected.destroy(); @@ -147,6 +154,7 @@ describe("collaboration crash recovery", () => { await vi.waitFor(() => { expect(fresh.isSynced).toBe(true); expect(documentText(freshDocument)).toContain("Survives restart"); + expect(documentText(freshDocument)).toContain("After acknowledgement"); }); }, 15_000, diff --git a/services/collaboration/src/server.test.ts b/services/collaboration/src/server.test.ts index fb84095be..5d87ae286 100644 --- a/services/collaboration/src/server.test.ts +++ b/services/collaboration/src/server.test.ts @@ -58,8 +58,11 @@ class BlockingStore implements DurableUpdateStore { class MemoryStore implements DurableUpdateStore { readonly updates: DurableUpdate[] = []; - async load() { - return this.updates; + async load(documentId: string, epoch: number) { + return this.updates.filter( + (update) => + update.documentId === documentId && update.epoch === epoch, + ); } async find(documentId: string, epoch: number, mutationId: string) { @@ -80,7 +83,17 @@ class MemoryStore implements DurableUpdateStore { ); if (existing) return existing; - const durable = { ...update, sequence: this.updates.length + 1 }; + const sequence = Math.max( + 0, + ...this.updates + .filter( + (existingUpdate) => + existingUpdate.documentId === update.documentId && + existingUpdate.epoch === update.epoch, + ) + .map(({ sequence }) => sequence), + ); + const durable = { ...update, sequence: sequence + 1 }; this.updates.push(durable); return durable; } @@ -185,7 +198,7 @@ describe("collaboration durability", () => { expect(store.updates[1].syncType).toBe(2); }); - it("returns the original sequence when the same update is delivered twice", async () => { + it("deduplicates identical update bytes within each document and epoch scope", async () => { const store = new MemoryStore(); const server = createCollaborationServer({ authenticate: async ({ documentName, token }) => ({ @@ -198,35 +211,61 @@ describe("collaboration durability", () => { await server.listen(); cleanup.push(() => server.destroy()); - const document = new Y.Doc(); - const acknowledgements: string[] = []; - let updateMessage: Uint8Array | undefined; - const provider = new HocuspocusProvider({ - document, - name: "speech-3@1", - onOutgoingMessage: ({ message }) => { - if (message.description === "A document update") { - updateMessage = message.toUint8Array(); - } - }, - onStateless: ({ payload }) => acknowledgements.push(payload), - token: "author", - url: `ws://127.0.0.1:${server.address.port}`, - }); - cleanup.push(() => provider.destroy()); - - await vi.waitFor(() => expect(provider.isSynced).toBe(true)); - insertParagraph(document, "One append"); - await vi.waitFor(() => expect(acknowledgements).toHaveLength(1)); - if (!updateMessage) throw new Error("Expected an outbound update message"); - - provider.configuration.websocketProvider.send(updateMessage); - - await vi.waitFor(() => expect(acknowledgements).toHaveLength(2)); - expect(store.updates).toHaveLength(1); - expect(JSON.parse(acknowledgements[1]).sequence).toBe( - JSON.parse(acknowledgements[0]).sequence, - ); + for (const [scopeIndex, name] of [ + "speech-a@1", + "speech-b@1", + "speech-a@2", + ].entries()) { + const document = new Y.Doc(); + const acknowledgements: string[] = []; + let updateMessage: Uint8Array | undefined; + const provider = new HocuspocusProvider({ + document, + name, + onOutgoingMessage: ({ message }) => { + if (message.description === "A document update") { + updateMessage = message.toUint8Array(); + } + }, + onStateless: ({ payload }) => acknowledgements.push(payload), + token: "author", + url: `ws://127.0.0.1:${server.address.port}`, + }); + cleanup.push(() => provider.destroy(), () => document.destroy()); + + await vi.waitFor(() => expect(provider.isSynced).toBe(true)); + document.clientID = 1_234_567; + insertParagraph(document, "Identical update"); + await vi.waitFor(() => expect(updateMessage).toBeDefined()); + await vi.waitFor(() => + expect(store.updates).toHaveLength(scopeIndex + 1), + ); + await vi.waitFor(() => expect(acknowledgements).toHaveLength(1)); + if (!updateMessage) { + throw new Error("Expected an outbound update message"); + } + expect(store.updates[scopeIndex].bytes).toEqual(store.updates[0].bytes); + + provider.configuration.websocketProvider.send(updateMessage); + await vi.waitFor(() => expect(acknowledgements).toHaveLength(2)); + + const original = JSON.parse(acknowledgements[0]); + const retry = JSON.parse(acknowledgements[1]); + expect(original.sequence).toBe(1); + expect(retry.sequence).toBe(original.sequence); + } + + expect( + store.updates.map(({ documentId, epoch, sequence }) => ({ + documentId, + epoch, + sequence, + })), + ).toEqual([ + { documentId: "speech-a", epoch: 1, sequence: 1 }, + { documentId: "speech-b", epoch: 1, sequence: 1 }, + { documentId: "speech-a", epoch: 2, sequence: 1 }, + ]); }); it("does not apply, broadcast, or acknowledge an update when append fails", async () => { diff --git a/src/test/workspace/collaboration.test.ts b/src/test/workspace/collaboration.test.ts index 7186c5c87..e4b37485f 100644 --- a/src/test/workspace/collaboration.test.ts +++ b/src/test/workspace/collaboration.test.ts @@ -1,99 +1,157 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { HocuspocusProvider } from "@hocuspocus/provider"; +import { afterEach, describe, expect, it, vi } from "vitest"; import * as Y from "yjs"; import { createWorkspaceEditor } from "@/lib/workspace/editor-schema"; +import { + createCollaborationServer, + type DurableUpdate, + type DurableUpdateStore, +} from "../../../services/collaboration/src/server"; -function connectDocuments(left: Y.Doc, right: Y.Doc) { - const leftOrigin = Symbol("left"); - const rightOrigin = Symbol("right"); - const leftUpdates: Uint8Array[] = []; - const rightUpdates: Uint8Array[] = []; +class MemoryStore implements DurableUpdateStore { + readonly updates: DurableUpdate[] = []; - left.on("update", (update, origin) => { - if (origin !== rightOrigin) leftUpdates.push(update); - }); - right.on("update", (update, origin) => { - if (origin !== leftOrigin) rightUpdates.push(update); - }); + async load(documentId: string, epoch: number) { + return this.updates.filter( + (update) => + update.documentId === documentId && update.epoch === epoch, + ); + } + + async find(documentId: string, epoch: number, mutationId: string) { + return this.updates.find( + (update) => + update.documentId === documentId && + update.epoch === epoch && + update.mutationId === mutationId, + ); + } + + async append(update: Omit) { + const existing = await this.find( + update.documentId, + update.epoch, + update.mutationId, + ); + if (existing) return existing; - return () => { - if (leftUpdates.length) { - Y.applyUpdate(right, Y.mergeUpdates(leftUpdates.splice(0)), leftOrigin); - } - if (rightUpdates.length) { - Y.applyUpdate(left, Y.mergeUpdates(rightUpdates.splice(0)), rightOrigin); - } - }; + const durable = { ...update, sequence: this.updates.length + 1 }; + this.updates.push(durable); + return durable; + } } describe("workspace editor collaboration", () => { - const cleanup: Array<() => void> = []; + const cleanup: Array<() => void | Promise> = []; - afterEach(() => { - cleanup.splice(0).forEach((dispose) => dispose()); + afterEach(async () => { + await Promise.all(cleanup.splice(0).map((dispose) => dispose())); }); - it("converges structured paragraph, list, and link edits after reconnect", () => { + async function createSession() { + const server = (() => { + const deno = Object.getOwnPropertyDescriptor(globalThis, "Deno"); + try { + Reflect.deleteProperty(globalThis, "Deno"); + return createCollaborationServer({ + authenticate: async ({ documentName, token }) => ({ + authorId: token, + documentId: documentName.split("@")[0], + epoch: Number(documentName.split("@")[1]), + }), + store: new MemoryStore(), + }); + } finally { + if (deno) Object.defineProperty(globalThis, "Deno", deno); + } + })(); + await server.listen(); + const leftDocument = new Y.Doc(); const rightDocument = new Y.Doc(); const left = createWorkspaceEditor(leftDocument); const right = createWorkspaceEditor(rightDocument); - const exchange = connectDocuments(leftDocument, rightDocument); + const url = `ws://127.0.0.1:${server.address.port}`; + const leftProvider = new HocuspocusProvider({ + document: leftDocument, + name: "structured-editor@1", + token: "left", + url, + }); + const rightProvider = new HocuspocusProvider({ + document: rightDocument, + name: "structured-editor@1", + token: "right", + url, + }); cleanup.push( + () => leftProvider.destroy(), + () => rightProvider.destroy(), () => left.destroy(), () => right.destroy(), () => leftDocument.destroy(), () => rightDocument.destroy(), + () => server.destroy(), ); - left.commands.setContent("

Opening claim

"); - exchange(); + await vi.waitFor(() => { + expect(leftProvider.isSynced).toBe(true); + expect(rightProvider.isSynced).toBe(true); + }); - left.commands.insertContentAt(1, "For "); - right.commands.insertContentAt(1, "Against "); - exchange(); + return { left, leftProvider, right, rightProvider }; + } - left.commands.selectAll(); - expect(left.commands.toggleBulletList()).toBe(true); - exchange(); + it("converges structured paragraph, list, and link edits after reconnect", async () => { + const { left, leftProvider, right, rightProvider } = await createSession(); - right.commands.selectAll(); - expect(right.commands.setLink({ href: "https://example.com/evidence" })).toBe( - true, - ); - exchange(); + left.commands.setContent("

Opening claim

Evidence

"); + await vi.waitFor(() => expect(right.getJSON()).toEqual(left.getJSON())); - expect(left.getJSON()).toEqual(right.getJSON()); - expect(left.getJSON().content?.[0]?.type).toBe("bulletList"); - expect(JSON.stringify(left.getJSON())).toContain( - "https://example.com/evidence", + rightProvider.disconnect(); + await vi.waitFor(() => expect(rightProvider.isSynced).toBe(false)); + + left.commands.insertContentAt( + left.state.doc.content.size, + "

Concurrent rebuttal

", ); + right.commands.insertContentAt(1, "Offline "); + right.commands.selectAll(); + expect(right.commands.toggleBulletList()).toBe(true); + right.commands.selectAll(); + expect( + right.commands.setLink({ href: "https://example.com/evidence" }), + ).toBe(true); + + rightProvider.connect(); + await vi.waitFor(() => { + expect(leftProvider.isSynced).toBe(true); + expect(rightProvider.isSynced).toBe(true); + expect(right.getJSON()).toEqual(left.getJSON()); + }); + + const json = JSON.stringify(left.getJSON()); + expect(json).toContain('"type":"paragraph"'); + expect(json).toContain('"type":"bulletList"'); + expect(json).toContain('"type":"link"'); + expect(json).toContain("Offline Opening claim"); + expect(json).toContain("Concurrent rebuttal"); }); - it("undoes only the current editor's last change", () => { - const leftDocument = new Y.Doc(); - const rightDocument = new Y.Doc(); - const left = createWorkspaceEditor(leftDocument); - const right = createWorkspaceEditor(rightDocument); - const exchange = connectDocuments(leftDocument, rightDocument); - cleanup.push( - () => left.destroy(), - () => right.destroy(), - () => leftDocument.destroy(), - () => rightDocument.destroy(), - ); + it("undoes only the current editor's last change through the provider", async () => { + const { left, right } = await createSession(); left.commands.setContent("

Base

"); - exchange(); + await vi.waitFor(() => expect(right.getJSON()).toEqual(left.getJSON())); left.commands.insertContentAt(5, " Alice"); - exchange(); + await vi.waitFor(() => expect(right.getText()).toContain("Alice")); right.commands.insertContentAt(11, " Bob"); - exchange(); + await vi.waitFor(() => expect(left.getText()).toContain("Bob")); expect(left.commands.undo()).toBe(true); - exchange(); + await vi.waitFor(() => expect(right.getJSON()).toEqual(left.getJSON())); expect(left.getText()).toContain("Bob"); expect(left.getText()).not.toContain("Alice"); - expect(left.getJSON()).toEqual(right.getJSON()); }); });