From f57e624acc808b0e4be8892b1226ccd7a118b9c4 Mon Sep 17 00:00:00 2001 From: Andrew Chou Date: Mon, 28 Sep 2026 15:47:50 -0400 Subject: [PATCH 01/33] update @comapeo/core --- package-lock.json | 428 +++++++++++++++++++++++++++++++++++++++++++++- package.json | 4 +- 2 files changed, 424 insertions(+), 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index d788c69..00ae84c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "type-fest": "^5.9.0" }, "devDependencies": { - "@comapeo/core": "7.5.1", + "@comapeo/core": "7.6.0", "@comapeo/ipc": "9.0.1", "@eslint/js": "10.0.1", "@ianvs/prettier-plugin-sort-imports": "4.7.1", @@ -55,7 +55,7 @@ "vitest": "5.0.0" }, "peerDependencies": { - "@comapeo/core": "^7.2.0", + "@comapeo/core": "^7.6.0", "@comapeo/ipc": "^9.0.0", "@tanstack/react-query": "^5", "react": "^18 || ^19" @@ -471,9 +471,9 @@ } }, "node_modules/@comapeo/core": { - "version": "7.5.1", - "resolved": "https://registry.npmjs.org/@comapeo/core/-/core-7.5.1.tgz", - "integrity": "sha512-LXscXFLxt/dPerDX6K94Hy5TlXenbfnsdFzi50uFTBOHJqko9UbBDgNVqjB4xSsxlGUsH3ZhkQ5qi+Ymd8EFww==", + "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@comapeo/core/-/core-7.6.0.tgz", + "integrity": "sha512-3MPdyzNWS3YqmUs35l80JfcQBFUycKUCG9kArdA4wovs4XE2sh2iiWOE4RFns7mOtLABR9W5zjuIxpifb79Pww==", "dev": true, "license": "MIT", "dependencies": { @@ -484,6 +484,7 @@ "@fastify/error": "^3.4.1", "@fastify/type-provider-typebox": "^4.1.0", "@hyperswarm/secret-stream": "^6.6.3", + "@mapeo/crypto": "^1.1.0", "@mapeo/sqlite-indexer": "^1.0.2", "@sinclair/typebox": "^0.33.17", "@sindresorhus/merge-streams": "^4.0.0", @@ -492,10 +493,11 @@ "bcp-47-normalize": "^2.3.0", "better-sqlite3": "^11.10.0", "big-sparse-array": "^1.0.3", - "bogon": "^1.1.0", + "bogon": "^1.2.0", "comapeocat": "^1.1.0", "compact-encoding": "^2.12.0", "corestore": "6.8.4", + "crockford-base32": "^2.1.0", "custom-error-creator": "^1.1.1", "debug": "^4.3.4", "dot-prop": "^9.0.0", @@ -508,6 +510,7 @@ "hypercore": "10.19.0", "hypercore-crypto": "3.4.2", "hyperdrive": "11.5.3", + "hyperswarm": "^4.17.0", "iso-3166": "^4.3.0", "json-stable-stringify": "^1.1.1", "magic-bytes.js": "^1.10.0", @@ -3641,6 +3644,16 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/adaptive-timeout": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/adaptive-timeout/-/adaptive-timeout-1.0.1.tgz", + "integrity": "sha512-+seGiBtHqbnyZ0Quq/ZGxxwP66153WBABawa07/QeyuPFzbduPBjiGQAyCiriiLDzt3dkbMBskSlfqtRwblK8Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "xache": "^1.2.1" + } + }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -4244,6 +4257,16 @@ "file-uri-to-path": "1.0.0" } }, + "node_modules/bits-to-bytes": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/bits-to-bytes/-/bits-to-bytes-1.3.0.tgz", + "integrity": "sha512-OJoHTpFXS9bXHBCekGTByf3MqM8CGblBDIduKQeeVVeiU9dDWywSSirXIBYGgg3d1zbVuvnMa1vD4r6PA0kOKg==", + "dev": true, + "license": "ISC", + "dependencies": { + "b4a": "^1.5.0" + } + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -4296,6 +4319,66 @@ "node": ">= 6" } }, + "node_modules/blind-relay": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/blind-relay/-/blind-relay-1.6.1.tgz", + "integrity": "sha512-38YmKCl/m9NcTkwueBbcGIt9Zx3IT/Q9Nsluu6C5Gur6PqfE0zWPhPwCzia1hri/g0ICYxrqkgLtEaoWD5WeSQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.6.4", + "bare-events": "^2.2.0", + "bits-to-bytes": "^1.3.0", + "compact-encoding": "^3.0.0", + "compact-encoding-bitfield": "^1.0.0", + "protomux": "^3.5.1", + "sodium-universal": "^5.0.0", + "streamx": "^2.15.1" + } + }, + "node_modules/blind-relay/node_modules/compact-encoding": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/compact-encoding/-/compact-encoding-3.5.2.tgz", + "integrity": "sha512-rgGcWdfxyuLwJHMUXI0OrYGpJe3VN4RZyovbxWVlxPYIsUZutOTfP3shmDyDi/iaybwcxMsTJDDuxQa4xRG0RQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.3.0" + } + }, + "node_modules/blind-relay/node_modules/sodium-native": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/sodium-native/-/sodium-native-5.1.0.tgz", + "integrity": "sha512-3RxgyWyJlhTsABPnJVpCI5CoTDANZTqqFrEPqr+kjfnRaBihpVtMUE3yTF40ukdoB1APXeoBNKF3MzZAIHg39g==", + "dev": true, + "license": "MIT", + "dependencies": { + "bare-assert": "^1.2.0", + "require-addon": "^1.1.0", + "which-runtime": "^1.2.1" + }, + "engines": { + "bare": ">=1.16.0" + } + }, + "node_modules/blind-relay/node_modules/sodium-universal": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/sodium-universal/-/sodium-universal-5.0.1.tgz", + "integrity": "sha512-rv+aH+tnKB5H0MAc2UadHShLMslpJsc4wjdnHRtiSIEYpOetCgu8MS4ExQRia+GL/MK3uuCyZPeEsi+J3h+Q+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "sodium-native": "^5.0.1" + }, + "peerDependencies": { + "sodium-javascript": "~0.8.0" + }, + "peerDependenciesMeta": { + "sodium-javascript": { + "optional": true + } + } + }, "node_modules/bogon": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/bogon/-/bogon-1.3.0.tgz", @@ -4705,6 +4788,26 @@ "b4a": "^1.3.0" } }, + "node_modules/compact-encoding-bitfield": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/compact-encoding-bitfield/-/compact-encoding-bitfield-1.1.0.tgz", + "integrity": "sha512-F6wliSKHi50BsBStmnsRJAzJgYSIYzdfSe3M0oHj4uQ1RcctJK/NQVD7wF51bj/DBMne2i5gUxrGUFkNZQns5w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "compact-encoding": "^3.0.0" + } + }, + "node_modules/compact-encoding-bitfield/node_modules/compact-encoding": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/compact-encoding/-/compact-encoding-3.5.2.tgz", + "integrity": "sha512-rgGcWdfxyuLwJHMUXI0OrYGpJe3VN4RZyovbxWVlxPYIsUZutOTfP3shmDyDi/iaybwcxMsTJDDuxQa4xRG0RQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.3.0" + } + }, "node_modules/compact-encoding-net": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/compact-encoding-net/-/compact-encoding-net-1.3.0.tgz", @@ -4869,6 +4972,23 @@ "node": ">= 14" } }, + "node_modules/crockford-base32": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/crockford-base32/-/crockford-base32-2.1.0.tgz", + "integrity": "sha512-vadAEK4CkOKWaIkf0oWr3XMaYpXCyAwwE8N6rCUye6/QsVXn7cuJQXp2Yuy9BKgsCoXlkixTu+o3PXUoW1s5lw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "^14.17.29" + } + }, + "node_modules/crockford-base32/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -5225,6 +5345,70 @@ "node": ">=8" } }, + "node_modules/dht-rpc": { + "version": "6.27.0", + "resolved": "https://registry.npmjs.org/dht-rpc/-/dht-rpc-6.27.0.tgz", + "integrity": "sha512-NsfgRlFDQnkA2+H+hJXMPLmBOn/TSIIc0cRMV8q42M4xc1uAXWtpvIIQsONF5tYcYC6px0bslrUGideN1h4S4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "adaptive-timeout": "^1.0.1", + "b4a": "^1.6.1", + "bare-events": "^2.2.0", + "compact-encoding": "^3.0.0", + "compact-encoding-net": "^1.2.0", + "fast-fifo": "^1.1.0", + "kademlia-routing-table": "^1.0.1", + "nat-sampler": "^1.0.1", + "sodium-universal": "^5.0.0", + "streamx": "^2.13.2", + "time-ordered-set": "^2.0.0", + "udx-native": "^1.5.3" + } + }, + "node_modules/dht-rpc/node_modules/compact-encoding": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/compact-encoding/-/compact-encoding-3.5.2.tgz", + "integrity": "sha512-rgGcWdfxyuLwJHMUXI0OrYGpJe3VN4RZyovbxWVlxPYIsUZutOTfP3shmDyDi/iaybwcxMsTJDDuxQa4xRG0RQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.3.0" + } + }, + "node_modules/dht-rpc/node_modules/sodium-native": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/sodium-native/-/sodium-native-5.1.0.tgz", + "integrity": "sha512-3RxgyWyJlhTsABPnJVpCI5CoTDANZTqqFrEPqr+kjfnRaBihpVtMUE3yTF40ukdoB1APXeoBNKF3MzZAIHg39g==", + "dev": true, + "license": "MIT", + "dependencies": { + "bare-assert": "^1.2.0", + "require-addon": "^1.1.0", + "which-runtime": "^1.2.1" + }, + "engines": { + "bare": ">=1.16.0" + } + }, + "node_modules/dht-rpc/node_modules/sodium-universal": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/sodium-universal/-/sodium-universal-5.0.1.tgz", + "integrity": "sha512-rv+aH+tnKB5H0MAc2UadHShLMslpJsc4wjdnHRtiSIEYpOetCgu8MS4ExQRia+GL/MK3uuCyZPeEsi+J3h+Q+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "sodium-native": "^5.0.1" + }, + "peerDependencies": { + "sodium-javascript": "~0.8.0" + }, + "peerDependenciesMeta": { + "sodium-javascript": { + "optional": true + } + } + }, "node_modules/diff": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", @@ -6330,6 +6514,23 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/generate-object-property": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/generate-object-property/-/generate-object-property-2.0.0.tgz", + "integrity": "sha512-KwuURPyqn2Mz8DdV29pJwQu0Y7tcsbkULr82eeOcY/ZllFK6I9Wm8dsRByIu7CKWlFi9BdW1b3mcXMp/kQBQsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-property": "^1.0.0" + } + }, + "node_modules/generate-string": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/generate-string/-/generate-string-1.0.1.tgz", + "integrity": "sha512-IfTY0dKZM43ACyGvXkbG7De7WY7MxTS5VO6Juhe8oJKpCmrYYXoqp/cJMskkpi0k9H8wuXq0H+eI898/BCqvXg==", + "dev": true, + "license": "MIT" + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -6737,6 +6938,101 @@ "z32": "^1.0.0" } }, + "node_modules/hyperdht": { + "version": "6.34.0", + "resolved": "https://registry.npmjs.org/hyperdht/-/hyperdht-6.34.0.tgz", + "integrity": "sha512-biDDqVvdznvXpS017W9xaUxLGizSZt23YItSmo69GEB4W3Cjzw/fQXHtV9vMJf770V4DqHR3GgxFjv7hBCSRCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@hyperswarm/secret-stream": "^6.6.2", + "b4a": "^1.3.1", + "bare-events": "^2.2.0", + "blind-relay": "^1.3.0", + "bogon": "^1.0.0", + "compact-encoding": "^3.0.0", + "dht-rpc": "^6.15.1", + "hypercore-crypto": "^3.3.0", + "hypercore-id-encoding": "^1.2.0", + "hyperdht-address": "^1.0.1", + "noise-curve-ed": "^2.0.0", + "noise-handshake": "^4.0.0", + "record-cache": "^1.1.1", + "safety-catch": "^1.0.1", + "signal-promise": "^1.0.3", + "sodium-universal": "^5.0.1", + "streamx": "^2.16.1", + "unslab": "^1.3.0", + "xache": "^1.1.0" + }, + "bin": { + "hyperdht": "bin.js" + } + }, + "node_modules/hyperdht-address": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/hyperdht-address/-/hyperdht-address-1.1.1.tgz", + "integrity": "sha512-Mu/+7SW2cwvHxMXswa5mOrhrS5BJyntViAuB25k8d8wNtA8eAPs4LaIq6TwN1SS/KQY02h1r+gM8cQeN/k360w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "compact-encoding": "^3.0.0", + "hyperschema": "^1.20.1" + } + }, + "node_modules/hyperdht-address/node_modules/compact-encoding": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/compact-encoding/-/compact-encoding-3.5.2.tgz", + "integrity": "sha512-rgGcWdfxyuLwJHMUXI0OrYGpJe3VN4RZyovbxWVlxPYIsUZutOTfP3shmDyDi/iaybwcxMsTJDDuxQa4xRG0RQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.3.0" + } + }, + "node_modules/hyperdht/node_modules/compact-encoding": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/compact-encoding/-/compact-encoding-3.5.2.tgz", + "integrity": "sha512-rgGcWdfxyuLwJHMUXI0OrYGpJe3VN4RZyovbxWVlxPYIsUZutOTfP3shmDyDi/iaybwcxMsTJDDuxQa4xRG0RQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.3.0" + } + }, + "node_modules/hyperdht/node_modules/sodium-native": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/sodium-native/-/sodium-native-5.1.0.tgz", + "integrity": "sha512-3RxgyWyJlhTsABPnJVpCI5CoTDANZTqqFrEPqr+kjfnRaBihpVtMUE3yTF40ukdoB1APXeoBNKF3MzZAIHg39g==", + "dev": true, + "license": "MIT", + "dependencies": { + "bare-assert": "^1.2.0", + "require-addon": "^1.1.0", + "which-runtime": "^1.2.1" + }, + "engines": { + "bare": ">=1.16.0" + } + }, + "node_modules/hyperdht/node_modules/sodium-universal": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/sodium-universal/-/sodium-universal-5.0.1.tgz", + "integrity": "sha512-rv+aH+tnKB5H0MAc2UadHShLMslpJsc4wjdnHRtiSIEYpOetCgu8MS4ExQRia+GL/MK3uuCyZPeEsi+J3h+Q+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "sodium-native": "^5.0.1" + }, + "peerDependencies": { + "sodium-javascript": "~0.8.0" + }, + "peerDependenciesMeta": { + "sodium-javascript": { + "optional": true + } + } + }, "node_modules/hyperdrive": { "version": "11.5.3", "resolved": "https://registry.npmjs.org/hyperdrive/-/hyperdrive-11.5.3.tgz", @@ -6756,6 +7052,45 @@ "unix-path-resolve": "^1.0.2" } }, + "node_modules/hyperschema": { + "version": "1.26.2", + "resolved": "https://registry.npmjs.org/hyperschema/-/hyperschema-1.26.2.tgz", + "integrity": "sha512-5Y7C5zCfH28yiK7RhuMl7VkWt24Mr0E5sp1r2ntd3Ab3+yOBdkDWau0/d/F5beC799Vd5e4xqIAu5NJLM7i//w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "bare-fs": "^4.0.1", + "compact-encoding": "^3.5.0", + "generate-object-property": "^2.0.0", + "generate-string": "^1.0.1" + } + }, + "node_modules/hyperschema/node_modules/compact-encoding": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/compact-encoding/-/compact-encoding-3.5.2.tgz", + "integrity": "sha512-rgGcWdfxyuLwJHMUXI0OrYGpJe3VN4RZyovbxWVlxPYIsUZutOTfP3shmDyDi/iaybwcxMsTJDDuxQa4xRG0RQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.3.0" + } + }, + "node_modules/hyperswarm": { + "version": "4.17.2", + "resolved": "https://registry.npmjs.org/hyperswarm/-/hyperswarm-4.17.2.tgz", + "integrity": "sha512-yKTNgHjijwrmSj5neC2Lu5JyKDvj9gZK/zkVLySP6J2M7nF+uvFMoxzRlWKKjdySfZVHZXPLfhk4xAnpqibm5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "b4a": "^1.3.1", + "bare-events": "^2.2.0", + "hyperdht": "^6.21.0", + "safety-catch": "^1.0.2", + "shuffled-priority-queue": "^2.1.0", + "streamx": "^2.22.1", + "unslab": "^1.3.0" + } + }, "node_modules/iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -7020,6 +7355,13 @@ "dev": true, "license": "MIT" }, + "node_modules/is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "dev": true, + "license": "MIT" + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -7351,6 +7693,16 @@ "node": ">=18.0.0" } }, + "node_modules/kademlia-routing-table": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/kademlia-routing-table/-/kademlia-routing-table-1.0.6.tgz", + "integrity": "sha512-Ve6jwIlUCYvUzBnXnzVRHDZCFgXURW9gmF3r7n05kZs/2rNbLHXwGdcq0qIaSwdmJCvtosgR4JensnVU65hzNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "bare-events": "^2.2.0" + } + }, "node_modules/keyv": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/keyv/-/keyv-5.6.0.tgz", @@ -8120,6 +8472,13 @@ "dev": true, "license": "MIT" }, + "node_modules/nat-sampler": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/nat-sampler/-/nat-sampler-1.0.1.tgz", + "integrity": "sha512-yQvyNN7xbqR8crTKk3U8gRgpcV1Az+vfCEijiHu9oHHsnIl8n3x+yXNHl42M6L3czGynAVoOT9TqBfS87gDdcw==", + "dev": true, + "license": "MIT" + }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", @@ -9437,6 +9796,16 @@ "node": ">= 12.13.0" } }, + "node_modules/record-cache": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/record-cache/-/record-cache-1.2.0.tgz", + "integrity": "sha512-kyy3HWCez2WrotaL3O4fTn0rsIdfRKOdQQcEJ9KpvmKmbffKVvwsloX063EgRUlpJIXHiDQFhJcTbZequ2uTZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "b4a": "^1.3.1" + } + }, "node_modules/reghex": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/reghex/-/reghex-3.0.2.tgz", @@ -9973,6 +10342,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/shuffled-priority-queue": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/shuffled-priority-queue/-/shuffled-priority-queue-2.1.0.tgz", + "integrity": "sha512-xhdh7fHyMsr0m/w2kDfRJuBFRS96b9l8ZPNWGaQ+PMvnUnZ/Eh+gJJ9NsHBd7P9k0399WYlCLzsy18EaMfyadA==", + "dev": true, + "license": "MIT", + "dependencies": { + "unordered-set": "^2.0.1" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -9993,6 +10372,13 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/signal-promise": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/signal-promise/-/signal-promise-1.0.3.tgz", + "integrity": "sha512-WBgv0UnIq2C+Aeh0/n+IRpP6967eIx9WpynTUoiW3isPpfe1zu2LJzyfXdo9Tgef8yR/sGjcMvoUXD7EYdiz+g==", + "dev": true, + "license": "MIT" + }, "node_modules/signed-varint": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/signed-varint/-/signed-varint-2.0.1.tgz", @@ -10917,6 +11303,13 @@ "node": ">=12.22" } }, + "node_modules/time-ordered-set": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/time-ordered-set/-/time-ordered-set-2.0.1.tgz", + "integrity": "sha512-VJEKmgSN2UiOLB8BpN8Sh2b9LGMHTP5OPrQRpnKjvOheOyzk0mufbjzjKTIG2gO4A+Y+vDJ+0TcLbpUmMLsg8A==", + "dev": true, + "license": "MIT" + }, "node_modules/timeout-refresh": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/timeout-refresh/-/timeout-refresh-2.0.1.tgz", @@ -11225,6 +11618,22 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/udx-native": { + "version": "1.21.3", + "resolved": "https://registry.npmjs.org/udx-native/-/udx-native-1.21.3.tgz", + "integrity": "sha512-2rUrocAjI2/0ijFqb0E3R6PH3AoH+o5ZhaL3lGm/bHCRGaqPnGhywTOMPM2ByYEXscsZsj5svhmSbY1ogxGGbg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "b4a": "^1.5.0", + "bare-events": "^2.2.0", + "require-addon": "^1.1.0", + "streamx": "^2.22.0" + }, + "engines": { + "bare": ">=1.17.4" + } + }, "node_modules/uint8array-extras": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/uint8array-extras/-/uint8array-extras-1.5.0.tgz", @@ -11273,6 +11682,13 @@ "dev": true, "license": "MIT" }, + "node_modules/unordered-set": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unordered-set/-/unordered-set-2.0.1.tgz", + "integrity": "sha512-eUmNTPzdx+q/WvOHW0bgGYLWvWHNT3PTKEQLg0MAQhc0AHASHVHoP/9YytYd4RBVariqno/mEUhVZN98CmD7bg==", + "dev": true, + "license": "MIT" + }, "node_modules/unslab": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/unslab/-/unslab-1.3.0.tgz", diff --git a/package.json b/package.json index 7b76f50..6d896d4 100644 --- a/package.json +++ b/package.json @@ -70,13 +70,13 @@ "type-fest": "^5.9.0" }, "peerDependencies": { - "@comapeo/core": "^7.2.0", + "@comapeo/core": "^7.6.0", "@comapeo/ipc": "^9.0.0", "@tanstack/react-query": "^5", "react": "^18 || ^19" }, "devDependencies": { - "@comapeo/core": "7.5.1", + "@comapeo/core": "7.6.0", "@comapeo/ipc": "9.0.1", "@eslint/js": "10.0.1", "@ianvs/prettier-plugin-sort-imports": "4.7.1", From 7fb032bbb5100e60c379a5e7ac47f82b311ec1e7 Mon Sep 17 00:00:00 2001 From: Andrew Chou Date: Mon, 28 Sep 2026 15:50:15 -0400 Subject: [PATCH 02/33] update minimum react query version uses the version that adds the mutation context (see https://github.com/TanStack/query/releases/tag/v5.89.0) --- package-lock.json | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package-lock.json b/package-lock.json index 00ae84c..ab3f146 100644 --- a/package-lock.json +++ b/package-lock.json @@ -57,7 +57,7 @@ "peerDependencies": { "@comapeo/core": "^7.6.0", "@comapeo/ipc": "^9.0.0", - "@tanstack/react-query": "^5", + "@tanstack/react-query": "^5.89.0", "react": "^18 || ^19" } }, diff --git a/package.json b/package.json index 6d896d4..16456aa 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ "peerDependencies": { "@comapeo/core": "^7.6.0", "@comapeo/ipc": "^9.0.0", - "@tanstack/react-query": "^5", + "@tanstack/react-query": "^5.89.0", "react": "^18 || ^19" }, "devDependencies": { From 86d4facc6bcfae1eb8ad2392c04067a392bdbd49 Mon Sep 17 00:00:00 2001 From: Andrew Chou Date: Mon, 28 Sep 2026 16:10:42 -0400 Subject: [PATCH 03/33] initial implementation --- docs/API.md | 67 +++++++++- src/contexts/ClientApi.ts | 30 +++-- src/hooks/invites.ts | 98 +++++++++++++++ src/hooks/projects.ts | 212 ++++++++++++++++++++++++++++++-- src/index.ts | 17 ++- src/lib/react-query.ts | 44 ++++++- src/lib/types.ts | 28 +++++ test/helpers/client-api-mock.ts | 11 +- 8 files changed, 474 insertions(+), 33 deletions(-) diff --git a/docs/API.md b/docs/API.md index f55b560..743f4d1 100644 --- a/docs/API.md +++ b/docs/API.md @@ -47,6 +47,11 @@ - [useSetAutostopDataSyncTimeout](#usesetautostopdatasynctimeout) - [useExportGeoJSON](#useexportgeojson) - [useExportZipFile](#useexportzipfile) +- [useManyInviteLinks](#usemanyinvitelinks) +- [useCreateInviteLink](#usecreateinvitelink) +- [useCancelInviteLink](#usecancelinvitelink) +- [useAcceptInviteLinkRequest](#useacceptinvitelinkrequest) +- [useDenyInviteLinkRequest](#usedenyinvitelinkrequest) - [useSingleDocByDocId](#usesingledocbydocid) - [useSingleDocByVersionId](#usesingledocbyversionid) - [useManyDocs](#usemanydocs) @@ -60,6 +65,10 @@ - [useRejectInvite](#userejectinvite) - [useSendInvite](#usesendinvite) - [useRequestCancelInvite](#userequestcancelinvite) +- [useManyJoinRequests](#usemanyjoinrequests) +- [useSingleJoinRequest](#usesinglejoinrequest) +- [useCreateJoinRequest](#usecreatejoinrequest) +- [useCancelJoinRequest](#usecanceljoinrequest) - [useMapStyleUrl](#usemapstyleurl) - [useImportCustomMapFile](#useimportcustommapfile) - [useRemoveCustomMapFile](#useremovecustommapfile) @@ -833,6 +842,36 @@ Parameters: * `opts.projectId`: Public ID of the project to apply changes to. +### useManyInviteLinks + +| Function | Type | +| ---------- | ---------- | +| `useManyInviteLinks` | `({ projectId, }: { projectId: string; }) => Pick[]>, "data" or ... 1 more ... or "isRefetching">` | + +### useCreateInviteLink + +| Function | Type | +| ---------- | ---------- | +| `useCreateInviteLink` | `() => Pick, { mutate: UseMutateFunction; }> and { ...; }, "error" or ... 3 more ... or "mutateAsync"> or Pick<...> or Pick<...> or Pick<...>` | + +### useCancelInviteLink + +| Function | Type | +| ---------- | ---------- | +| `useCancelInviteLink` | `() => Pick, { mutate: UseMutateFunction; }> and { ...; }, "error" or ... 3 more ... or "mutateAsync"> or Pick<...> or Pick<...> or Pick<...>` | + +### useAcceptInviteLinkRequest + +| Function | Type | +| ---------- | ---------- | +| `useAcceptInviteLinkRequest` | `() => FilteredMutationResult>` | + +### useDenyInviteLinkRequest + +| Function | Type | +| ---------- | ---------- | +| `useDenyInviteLinkRequest` | `() => FilteredMutationResult>` | + ### useSingleDocByDocId Retrieve a single document from the database based on the document's document ID. @@ -1087,7 +1126,7 @@ Send an invite for a project. | Function | Type | | ---------- | ---------- | -| `useSendInvite` | `({ projectId }: { projectId: string; }) => Pick, { ...; }> and { ...; },...` | +| `useSendInvite` | `({ projectId }: { projectId: string; }) => Pick, { ...; }> and { ...; }, "error" or ... 3 ...` | Parameters: @@ -1107,6 +1146,30 @@ Parameters: * `opts.projectId`: Public ID of project to request the invite cancellation for. +### useManyJoinRequests + +| Function | Type | +| ---------- | ---------- | +| `useManyJoinRequests` | `() => Pick, "data" or "error" or "isRefetching">` | + +### useSingleJoinRequest + +| Function | Type | +| ---------- | ---------- | +| `useSingleJoinRequest` | `({ inviteId, }: { inviteId: string; }) => Pick, "data" or "error" or "isRefetching">` | + +### useCreateJoinRequest + +| Function | Type | +| ---------- | ---------- | +| `useCreateJoinRequest` | `() => FilteredMutationResult>` | + +### useCancelJoinRequest + +| Function | Type | +| ---------- | ---------- | +| `useCancelJoinRequest` | `() => FilteredMutationResult>` | + ### useMapStyleUrl Get a URL that points to a StyleJSON resource served by the embedded HTTP server. @@ -1162,7 +1225,7 @@ Note that this is _not_ a suspenseful hook. The different read states should be | Function | Type | | ---------- | ---------- | -| `useGetCustomMapInfo` | `() => Pick, Error>, "data" or "error" or "status" or "isRefetching"> or ... 4 more ... or Pick<...>` | +| `useGetCustomMapInfo` | `() => Pick, "data" or "error" or "status" or "isRefetching"> or ... 4 more ... or Pick<...>` | Examples: diff --git a/src/contexts/ClientApi.ts b/src/contexts/ClientApi.ts index 8fcdffb..f92be85 100644 --- a/src/contexts/ClientApi.ts +++ b/src/contexts/ClientApi.ts @@ -9,7 +9,10 @@ import { type PropsWithChildren, } from 'react' -import { getInvitesQueryKey } from '../lib/react-query.js' +import { + getInvitesQueryKey, + getJoinRequestsQueryKey, +} from '../lib/react-query.js' export const ClientApiContext: Context = createContext(null) @@ -32,17 +35,30 @@ export function ClientApiProvider({ const queryClient = useQueryClient() useEffect(() => { - function invalidateInviteCache() { + function handleInviteEvent() { queryClient.invalidateQueries({ queryKey: getInvitesQueryKey() }) } - // Invite listeners - clientApi.invite.addListener('invite-received', invalidateInviteCache) - clientApi.invite.addListener('invite-updated', invalidateInviteCache) + clientApi.invite.addListener('invite-received', handleInviteEvent) + clientApi.invite.addListener('invite-updated', handleInviteEvent) + + function handleJoinRequestUpdateEvent() { + queryClient.invalidateQueries({ queryKey: getJoinRequestsQueryKey() }) + } + + clientApi.inviteLinks.addListener( + 'join-request-update', + handleJoinRequestUpdateEvent, + ) return () => { - clientApi.invite.removeListener('invite-received', invalidateInviteCache) - clientApi.invite.removeListener('invite-updated', invalidateInviteCache) + clientApi.invite.removeListener('invite-received', handleInviteEvent) + clientApi.invite.removeListener('invite-updated', handleInviteEvent) + + clientApi.inviteLinks.removeListener( + 'join-request-update', + handleJoinRequestUpdateEvent, + ) } }, [clientApi, queryClient]) diff --git a/src/hooks/invites.ts b/src/hooks/invites.ts index 945dea4..dc55706 100644 --- a/src/hooks/invites.ts +++ b/src/hooks/invites.ts @@ -3,6 +3,7 @@ import { useMutation, useQueryClient, useSuspenseQuery, + type UseMutationResult, type UseSuspenseQueryResult, } from '@tanstack/react-query' @@ -12,10 +13,14 @@ import { filterMutationResult, getInvitesByIdQueryKey, getInvitesQueryKey, + getJoinRequestsByIdQueryKey, + getJoinRequestsQueryKey, getMembersQueryKey, getProjectByIdQueryKey, getProjectsQueryKey, + type FilteredMutationResult, } from '../lib/react-query.js' +import type { JoinRequest } from '../lib/types.js' import { useClientApi } from './client.js' import { useSingleProject } from './projects.js' @@ -195,3 +200,96 @@ export function useRequestCancelInvite({ projectId }: { projectId: string }) { }), ) } + +export function useManyJoinRequests(): // NOTE: Needs explicit return type due to TS2742 +Pick< + UseSuspenseQueryResult>, + 'data' | 'error' | 'isRefetching' +> { + const clientApi = useClientApi() + + const { data, error, isRefetching } = useSuspenseQuery({ + ...baseQueryOptions(), + queryKey: getJoinRequestsQueryKey(), + queryFn: async () => { + return clientApi.inviteLinks.getJoinRequests() + }, + }) + + return { data, error, isRefetching } +} + +export function useSingleJoinRequest({ + inviteId, +}: { + inviteId: string +}): // NOTE: Needs explicit return type due to TS2742 +Pick, 'data' | 'error' | 'isRefetching'> { + const clientApi = useClientApi() + + const { data, error, isRefetching } = useSuspenseQuery({ + ...baseQueryOptions(), + queryKey: getJoinRequestsByIdQueryKey({ inviteId }), + queryFn: async () => { + return clientApi.inviteLinks.getJoinRequestById(inviteId) + }, + }) + + return { data, error, isRefetching } +} + +export function useCreateJoinRequest(): // NOTE: Needs explicit return type due to TS struggles with inference (TS2883) +FilteredMutationResult< + UseMutationResult +> { + const clientApi = useClientApi() + + return filterMutationResult( + useMutation({ + ...baseMutationOptions(), + mutationFn: async ({ + url, + timeout, + }: { + url: string + timeout?: number + }) => { + // Have to avoid passing `undefined` explicitly + // See https://github.com/digidem/rpc-reflector/issues/21 + return timeout === undefined + ? clientApi.inviteLinks.createJoinRequest(url) + : clientApi.inviteLinks.createJoinRequest(url, { timeout }) + }, + onSuccess: async (_data, _variables, _onMutateResult, context) => { + context.client.invalidateQueries({ + queryKey: getJoinRequestsQueryKey(), + }) + }, + }), + ) +} + +export function useCancelJoinRequest(): // NOTE: Needs explicit return type due to TS struggles with inference (TS2883) +FilteredMutationResult< + UseMutationResult +> { + const clientApi = useClientApi() + + return filterMutationResult( + useMutation({ + ...baseMutationOptions(), + mutationFn: async ({ url, reason }: { url: string; reason?: Error }) => { + // Have to avoid passing `undefined` explicitly + // See https://github.com/digidem/rpc-reflector/issues/21 + return reason === undefined + ? clientApi.inviteLinks.cancelJoinRequest(url, reason) + : clientApi.inviteLinks.cancelJoinRequest(url) + }, + onSuccess: async (_data, _variables, _onMutateResult, context) => { + context.client.invalidateQueries({ + queryKey: getJoinRequestsQueryKey(), + }) + }, + }), + ) +} diff --git a/src/hooks/projects.ts b/src/hooks/projects.ts index 1a45ed4..fe43883 100644 --- a/src/hooks/projects.ts +++ b/src/hooks/projects.ts @@ -10,6 +10,7 @@ import type { } from '@comapeo/ipc' import { matchQuery, + queryOptions, useMutation, UseMutationResult, useQueryClient, @@ -23,19 +24,44 @@ import { baseQueryOptions, filterMutationResult, getDocumentCreatedByQueryKey, + getInviteLinksQueryKey, getMediaServerOriginQueryKey, getMemberByIdQueryKey, getMembersQueryKey, getProjectByIdQueryKey, + // getProjectByIdQueryOptions, getProjectRoleQueryKey, getProjectSettingsQueryKey, getProjectsQueryKey, type FilteredMutationResult, } from '../lib/react-query.js' import { SyncStore, type SyncState } from '../lib/sync.js' +import type { InviteDecision, InviteLink, InviteOptions } from '../lib/types.js' import { getBlobUrl, getIconUrl } from '../lib/urls.js' import { useClientApi } from './client.js' +function getProjectByIdQueryOptions({ + clientApi, + projectId, +}: { + clientApi: ComapeoCoreClientApi + projectId: string +}) { + return queryOptions({ + ...baseQueryOptions(), + queryKey: getProjectByIdQueryKey({ projectId }), + queryFn: async () => { + return clientApi.getProject(projectId) + }, + // Keep project instances around indefinitely - shouldn't be a memory + // problem because these are only lightweight proxy objects, and project + // references are kept indefinitely on the backend anyway once they are + // accessed + staleTime: Infinity, + gcTime: Infinity, + }) +} + /** * Retrieve the project settings for a project. * @@ -99,19 +125,9 @@ Pick< > { const clientApi = useClientApi() - const { data, error, isRefetching } = useSuspenseQuery({ - ...baseQueryOptions(), - queryKey: getProjectByIdQueryKey({ projectId }), - queryFn: async () => { - return clientApi.getProject(projectId) - }, - // Keep project instances around indefinitely - shouldn't be a memory - // problem because these are only lightweight proxy objects, and project - // references are kept indefinitely on the backend anyway once they are - // accessed - staleTime: Infinity, - gcTime: Infinity, - }) + const { data, error, isRefetching } = useSuspenseQuery( + getProjectByIdQueryOptions({ clientApi, projectId }), + ) return { data, error, isRefetching } } @@ -1044,3 +1060,173 @@ export function useExportZipFile({ projectId }: { projectId: string }) { }), ) } + +export function useManyInviteLinks({ + projectId, +}: { + projectId: string +}): Pick< + UseSuspenseQueryResult>, + 'data' | 'error' | 'isRefetching' +> { + const { data: projectApi } = useSingleProject({ projectId }) + + const { data, error, isRefetching } = useSuspenseQuery({ + ...baseQueryOptions(), + queryKey: getInviteLinksQueryKey({ projectId }), + queryFn: async () => { + return projectApi.$member.listInviteLinks() + }, + }) + + return { data, error, isRefetching } +} + +export function useCreateInviteLink() { + const clientApi = useClientApi() + + return filterMutationResult( + useMutation({ + ...baseMutationOptions(), + mutationFn: async ( + { + projectId, + ...inviteOptions + }: { + projectId: string + } & InviteOptions, + context, + ) => { + const projectApi = await context.client.ensureQueryData( + getProjectByIdQueryOptions({ clientApi, projectId }), + ) + + return projectApi.$member.createInviteLink(inviteOptions) + }, + onSuccess: async (_data, variables, _onMutateResult, context) => { + context.client.invalidateQueries({ + queryKey: getInviteLinksQueryKey({ projectId: variables.projectId }), + }) + }, + }), + ) +} + +export function useCancelInviteLink() { + const clientApi = useClientApi() + + return filterMutationResult( + useMutation({ + ...baseMutationOptions(), + mutationFn: async ( + { + projectId, + inviteUrl, + }: { + projectId: string + inviteUrl: string | undefined + }, + context, + ) => { + const projectApi = await context.client.ensureQueryData( + getProjectByIdQueryOptions({ clientApi, projectId }), + ) + + // Have to avoid passing `undefined` explicitly + // See https://github.com/digidem/rpc-reflector/issues/21 + return inviteUrl + ? projectApi.$member.cancelInviteLink(inviteUrl) + : projectApi.$member.cancelInviteLink() + }, + onSuccess: async (_data, variables, _onMutateResult, context) => { + context.client.invalidateQueries({ + queryKey: getInviteLinksQueryKey({ projectId: variables.projectId }), + }) + }, + }), + ) +} + +export function useAcceptInviteLinkRequest(): // NOTE: Needs explicit return type due to TS struggles with inference (TS2883) +FilteredMutationResult< + UseMutationResult< + InviteDecision, + Error, + { projectId: string; inviteId: string; deviceId: string } + > +> { + const clientApi = useClientApi() + + return filterMutationResult( + useMutation({ + ...baseMutationOptions(), + mutationFn: async ( + { + projectId, + inviteId, + deviceId, + }: { + projectId: string + inviteId: string + deviceId: string + }, + context, + ) => { + const projectApi = await context.client.ensureQueryData( + getProjectByIdQueryOptions({ clientApi, projectId }), + ) + + return projectApi.$member.acceptInviteLinkRequest(inviteId, deviceId) + }, + onSuccess: async (_data, variables, _onMutateResult, context) => { + context.client.invalidateQueries({ + queryKey: getInviteLinksQueryKey({ projectId: variables.projectId }), + }) + context.client.invalidateQueries({ + queryKey: getMembersQueryKey({ projectId: variables.projectId }), + }) + }, + }), + ) +} + +export function useDenyInviteLinkRequest(): // NOTE: Needs explicit return type due to TS struggles with inference (TS2883) +FilteredMutationResult< + UseMutationResult< + void, + Error, + { projectId: string; inviteId: string; deviceId: string } + > +> { + const clientApi = useClientApi() + + return filterMutationResult( + useMutation({ + ...baseMutationOptions(), + mutationFn: async ( + { + projectId, + inviteId, + deviceId, + }: { + projectId: string + inviteId: string + deviceId: string + // TODO: Expose reason? + }, + context, + ) => { + const projectApi = await context.client.ensureQueryData( + getProjectByIdQueryOptions({ clientApi, projectId }), + ) + + return projectApi.$member.denyInviteLinkRequest(inviteId, deviceId) + }, + onSuccess: async (_data, variables, _onMutateResult, context) => { + context.client.invalidateQueries({ + queryKey: getInviteLinksQueryKey({ projectId: variables.projectId }), + }) + }, + }), + ) +} diff --git a/src/index.ts b/src/index.ts index d1325fa..c4ae127 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,11 +17,15 @@ export { } from './hooks/documents.js' export { useAcceptInvite, + useCancelJoinRequest, + useCreateJoinRequest, useManyInvites, + useManyJoinRequests, useRejectInvite, useRequestCancelInvite, useSendInvite, useSingleInvite, + useSingleJoinRequest, } from './hooks/invites.js' export { useMapStyleUrl, @@ -54,25 +58,33 @@ export { InvalidStatusTransitionError, } from './lib/map-shares-stores.js' export { + useAcceptInviteLinkRequest, useAddServerPeer, useAttachmentUrl, + useCancelInviteLink, + useChangeMemberRole, useConnectSyncServers, useCreateBlob, + useCreateInviteLink, useCreateProject, useDataSyncProgress, + useDenyInviteLinkRequest, useDisconnectSyncServers, useDocumentCreatedBy, + useExportGeoJSON, + useExportZipFile, useIconUrl, useImportProjectCategories, useImportProjectConfig, useLeaveProject, + useManyInviteLinks, useManyMembers, useManyProjects, useOwnRoleInProject, useProjectOwnRoleChangeListener, useProjectSettings, - useRemoveServerPeer, useRemoveMember, + useRemoveServerPeer, useSetAutostopDataSyncTimeout, useSingleMember, useSingleProject, @@ -80,9 +92,6 @@ export { useStopSync, useSyncState, useUpdateProjectSettings, - useChangeMemberRole, - useExportGeoJSON, - useExportZipFile, } from './hooks/projects.js' export type { SyncState } from './lib/sync.js' export type { diff --git a/src/lib/react-query.ts b/src/lib/react-query.ts index beefed2..1573d3f 100644 --- a/src/lib/react-query.ts +++ b/src/lib/react-query.ts @@ -1,10 +1,10 @@ import { DEFAULT_MAP_ID } from '@comapeo/map-server/constants.js' -import type { - QueryClient, - QueryOptions, - UseMutationOptions, - UseMutationResult, - UseQueryResult, +import { + type QueryClient, + type QueryOptions, + type UseMutationOptions, + type UseMutationResult, + type UseQueryResult, } from '@tanstack/react-query' import { DistributedPick } from 'type-fest' @@ -113,6 +113,18 @@ export function getInvitesByIdQueryKey({ inviteId }: { inviteId: string }) { return [ROOT_QUERY_KEY, 'invites', { inviteId }] as const } +export function getJoinRequestsQueryKey() { + return [ROOT_QUERY_KEY, 'join-requests'] as const +} + +export function getJoinRequestsByIdQueryKey({ + inviteId, +}: { + inviteId: string +}) { + return [ROOT_QUERY_KEY, 'join-requests', { inviteId }] as const +} + // #endregion // #region Maps @@ -201,6 +213,26 @@ export function getMemberByIdQueryKey({ return [ROOT_QUERY_KEY, 'projects', projectId, 'members', deviceId] as const } +export function getInviteLinksQueryKey({ projectId }: { projectId: string }) { + return [ROOT_QUERY_KEY, 'projects', projectId, 'invite-links'] as const +} + +export function getInviteLinksByIdQueryKey({ + projectId, + inviteId, +}: { + projectId: string + inviteId: string +}) { + return [ + ROOT_QUERY_KEY, + 'projects', + projectId, + 'invite-links', + { inviteId }, + ] as const +} + export function getDocumentCreatedByQueryKey({ projectId, originalVersionId, diff --git a/src/lib/types.ts b/src/lib/types.ts index 985dd4c..06e16c5 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,4 +1,6 @@ import type { ComapeoDoc, ComapeoValue } from '@comapeo/core/schema.js' +import type { ComapeoCoreClientApi } from '@comapeo/ipc' +import type { ComapeoProjectClientApi } from '@comapeo/ipc/client.js' export type WriteableDocumentType = Extract< ComapeoDoc['schemaName'], @@ -13,4 +15,30 @@ export type WriteableDocument = Extract< { schemaName: D } > +// TODO: Export from core +export type JoinRequest = Awaited< + ReturnType +> + +// TODO: Export from core +export type JoinRequestUpdate = Parameters< + Parameters[1] +>[0] + +// TODO: Export from core +export type InviteLink = Awaited< + ReturnType +>[number] + +// TODO: Export from core +export type InviteOptions = Omit< + Parameters[0], + '__testOnlyInviteId' +> + +// TODO: Export from core +export type InviteDecision = Awaited< + ReturnType +> + export {} diff --git a/test/helpers/client-api-mock.ts b/test/helpers/client-api-mock.ts index 887d548..d359895 100644 --- a/test/helpers/client-api-mock.ts +++ b/test/helpers/client-api-mock.ts @@ -10,6 +10,10 @@ export type MockClientApi = { addListener: ReturnType removeListener: ReturnType } + inviteLinks: { + addListener: ReturnType + removeListener: ReturnType + } } export function createMockClientApi(): MockClientApi { @@ -48,5 +52,10 @@ export function createMockClientApi(): MockClientApi { removeListener: vi.fn(), } - return { on, off, emit, listeners, sendMapShare, invite } + const inviteLinks = { + addListener: vi.fn(), + removeListener: vi.fn(), + } + + return { on, off, emit, listeners, sendMapShare, invite, inviteLinks } } From 1c37ad2c209e2ded41c44ec0d5c0b57f97ad65ae Mon Sep 17 00:00:00 2001 From: Andrew Chou Date: Mon, 28 Sep 2026 16:18:49 -0400 Subject: [PATCH 04/33] update useSendInvite to address bundling complaint --- src/hooks/invites.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/hooks/invites.ts b/src/hooks/invites.ts index dc55706..963e5cb 100644 --- a/src/hooks/invites.ts +++ b/src/hooks/invites.ts @@ -20,7 +20,7 @@ import { getProjectsQueryKey, type FilteredMutationResult, } from '../lib/react-query.js' -import type { JoinRequest } from '../lib/types.js' +import type { InviteDecision, JoinRequest } from '../lib/types.js' import { useClientApi } from './client.js' import { useSingleProject } from './projects.js' @@ -147,7 +147,23 @@ export function useRejectInvite() { * * @param opts.projectId Public ID of project to send the invite on behalf of. */ -export function useSendInvite({ projectId }: { projectId: string }) { +export function useSendInvite({ + projectId, +}: { + projectId: string +}): // NOTE: Needs explicit return type due to TS struggles with inference (TS 2883) +FilteredMutationResult< + UseMutationResult< + InviteDecision, + Error, + { + deviceId: string + roleDescription?: string + roleId: MemberApi.RoleIdForNewInvite + roleName?: string + } + > +> { const queryClient = useQueryClient() const { data: projectApi } = useSingleProject({ projectId }) From 25b777a65c23b547a917a09f42f0e00e0c3c3c73 Mon Sep 17 00:00:00 2001 From: Andrew Chou Date: Mon, 28 Sep 2026 16:22:03 -0400 Subject: [PATCH 05/33] minor import cleanup --- src/hooks/projects.ts | 1 - src/lib/react-query.ts | 12 ++++++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/hooks/projects.ts b/src/hooks/projects.ts index fe43883..8f39f96 100644 --- a/src/hooks/projects.ts +++ b/src/hooks/projects.ts @@ -29,7 +29,6 @@ import { getMemberByIdQueryKey, getMembersQueryKey, getProjectByIdQueryKey, - // getProjectByIdQueryOptions, getProjectRoleQueryKey, getProjectSettingsQueryKey, getProjectsQueryKey, diff --git a/src/lib/react-query.ts b/src/lib/react-query.ts index 1573d3f..77691d5 100644 --- a/src/lib/react-query.ts +++ b/src/lib/react-query.ts @@ -1,10 +1,10 @@ import { DEFAULT_MAP_ID } from '@comapeo/map-server/constants.js' -import { - type QueryClient, - type QueryOptions, - type UseMutationOptions, - type UseMutationResult, - type UseQueryResult, +import type { + QueryClient, + QueryOptions, + UseMutationOptions, + UseMutationResult, + UseQueryResult, } from '@tanstack/react-query' import { DistributedPick } from 'type-fest' From a57bb8180cc3dcb8efb85a5c52912ee0778e507a Mon Sep 17 00:00:00 2001 From: Andrew Chou Date: Mon, 28 Sep 2026 16:26:06 -0400 Subject: [PATCH 06/33] add comment --- src/hooks/projects.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/hooks/projects.ts b/src/hooks/projects.ts index 8f39f96..4753729 100644 --- a/src/hooks/projects.ts +++ b/src/hooks/projects.ts @@ -1064,7 +1064,8 @@ export function useManyInviteLinks({ projectId, }: { projectId: string -}): Pick< +}): // NOTE: Needs explicit return type due to TS2742 +Pick< UseSuspenseQueryResult>, 'data' | 'error' | 'isRefetching' > { From fc9a7fa1b80ddf6461a83c33a13d5db620a36435 Mon Sep 17 00:00:00 2001 From: Andrew Chou Date: Mon, 28 Sep 2026 16:29:45 -0400 Subject: [PATCH 07/33] raise react query version floor provides access to unified query API --- package-lock.json | 2 +- package.json | 2 +- src/hooks/projects.ts | 28 ++++++++++++++++------------ 3 files changed, 18 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index ab3f146..f363f14 100644 --- a/package-lock.json +++ b/package-lock.json @@ -57,7 +57,7 @@ "peerDependencies": { "@comapeo/core": "^7.6.0", "@comapeo/ipc": "^9.0.0", - "@tanstack/react-query": "^5.89.0", + "@tanstack/react-query": "^5.102.8", "react": "^18 || ^19" } }, diff --git a/package.json b/package.json index 16456aa..61d11c6 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ "peerDependencies": { "@comapeo/core": "^7.6.0", "@comapeo/ipc": "^9.0.0", - "@tanstack/react-query": "^5.89.0", + "@tanstack/react-query": "^5.102.8", "react": "^18 || ^19" }, "devDependencies": { diff --git a/src/hooks/projects.ts b/src/hooks/projects.ts index 4753729..6a851c3 100644 --- a/src/hooks/projects.ts +++ b/src/hooks/projects.ts @@ -1097,9 +1097,10 @@ export function useCreateInviteLink() { } & InviteOptions, context, ) => { - const projectApi = await context.client.ensureQueryData( - getProjectByIdQueryOptions({ clientApi, projectId }), - ) + const projectApi = await context.client.query({ + ...getProjectByIdQueryOptions({ clientApi, projectId }), + staleTime: 'static', + }) return projectApi.$member.createInviteLink(inviteOptions) }, @@ -1128,9 +1129,10 @@ export function useCancelInviteLink() { }, context, ) => { - const projectApi = await context.client.ensureQueryData( - getProjectByIdQueryOptions({ clientApi, projectId }), - ) + const projectApi = await context.client.query({ + ...getProjectByIdQueryOptions({ clientApi, projectId }), + staleTime: 'static', + }) // Have to avoid passing `undefined` explicitly // See https://github.com/digidem/rpc-reflector/issues/21 @@ -1172,9 +1174,10 @@ FilteredMutationResult< }, context, ) => { - const projectApi = await context.client.ensureQueryData( - getProjectByIdQueryOptions({ clientApi, projectId }), - ) + const projectApi = await context.client.query({ + ...getProjectByIdQueryOptions({ clientApi, projectId }), + staleTime: 'static', + }) return projectApi.$member.acceptInviteLinkRequest(inviteId, deviceId) }, @@ -1216,9 +1219,10 @@ FilteredMutationResult< }, context, ) => { - const projectApi = await context.client.ensureQueryData( - getProjectByIdQueryOptions({ clientApi, projectId }), - ) + const projectApi = await context.client.query({ + ...getProjectByIdQueryOptions({ clientApi, projectId }), + staleTime: 'static', + }) return projectApi.$member.denyInviteLinkRequest(inviteId, deviceId) }, From 69ab5501ca7075621df305eb613c1ca459245fd0 Mon Sep 17 00:00:00 2001 From: Andrew Chou Date: Mon, 5 Oct 2026 11:52:23 -0400 Subject: [PATCH 08/33] update core dep --- package-lock.json | 25 ++++++++++++++++++++----- package.json | 4 ++-- 2 files changed, 22 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index f363f14..4924570 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "type-fest": "^5.9.0" }, "devDependencies": { - "@comapeo/core": "7.6.0", + "@comapeo/core": "7.6.1", "@comapeo/ipc": "9.0.1", "@eslint/js": "10.0.1", "@ianvs/prettier-plugin-sort-imports": "4.7.1", @@ -55,7 +55,7 @@ "vitest": "5.0.0" }, "peerDependencies": { - "@comapeo/core": "^7.6.0", + "@comapeo/core": "^7.6.1", "@comapeo/ipc": "^9.0.0", "@tanstack/react-query": "^5.102.8", "react": "^18 || ^19" @@ -471,9 +471,9 @@ } }, "node_modules/@comapeo/core": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/@comapeo/core/-/core-7.6.0.tgz", - "integrity": "sha512-3MPdyzNWS3YqmUs35l80JfcQBFUycKUCG9kArdA4wovs4XE2sh2iiWOE4RFns7mOtLABR9W5zjuIxpifb79Pww==", + "version": "7.6.1", + "resolved": "https://registry.npmjs.org/@comapeo/core/-/core-7.6.1.tgz", + "integrity": "sha512-m+ZlqYOeMS43frI2I4b65fLJdyw7TiB+oDqkah+itToyWO//nbNUoS94rctBCM+GJgAAewgi+LSemuEHmwjBzg==", "dev": true, "license": "MIT", "dependencies": { @@ -524,6 +524,7 @@ "protobufjs": "^7.2.3", "protomux": "^3.4.1", "quickbit-universal": "^2.2.0", + "range-parser": "^1.3.0", "ready-resource": "^1.2.0", "sodium-universal": "^4.0.0", "start-stop-state-machine": "^1.2.0", @@ -9639,6 +9640,20 @@ "dev": true, "license": "MIT" }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", diff --git a/package.json b/package.json index 61d11c6..f851ff0 100644 --- a/package.json +++ b/package.json @@ -70,13 +70,13 @@ "type-fest": "^5.9.0" }, "peerDependencies": { - "@comapeo/core": "^7.6.0", + "@comapeo/core": "^7.6.1", "@comapeo/ipc": "^9.0.0", "@tanstack/react-query": "^5.102.8", "react": "^18 || ^19" }, "devDependencies": { - "@comapeo/core": "7.6.0", + "@comapeo/core": "7.6.1", "@comapeo/ipc": "9.0.1", "@eslint/js": "10.0.1", "@ianvs/prettier-plugin-sort-imports": "4.7.1", From b3184e204dab55fc57110053579a87e1d30883e9 Mon Sep 17 00:00:00 2001 From: Andrew Chou Date: Tue, 6 Oct 2026 15:03:01 -0400 Subject: [PATCH 09/33] implementation cleanup --- docs/API.md | 8 ++++---- src/hooks/invites.ts | 18 ++++++++++++------ src/hooks/projects.ts | 32 ++++++++++++-------------------- src/lib/types.ts | 28 ---------------------------- 4 files changed, 28 insertions(+), 58 deletions(-) diff --git a/docs/API.md b/docs/API.md index 743f4d1..237a0cc 100644 --- a/docs/API.md +++ b/docs/API.md @@ -846,19 +846,19 @@ Parameters: | Function | Type | | ---------- | ---------- | -| `useManyInviteLinks` | `({ projectId, }: { projectId: string; }) => Pick[]>, "data" or ... 1 more ... or "isRefetching">` | +| `useManyInviteLinks` | `({ projectId, }: { projectId: string; }) => Pick, "data" or "error" or "isRefetching">` | ### useCreateInviteLink | Function | Type | | ---------- | ---------- | -| `useCreateInviteLink` | `() => Pick, { mutate: UseMutateFunction; }> and { ...; }, "error" or ... 3 more ... or "mutateAsync"> or Pick<...> or Pick<...> or Pick<...>` | +| `useCreateInviteLink` | `() => FilteredMutationResult>>` | ### useCancelInviteLink | Function | Type | | ---------- | ---------- | -| `useCancelInviteLink` | `() => Pick, { mutate: UseMutateFunction; }> and { ...; }, "error" or ... 3 more ... or "mutateAsync"> or Pick<...> or Pick<...> or Pick<...>` | +| `useCancelInviteLink` | `() => Pick, { mutate: UseMutateFunction; }> and { ...; }, "error" or ... 3 more ... or "mutateAsync"> or Pick<...> or Pick<...> or Pick<...>` | ### useAcceptInviteLinkRequest @@ -1126,7 +1126,7 @@ Send an invite for a project. | Function | Type | | ---------- | ---------- | -| `useSendInvite` | `({ projectId }: { projectId: string; }) => Pick, { ...; }> and { ...; }, "error" or ... 3 ...` | +| `useSendInvite` | `({ projectId, }: { projectId: string; }) => FilteredMutationResult>` | Parameters: diff --git a/src/hooks/invites.ts b/src/hooks/invites.ts index 963e5cb..c662d84 100644 --- a/src/hooks/invites.ts +++ b/src/hooks/invites.ts @@ -1,4 +1,4 @@ -import type { InviteApi, MemberApi } from '@comapeo/core' +import type { InviteApi, InviteLinkJoiner, MemberApi } from '@comapeo/core' import { useMutation, useQueryClient, @@ -20,7 +20,6 @@ import { getProjectsQueryKey, type FilteredMutationResult, } from '../lib/react-query.js' -import type { InviteDecision, JoinRequest } from '../lib/types.js' import { useClientApi } from './client.js' import { useSingleProject } from './projects.js' @@ -154,7 +153,7 @@ export function useSendInvite({ }): // NOTE: Needs explicit return type due to TS struggles with inference (TS 2883) FilteredMutationResult< UseMutationResult< - InviteDecision, + MemberApi.InviteDecision, Error, { deviceId: string @@ -219,7 +218,7 @@ export function useRequestCancelInvite({ projectId }: { projectId: string }) { export function useManyJoinRequests(): // NOTE: Needs explicit return type due to TS2742 Pick< - UseSuspenseQueryResult>, + UseSuspenseQueryResult>, 'data' | 'error' | 'isRefetching' > { const clientApi = useClientApi() @@ -240,7 +239,10 @@ export function useSingleJoinRequest({ }: { inviteId: string }): // NOTE: Needs explicit return type due to TS2742 -Pick, 'data' | 'error' | 'isRefetching'> { +Pick< + UseSuspenseQueryResult, + 'data' | 'error' | 'isRefetching' +> { const clientApi = useClientApi() const { data, error, isRefetching } = useSuspenseQuery({ @@ -256,7 +258,11 @@ Pick, 'data' | 'error' | 'isRefetching'> { export function useCreateJoinRequest(): // NOTE: Needs explicit return type due to TS struggles with inference (TS2883) FilteredMutationResult< - UseMutationResult + UseMutationResult< + InviteLinkJoiner.JoinRequest, + Error, + { url: string; timeout?: number } + > > { const clientApi = useClientApi() diff --git a/src/hooks/projects.ts b/src/hooks/projects.ts index 6a851c3..dba2065 100644 --- a/src/hooks/projects.ts +++ b/src/hooks/projects.ts @@ -35,7 +35,6 @@ import { type FilteredMutationResult, } from '../lib/react-query.js' import { SyncStore, type SyncState } from '../lib/sync.js' -import type { InviteDecision, InviteLink, InviteOptions } from '../lib/types.js' import { getBlobUrl, getIconUrl } from '../lib/urls.js' import { useClientApi } from './client.js' @@ -1066,7 +1065,7 @@ export function useManyInviteLinks({ projectId: string }): // NOTE: Needs explicit return type due to TS2742 Pick< - UseSuspenseQueryResult>, + UseSuspenseQueryResult>, 'data' | 'error' | 'isRefetching' > { const { data: projectApi } = useSingleProject({ projectId }) @@ -1082,21 +1081,20 @@ Pick< return { data, error, isRefetching } } -export function useCreateInviteLink() { +export function useCreateInviteLink(): // NOTE: Needs explicit return type due to TS struggles with inference (TS2883) +FilteredMutationResult< + UseMutationResult< + string, + Error, + { projectId: string } & Omit + > +> { const clientApi = useClientApi() return filterMutationResult( useMutation({ ...baseMutationOptions(), - mutationFn: async ( - { - projectId, - ...inviteOptions - }: { - projectId: string - } & InviteOptions, - context, - ) => { + mutationFn: async ({ projectId, ...inviteOptions }, context) => { const projectApi = await context.client.query({ ...getProjectByIdQueryOptions({ clientApi, projectId }), staleTime: 'static', @@ -1120,13 +1118,7 @@ export function useCancelInviteLink() { useMutation({ ...baseMutationOptions(), mutationFn: async ( - { - projectId, - inviteUrl, - }: { - projectId: string - inviteUrl: string | undefined - }, + { projectId, inviteUrl }: { projectId: string; inviteUrl?: string }, context, ) => { const projectApi = await context.client.query({ @@ -1152,7 +1144,7 @@ export function useCancelInviteLink() { export function useAcceptInviteLinkRequest(): // NOTE: Needs explicit return type due to TS struggles with inference (TS2883) FilteredMutationResult< UseMutationResult< - InviteDecision, + MemberApi.InviteDecision, Error, { projectId: string; inviteId: string; deviceId: string } > diff --git a/src/lib/types.ts b/src/lib/types.ts index 06e16c5..985dd4c 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1,6 +1,4 @@ import type { ComapeoDoc, ComapeoValue } from '@comapeo/core/schema.js' -import type { ComapeoCoreClientApi } from '@comapeo/ipc' -import type { ComapeoProjectClientApi } from '@comapeo/ipc/client.js' export type WriteableDocumentType = Extract< ComapeoDoc['schemaName'], @@ -15,30 +13,4 @@ export type WriteableDocument = Extract< { schemaName: D } > -// TODO: Export from core -export type JoinRequest = Awaited< - ReturnType -> - -// TODO: Export from core -export type JoinRequestUpdate = Parameters< - Parameters[1] ->[0] - -// TODO: Export from core -export type InviteLink = Awaited< - ReturnType ->[number] - -// TODO: Export from core -export type InviteOptions = Omit< - Parameters[0], - '__testOnlyInviteId' -> - -// TODO: Export from core -export type InviteDecision = Awaited< - ReturnType -> - export {} From 924ab344ef5bd2d0a527ee2fcdf588bafdfa3ecc Mon Sep 17 00:00:00 2001 From: Andrew Chou Date: Tue, 6 Oct 2026 15:03:27 -0400 Subject: [PATCH 10/33] remove unnecessary query key helper --- src/lib/react-query.ts | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/src/lib/react-query.ts b/src/lib/react-query.ts index 77691d5..4108092 100644 --- a/src/lib/react-query.ts +++ b/src/lib/react-query.ts @@ -217,22 +217,6 @@ export function getInviteLinksQueryKey({ projectId }: { projectId: string }) { return [ROOT_QUERY_KEY, 'projects', projectId, 'invite-links'] as const } -export function getInviteLinksByIdQueryKey({ - projectId, - inviteId, -}: { - projectId: string - inviteId: string -}) { - return [ - ROOT_QUERY_KEY, - 'projects', - projectId, - 'invite-links', - { inviteId }, - ] as const -} - export function getDocumentCreatedByQueryKey({ projectId, originalVersionId, From 232bbb6fcf527ddad8a854fddf99014efdaca4c0 Mon Sep 17 00:00:00 2001 From: Andrew Chou Date: Wed, 7 Oct 2026 11:31:07 -0400 Subject: [PATCH 11/33] update tsconfig lib to reflect runtime versions more closely --- tsconfig.json | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tsconfig.json b/tsconfig.json index 6bf2b26..d105d46 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,7 +6,14 @@ "esModuleInterop": true, "isolatedModules": true, "jsx": "react-jsx", - "lib": ["es2022", "dom", "dom.iterable"], + "lib": [ + "es2024", + "ESNext.Array", + "ESNext.Collection", + "ESNext.Iterator", + "dom", + "dom.iterable" + ], "module": "NodeNext", "moduleResolution": "NodeNext", "moduleDetection": "force", From e331e1234c0fc4d860100dffb1f405799031857c Mon Sep 17 00:00:00 2001 From: Andrew Chou Date: Wed, 7 Oct 2026 11:31:22 -0400 Subject: [PATCH 12/33] wip: basic test for invite-over-internet --- test/helpers/ipc.ts | 7 +- test/hooks/hyperdht.d.ts | 28 +++ ...{invite-rejoin.test.ts => invites.test.ts} | 179 ++++++++++++++++++ 3 files changed, 213 insertions(+), 1 deletion(-) create mode 100644 test/hooks/hyperdht.d.ts rename test/hooks/{invite-rejoin.test.ts => invites.test.ts} (59%) diff --git a/test/helpers/ipc.ts b/test/helpers/ipc.ts index 761b69c..741903a 100644 --- a/test/helpers/ipc.ts +++ b/test/helpers/ipc.ts @@ -24,7 +24,11 @@ const clientMigrationsFolder = path.join( 'drizzle/client', ) -export function setupCoreIpc() { +export function setupCoreIpc({ + managerOverrides, +}: { + managerOverrides?: Partial[0]> +} = {}) { const { port1, port2 } = new MessageChannel() const fastify = Fastify() @@ -36,6 +40,7 @@ export function setupCoreIpc() { projectMigrationsFolder, clientMigrationsFolder, fastify, + ...managerOverrides, }) const server = createComapeoCoreServer(manager, port1) diff --git a/test/hooks/hyperdht.d.ts b/test/hooks/hyperdht.d.ts new file mode 100644 index 0000000..23e7591 --- /dev/null +++ b/test/hooks/hyperdht.d.ts @@ -0,0 +1,28 @@ +declare module 'hyperdht' { + export default class HyperDHT {} +} + +declare module 'hyperdht/testnet.js' { + interface TestnetOpts { + teardown?: TestnetTearDownFn + host?: string + port?: number + } + + type TestnetTearDownFn = ( + opts: TestnetOpts, + onFinishTeardown: () => Promise, + ) => void | Promise + + class TestNet { + nodes: HyperDHT[] + bootstrap: string[] + createNode(opts?: object): HyperDHT + destroy(): Promise + } + + export default function createTestnet( + size?: number, + opts?: TestnetOpts | TestnetTearDownFn, + ): Promise +} diff --git a/test/hooks/invite-rejoin.test.ts b/test/hooks/invites.test.ts similarity index 59% rename from test/hooks/invite-rejoin.test.ts rename to test/hooks/invites.test.ts index d96f60a..cc6e985 100644 --- a/test/hooks/invite-rejoin.test.ts +++ b/test/hooks/invites.test.ts @@ -4,12 +4,17 @@ import '../helpers/jsdom-setup.js' import type { InviteApi } from '@comapeo/core' import { QueryClient } from '@tanstack/react-query' import { act, renderHook, waitFor } from '@testing-library/react' +import createTestnet from 'hyperdht/testnet.js' import { pEvent, type TypedEventEmitter } from 'p-event' import { assert, test } from 'vitest' import { useAcceptInvite, + useAcceptInviteLinkRequest, + useCreateInviteLink, + useCreateJoinRequest, useLeaveProject, + useManyMembers, useProjectSettings, useSingleProject, } from '../../src/index.js' @@ -205,3 +210,177 @@ test( ) }, ) + +test('invite over internet', { timeout: 60_000 }, async (t) => { + // 1. Setup + const testnet = await createTestnet(2) + t.onTestFinished(() => testnet.destroy()) + + const invitor = setupCoreIpc({ + managerOverrides: { + swarm: { dht: testnet.nodes[0] }, + untrustedTimeout: 1_000, + }, + }) + + const invitee = setupCoreIpc({ + managerOverrides: { + swarm: { dht: testnet.nodes[0], untrustedTimeout: 1_000 }, + }, + }) + + t.onTestFinished(async () => { + await Promise.all([invitor.cleanup(), invitee.cleanup()]) + }) + + await invitor.manager.setDeviceInfo({ + name: 'invitor', + deviceType: 'desktop', + }) + + await invitee.manager.setDeviceInfo({ + name: 'invitee', + deviceType: 'mobile', + }) + + const disconnect = connectPeers([invitor.manager, invitee.manager]) + + t.onTestFinished(disconnect) + + await waitForPeers([invitor.manager, invitee.manager]) + + const projectId = await invitor.manager.createProject({ name: 'mapeo' }) + + const invitorWrapper = createWrapper({ + clientApi: invitor.client, + }) + + const inviteeWrapper = createWrapper({ + clientApi: invitee.client, + }) + + const manyMembersHook = renderHook( + ({ projectId }) => useManyMembers({ projectId, includeLeft: false }), + { wrapper: invitorWrapper, initialProps: { projectId } }, + ) + + await waitFor( + () => { + assert.isNotNull(manyMembersHook.result.current) + assert.isNull(manyMembersHook.result.current.error) + assert.ok(manyMembersHook.result.current.data) + }, + { timeout: 10_000 }, + ) + + assert.strictEqual(manyMembersHook.result.current.data.length, 1) + + // 2. Invitor: create invite link and wait for Invitee to send join request + const createInviteLinkHook = renderHook(() => useCreateInviteLink(), { + wrapper: invitorWrapper, + }) + + const inviteUrl = await act(() => { + return createInviteLinkHook.result.current.mutateAsync({ + projectId, + roleId: MEMBER_ROLE_ID, + }) + }) + + console.log('invite url', inviteUrl) + + const deferredInviteLinkJoinRequest = Promise.withResolvers<{ + projectId: string + deviceId: string + inviteId: string + }>() + + invitor.manager.on( + 'invite-link-join-request', + (projectId, deviceId, inviteId) => { + deferredInviteLinkJoinRequest.resolve({ projectId, deviceId, inviteId }) + }, + ) + + invitor.manager.on( + 'invite-link-join-request-error', + (error, deviceId, inviteId) => { + deferredInviteLinkJoinRequest.reject( + new Error( + `Invite link join error for device ${deviceId} and invite ${inviteId}`, + { cause: error }, + ), + ) + }, + ) + + // 3. Invitee: create invite link request and wait for it to be accepted and completed + const createJoinRequestHook = renderHook(() => useCreateJoinRequest(), { + wrapper: inviteeWrapper, + }) + + act(() => { + createJoinRequestHook.result.current.mutate({ url: inviteUrl }) + }) + + await waitFor( + () => { + assert.strictEqual( + createJoinRequestHook.result.current.status, + 'success', + `accept failed: ${createJoinRequestHook.result.current.error?.stack}`, + ) + }, + { timeout: 10_000 }, + ) + + const deferredJoinRequestCompleted = Promise.withResolvers() + + invitee.manager.inviteLinks.on('join-request-update', (update) => { + console.log('join request update', update) + + if (update.status === 'failed') { + deferredJoinRequestCompleted.reject(update.error) + return + } + + if (update.status === 'completed') { + deferredJoinRequestCompleted.resolve() + } + }) + + const linkJoinRequestPayload = await deferredInviteLinkJoinRequest.promise + + const acceptInviteLinkRequestHook = renderHook( + () => useAcceptInviteLinkRequest(), + { wrapper: invitorWrapper }, + ) + + act(() => { + acceptInviteLinkRequestHook.result.current.mutate(linkJoinRequestPayload) + }) + + await waitFor( + () => { + assert.strictEqual( + acceptInviteLinkRequestHook.result.current.status, + 'success', + `accept failed: ${acceptInviteLinkRequestHook.result.current.error?.stack}`, + ) + }, + { timeout: 10_000 }, + ) + + await deferredJoinRequestCompleted.promise + + // 4. Updates to relevant read hooks + await waitFor(() => { + assert.strictEqual(manyMembersHook.result.current.isRefetching, false) + assert.isNull(manyMembersHook.result.current.error) + assert.ok(manyMembersHook.result.current.data) + }) + + console.log('members', manyMembersHook.result.current.data) + + assert.strictEqual(manyMembersHook.result.current.data.length, 2) +}) From 2f3c54f5c1c1d439bcd372c06f30e669aa1110a5 Mon Sep 17 00:00:00 2001 From: Andrew Chou Date: Wed, 7 Oct 2026 11:37:38 -0400 Subject: [PATCH 13/33] remove local peers setup in test --- test/hooks/invites.test.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/test/hooks/invites.test.ts b/test/hooks/invites.test.ts index cc6e985..68a36cd 100644 --- a/test/hooks/invites.test.ts +++ b/test/hooks/invites.test.ts @@ -243,12 +243,6 @@ test('invite over internet', { timeout: 60_000 }, async (t) => { deviceType: 'mobile', }) - const disconnect = connectPeers([invitor.manager, invitee.manager]) - - t.onTestFinished(disconnect) - - await waitForPeers([invitor.manager, invitee.manager]) - const projectId = await invitor.manager.createProject({ name: 'mapeo' }) const invitorWrapper = createWrapper({ From 2f647f308649d0171343514ea16f6c136f19ad47 Mon Sep 17 00:00:00 2001 From: Andrew Chou Date: Wed, 7 Oct 2026 11:53:58 -0400 Subject: [PATCH 14/33] better assertion messages --- test/hooks/invites.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/hooks/invites.test.ts b/test/hooks/invites.test.ts index 68a36cd..9e1438d 100644 --- a/test/hooks/invites.test.ts +++ b/test/hooks/invites.test.ts @@ -322,7 +322,7 @@ test('invite over internet', { timeout: 60_000 }, async (t) => { assert.strictEqual( createJoinRequestHook.result.current.status, 'success', - `accept failed: ${createJoinRequestHook.result.current.error?.stack}`, + `create join request failed: ${createJoinRequestHook.result.current.error?.stack}`, ) }, { timeout: 10_000 }, @@ -359,7 +359,7 @@ test('invite over internet', { timeout: 60_000 }, async (t) => { assert.strictEqual( acceptInviteLinkRequestHook.result.current.status, 'success', - `accept failed: ${acceptInviteLinkRequestHook.result.current.error?.stack}`, + `accept invite link request failed: ${acceptInviteLinkRequestHook.result.current.error?.stack}`, ) }, { timeout: 10_000 }, From 5f615b3ea4c8a06597016bf397e6afba8c028f1b Mon Sep 17 00:00:00 2001 From: Andrew Chou Date: Wed, 7 Oct 2026 12:49:50 -0400 Subject: [PATCH 15/33] remove unnecessary swarm opt --- test/hooks/invites.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/test/hooks/invites.test.ts b/test/hooks/invites.test.ts index 9e1438d..a0b7a97 100644 --- a/test/hooks/invites.test.ts +++ b/test/hooks/invites.test.ts @@ -219,13 +219,12 @@ test('invite over internet', { timeout: 60_000 }, async (t) => { const invitor = setupCoreIpc({ managerOverrides: { swarm: { dht: testnet.nodes[0] }, - untrustedTimeout: 1_000, }, }) const invitee = setupCoreIpc({ managerOverrides: { - swarm: { dht: testnet.nodes[0], untrustedTimeout: 1_000 }, + swarm: { dht: testnet.nodes[0] }, }, }) From c7ca61f19d5ae5568fd318e105030fd81a8a7454 Mon Sep 17 00:00:00 2001 From: Andrew Chou Date: Wed, 7 Oct 2026 13:08:20 -0400 Subject: [PATCH 16/33] test cleanup --- test/hooks/invites.test.ts | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/test/hooks/invites.test.ts b/test/hooks/invites.test.ts index a0b7a97..3fa13fb 100644 --- a/test/hooks/invites.test.ts +++ b/test/hooks/invites.test.ts @@ -211,21 +211,20 @@ test( }, ) -test('invite over internet', { timeout: 60_000 }, async (t) => { +test('invite over internet', { timeout: 30_000 }, async (t) => { // 1. Setup const testnet = await createTestnet(2) - t.onTestFinished(() => testnet.destroy()) + + t.onTestFinished(() => { + return testnet.destroy() + }) const invitor = setupCoreIpc({ - managerOverrides: { - swarm: { dht: testnet.nodes[0] }, - }, + managerOverrides: { swarm: { dht: testnet.nodes[0] } }, }) const invitee = setupCoreIpc({ - managerOverrides: { - swarm: { dht: testnet.nodes[0] }, - }, + managerOverrides: { swarm: { dht: testnet.nodes[0] } }, }) t.onTestFinished(async () => { @@ -280,8 +279,6 @@ test('invite over internet', { timeout: 60_000 }, async (t) => { }) }) - console.log('invite url', inviteUrl) - const deferredInviteLinkJoinRequest = Promise.withResolvers<{ projectId: string deviceId: string @@ -330,8 +327,6 @@ test('invite over internet', { timeout: 60_000 }, async (t) => { const deferredJoinRequestCompleted = Promise.withResolvers() invitee.manager.inviteLinks.on('join-request-update', (update) => { - console.log('join request update', update) - if (update.status === 'failed') { deferredJoinRequestCompleted.reject(update.error) return @@ -373,7 +368,5 @@ test('invite over internet', { timeout: 60_000 }, async (t) => { assert.ok(manyMembersHook.result.current.data) }) - console.log('members', manyMembersHook.result.current.data) - assert.strictEqual(manyMembersHook.result.current.data.length, 2) }) From 910a7c5dfba19034cb9e477f283e11cd665827e7 Mon Sep 17 00:00:00 2001 From: Andrew Chou Date: Wed, 7 Oct 2026 13:54:46 -0400 Subject: [PATCH 17/33] update test --- test/hooks/invites.test.ts | 332 +++++++++++++++++++++++-------------- 1 file changed, 205 insertions(+), 127 deletions(-) diff --git a/test/hooks/invites.test.ts b/test/hooks/invites.test.ts index 3fa13fb..38fe9a4 100644 --- a/test/hooks/invites.test.ts +++ b/test/hooks/invites.test.ts @@ -2,6 +2,8 @@ import '../helpers/jsdom-setup.js' import type { InviteApi } from '@comapeo/core' +import { getErrorCode, NotFoundError } from '@comapeo/core/errors.js' +import { parseInviteURL } from '@comapeo/core/invite-urls.js' import { QueryClient } from '@tanstack/react-query' import { act, renderHook, waitFor } from '@testing-library/react' import createTestnet from 'hyperdht/testnet.js' @@ -14,8 +16,10 @@ import { useCreateInviteLink, useCreateJoinRequest, useLeaveProject, + useManyJoinRequests, useManyMembers, useProjectSettings, + useSingleJoinRequest, useSingleProject, } from '../../src/index.js' import { setupCoreIpc } from '../helpers/ipc.js' @@ -211,162 +215,236 @@ test( }, ) -test('invite over internet', { timeout: 30_000 }, async (t) => { - // 1. Setup - const testnet = await createTestnet(2) +test.describe('invite over internet', () => { + test('join from URL', { timeout: 30_000 }, async (t) => { + // 1. Setup + const testnet = await createTestnet(2) - t.onTestFinished(() => { - return testnet.destroy() - }) + t.onTestFinished(() => { + return testnet.destroy() + }) - const invitor = setupCoreIpc({ - managerOverrides: { swarm: { dht: testnet.nodes[0] } }, - }) + const invitor = setupCoreIpc({ + managerOverrides: { swarm: { dht: testnet.nodes[0] } }, + }) - const invitee = setupCoreIpc({ - managerOverrides: { swarm: { dht: testnet.nodes[0] } }, - }) + const invitee = setupCoreIpc({ + managerOverrides: { swarm: { dht: testnet.nodes[0] } }, + }) - t.onTestFinished(async () => { - await Promise.all([invitor.cleanup(), invitee.cleanup()]) - }) + t.onTestFinished(async () => { + await Promise.all([invitor.cleanup(), invitee.cleanup()]) + }) - await invitor.manager.setDeviceInfo({ - name: 'invitor', - deviceType: 'desktop', - }) + await invitor.manager.setDeviceInfo({ + name: 'invitor', + deviceType: 'desktop', + }) - await invitee.manager.setDeviceInfo({ - name: 'invitee', - deviceType: 'mobile', - }) + await invitee.manager.setDeviceInfo({ + name: 'invitee', + deviceType: 'mobile', + }) - const projectId = await invitor.manager.createProject({ name: 'mapeo' }) + const projectId = await invitor.manager.createProject({ name: 'mapeo' }) - const invitorWrapper = createWrapper({ - clientApi: invitor.client, - }) + const invitorWrapper = createWrapper({ + clientApi: invitor.client, + }) - const inviteeWrapper = createWrapper({ - clientApi: invitee.client, - }) + const inviteeWrapper = createWrapper({ + clientApi: invitee.client, + }) - const manyMembersHook = renderHook( - ({ projectId }) => useManyMembers({ projectId, includeLeft: false }), - { wrapper: invitorWrapper, initialProps: { projectId } }, - ) - - await waitFor( - () => { - assert.isNotNull(manyMembersHook.result.current) - assert.isNull(manyMembersHook.result.current.error) - assert.ok(manyMembersHook.result.current.data) - }, - { timeout: 10_000 }, - ) - - assert.strictEqual(manyMembersHook.result.current.data.length, 1) - - // 2. Invitor: create invite link and wait for Invitee to send join request - const createInviteLinkHook = renderHook(() => useCreateInviteLink(), { - wrapper: invitorWrapper, - }) + const manyMembersHook = renderHook( + ({ projectId }) => useManyMembers({ projectId, includeLeft: false }), + { wrapper: invitorWrapper, initialProps: { projectId } }, + ) + + await waitFor( + () => { + assert.isNotNull(manyMembersHook.result.current) + assert.isNull(manyMembersHook.result.current.error) + assert.ok(manyMembersHook.result.current.data) + }, + { timeout: 10_000 }, + ) + + assert.strictEqual(manyMembersHook.result.current.data.length, 1) - const inviteUrl = await act(() => { - return createInviteLinkHook.result.current.mutateAsync({ - projectId, - roleId: MEMBER_ROLE_ID, + // 2. Invitor: create invite link and wait for Invitee to send join request + const createInviteLinkHook = renderHook(() => useCreateInviteLink(), { + wrapper: invitorWrapper, }) - }) - const deferredInviteLinkJoinRequest = Promise.withResolvers<{ - projectId: string - deviceId: string - inviteId: string - }>() - - invitor.manager.on( - 'invite-link-join-request', - (projectId, deviceId, inviteId) => { - deferredInviteLinkJoinRequest.resolve({ projectId, deviceId, inviteId }) - }, - ) - - invitor.manager.on( - 'invite-link-join-request-error', - (error, deviceId, inviteId) => { - deferredInviteLinkJoinRequest.reject( - new Error( - `Invite link join error for device ${deviceId} and invite ${inviteId}`, - { cause: error }, - ), - ) - }, - ) + const inviteUrl = await act(() => { + return createInviteLinkHook.result.current.mutateAsync({ + projectId, + roleId: MEMBER_ROLE_ID, + }) + }) - // 3. Invitee: create invite link request and wait for it to be accepted and completed - const createJoinRequestHook = renderHook(() => useCreateJoinRequest(), { - wrapper: inviteeWrapper, - }) + const inviteIdFromUrl = parseInviteURL(inviteUrl).inviteIdString + + const deferredInviteLinkJoinRequest = Promise.withResolvers<{ + projectId: string + deviceId: string + inviteId: string + }>() + + invitor.manager.on( + 'invite-link-join-request', + (projectId, deviceId, inviteId) => { + if (inviteId !== inviteIdFromUrl) { + return + } + + deferredInviteLinkJoinRequest.resolve({ + projectId, + deviceId, + inviteId, + }) + }, + ) - act(() => { - createJoinRequestHook.result.current.mutate({ url: inviteUrl }) - }) + invitor.manager.on( + 'invite-link-join-request-error', + (error, deviceId, inviteId) => { + if (inviteId !== inviteIdFromUrl) { + return + } + + deferredInviteLinkJoinRequest.reject( + new Error( + `Invite link join error for device ${deviceId} and invite ${inviteId}`, + { cause: error }, + ), + ) + }, + ) + + // 3. Invitee: create invite link request and wait for it to be accepted and completed + const createJoinRequestHook = renderHook(() => useCreateJoinRequest(), { + wrapper: inviteeWrapper, + }) + const manyJoinRequestsHook = renderHook(() => useManyJoinRequests(), { + wrapper: inviteeWrapper, + }) + + const createdJoinRequest = await act(() => { + return createJoinRequestHook.result.current.mutateAsync({ + url: inviteUrl, + }) + }) + + const singleJoinRequestHook = renderHook( + ({ inviteId }) => useSingleJoinRequest({ inviteId }), + { + wrapper: inviteeWrapper, + initialProps: { inviteId: createdJoinRequest.inviteId }, + }, + ) - await waitFor( - () => { + await waitFor(() => { assert.strictEqual( - createJoinRequestHook.result.current.status, - 'success', - `create join request failed: ${createJoinRequestHook.result.current.error?.stack}`, + manyJoinRequestsHook.result.current.isRefetching, + false, ) - }, - { timeout: 10_000 }, - ) + assert.isNull(manyJoinRequestsHook.result.current.error) + assert.ok(manyJoinRequestsHook.result.current.data) - const deferredJoinRequestCompleted = Promise.withResolvers() + assert.strictEqual( + singleJoinRequestHook.result.current.isRefetching, + false, + ) + assert.isNull(singleJoinRequestHook.result.current.error) + assert.ok(singleJoinRequestHook.result.current.data) + }) - invitee.manager.inviteLinks.on('join-request-update', (update) => { - if (update.status === 'failed') { - deferredJoinRequestCompleted.reject(update.error) - return - } + assert.strictEqual(manyJoinRequestsHook.result.current.data.length, 1) + + // Changes to the `status` field are managed by internal implementation details. + { + const { status: _, ...joinRequestFromManyJoinRequestsHook } = + manyJoinRequestsHook.result.current.data[0]! + + const { status: __, ...joinRequestFromSingleJoinRequestHook } = + singleJoinRequestHook.result.current.data - if (update.status === 'completed') { - deferredJoinRequestCompleted.resolve() + const { status: ___, ...createdJoinRequestWithoutStatus } = + createdJoinRequest + + assert.deepStrictEqual( + joinRequestFromManyJoinRequestsHook, + createdJoinRequestWithoutStatus, + ) + assert.deepStrictEqual( + joinRequestFromSingleJoinRequestHook, + createdJoinRequestWithoutStatus, + ) } - }) - const linkJoinRequestPayload = await deferredInviteLinkJoinRequest.promise + const deferredJoinRequestCompleted = Promise.withResolvers() - const acceptInviteLinkRequestHook = renderHook( - () => useAcceptInviteLinkRequest(), - { wrapper: invitorWrapper }, - ) + invitee.manager.inviteLinks.on('join-request-update', (update) => { + if (update.inviteId !== createdJoinRequest.inviteId) { + return + } - act(() => { - acceptInviteLinkRequestHook.result.current.mutate(linkJoinRequestPayload) - }) + if (update.status === 'failed') { + deferredJoinRequestCompleted.reject(update.error) + return + } + + if (update.status === 'completed') { + deferredJoinRequestCompleted.resolve() + } + }) + + const linkJoinRequestPayload = await deferredInviteLinkJoinRequest.promise + + const acceptInviteLinkRequestHook = renderHook( + () => useAcceptInviteLinkRequest(), + { wrapper: invitorWrapper }, + ) + + act(() => { + acceptInviteLinkRequestHook.result.current.mutate(linkJoinRequestPayload) + }) + + await waitFor( + () => { + assert.strictEqual( + acceptInviteLinkRequestHook.result.current.status, + 'success', + `accept invite link request failed: ${acceptInviteLinkRequestHook.result.current.error?.stack}`, + ) + }, + { timeout: 10_000 }, + ) + + await deferredJoinRequestCompleted.promise + + // 4. Updates to relevant read hooks + await waitFor(() => { + assert.strictEqual(manyMembersHook.result.current.isRefetching, false) - await waitFor( - () => { assert.strictEqual( - acceptInviteLinkRequestHook.result.current.status, - 'success', - `accept invite link request failed: ${acceptInviteLinkRequestHook.result.current.error?.stack}`, + manyJoinRequestsHook.result.current.isRefetching, + false, ) - }, - { timeout: 10_000 }, - ) - await deferredJoinRequestCompleted.promise + assert.strictEqual( + singleJoinRequestHook.result.current.isRefetching, + false, + ) + }) - // 4. Updates to relevant read hooks - await waitFor(() => { - assert.strictEqual(manyMembersHook.result.current.isRefetching, false) - assert.isNull(manyMembersHook.result.current.error) - assert.ok(manyMembersHook.result.current.data) + assert.strictEqual(manyMembersHook.result.current.data.length, 2) + assert.strictEqual(manyJoinRequestsHook.result.current.data.length, 0) + assert.ok( + getErrorCode(singleJoinRequestHook.result.current.error), + NotFoundError.code, + ) }) - - assert.strictEqual(manyMembersHook.result.current.data.length, 2) }) From ea2eafac9952142cde599eed6232d166654c3337 Mon Sep 17 00:00:00 2001 From: Andrew Chou Date: Wed, 7 Oct 2026 14:33:50 -0400 Subject: [PATCH 18/33] update useCancelInviteLink to accept invite ID instead of URL --- docs/API.md | 8 ++++---- src/hooks/projects.ts | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/API.md b/docs/API.md index 237a0cc..472fa6d 100644 --- a/docs/API.md +++ b/docs/API.md @@ -103,7 +103,7 @@ set up, it will throw an error. | Function | Type | | ---------- | ---------- | -| `useClientApi` | `() => ClientApi and { getProject: (projectPublicId: string) => Promise>; }>` | +| `useClientApi` | `() => ComapeoCoreClientApi` | Returns: @@ -183,7 +183,7 @@ Set or unset the current device as an archive device. | Function | Type | | ---------- | ---------- | -| `ReceivedMapSharesProvider` | `({ children, clientApi, mapServerApi, queryClient, }: { clientApi: ClientApi and { getProject: (projectPublicId: string) => Promise>; }>; mapServerApi: MapServerApi; } and { ...; } and { ...; }) => Element` | +| `ReceivedMapSharesProvider` | `({ children, clientApi, mapServerApi, queryClient, }: { clientApi: ComapeoCoreClientApi; mapServerApi: MapServerApi; } and { children?: ReactNode; } and { queryClient: QueryClient; }) => Element` | ### SentMapSharesProvider @@ -858,7 +858,7 @@ Parameters: | Function | Type | | ---------- | ---------- | -| `useCancelInviteLink` | `() => Pick, { mutate: UseMutateFunction; }> and { ...; }, "error" or ... 3 more ... or "mutateAsync"> or Pick<...> or Pick<...> or Pick<...>` | +| `useCancelInviteLink` | `() => Pick, { mutate: UseMutateFunction; }> and { ...; }, "error" or ... 3 more ... or "mutateAsync"> or Pick<...> or Pick<...> or Pick<...>` | ### useAcceptInviteLinkRequest @@ -1489,7 +1489,7 @@ function SentShareStatus({ shareId }: { shareId: string }) { | Constant | Type | | ---------- | ---------- | -| `ClientApiContext` | `Context and { getProject: (projectPublicId: string) => Promise>; }> or null>` | +| `ClientApiContext` | `Context` | ### ReceivedMapSharesContext diff --git a/src/hooks/projects.ts b/src/hooks/projects.ts index dba2065..7ee7e8a 100644 --- a/src/hooks/projects.ts +++ b/src/hooks/projects.ts @@ -1118,7 +1118,7 @@ export function useCancelInviteLink() { useMutation({ ...baseMutationOptions(), mutationFn: async ( - { projectId, inviteUrl }: { projectId: string; inviteUrl?: string }, + { projectId, inviteId }: { projectId: string; inviteId?: string }, context, ) => { const projectApi = await context.client.query({ @@ -1128,8 +1128,8 @@ export function useCancelInviteLink() { // Have to avoid passing `undefined` explicitly // See https://github.com/digidem/rpc-reflector/issues/21 - return inviteUrl - ? projectApi.$member.cancelInviteLink(inviteUrl) + return inviteId + ? projectApi.$member.cancelInviteLink(inviteId) : projectApi.$member.cancelInviteLink() }, onSuccess: async (_data, variables, _onMutateResult, context) => { From 44edb651d07dff11eb2c7dbdf265a1baefde3585 Mon Sep 17 00:00:00 2001 From: Andrew Chou Date: Wed, 7 Oct 2026 14:33:59 -0400 Subject: [PATCH 19/33] add tests for invite link cancellation --- test/hooks/invites.test.ts | 165 +++++++++++++++++++++++++++++++++++++ 1 file changed, 165 insertions(+) diff --git a/test/hooks/invites.test.ts b/test/hooks/invites.test.ts index 38fe9a4..1939252 100644 --- a/test/hooks/invites.test.ts +++ b/test/hooks/invites.test.ts @@ -13,9 +13,11 @@ import { assert, test } from 'vitest' import { useAcceptInvite, useAcceptInviteLinkRequest, + useCancelInviteLink, useCreateInviteLink, useCreateJoinRequest, useLeaveProject, + useManyInviteLinks, useManyJoinRequests, useManyMembers, useProjectSettings, @@ -447,4 +449,167 @@ test.describe('invite over internet', () => { NotFoundError.code, ) }) + + test('invitor cancels specific invite link', async (t) => { + const testnet = await createTestnet(1) + + t.onTestFinished(() => { + return testnet.destroy() + }) + + const invitor = setupCoreIpc({ + managerOverrides: { swarm: { dht: testnet.nodes[0] } }, + }) + + t.onTestFinished(() => { + return invitor.cleanup() + }) + + await invitor.manager.setDeviceInfo({ + name: 'invitor', + deviceType: 'desktop', + }) + + const projectId = await invitor.manager.createProject({ name: 'mapeo' }) + + const invitorWrapper = createWrapper({ + clientApi: invitor.client, + }) + + const createInviteLinkHook = renderHook(() => useCreateInviteLink(), { + wrapper: invitorWrapper, + }) + const cancelInviteLinkHook = renderHook(() => useCancelInviteLink(), { + wrapper: invitorWrapper, + }) + + // 1. Invitor creates invite links + const [inviteUrl1, inviteUrl2] = await act(() => { + return Promise.all([ + createInviteLinkHook.result.current.mutateAsync({ + projectId, + roleId: MEMBER_ROLE_ID, + }), + createInviteLinkHook.result.current.mutateAsync({ + projectId, + roleId: MEMBER_ROLE_ID, + }), + ]) + }) + + const manyInviteLinksHook = renderHook( + ({ projectId }) => useManyInviteLinks({ projectId }), + { wrapper: invitorWrapper, initialProps: { projectId } }, + ) + + await waitFor(() => { + assert.strictEqual(manyInviteLinksHook.result.current.isRefetching, false) + assert.isNull(manyInviteLinksHook.result.current.error) + assert.ok(manyInviteLinksHook.result.current.data) + }) + + assert.strictEqual(manyInviteLinksHook.result.current.data.length, 2) + + // 2. Invitor cancels first invite link + + act(() => { + cancelInviteLinkHook.result.current.mutate({ + projectId, + inviteId: parseInviteURL(inviteUrl1).inviteIdString, + }) + }) + + await waitFor(() => { + assert.strictEqual(cancelInviteLinkHook.result.current.status, 'success') + }) + + // 3. Updates to relevant read hooks + await waitFor(() => { + assert.strictEqual(manyInviteLinksHook.result.current.isRefetching, false) + }) + + assert.strictEqual(manyInviteLinksHook.result.current.data.length, 1) + + assert.strictEqual( + manyInviteLinksHook.result.current.data[0]!.inviteId, + parseInviteURL(inviteUrl2).inviteIdString, + ) + }) + + test('invitor cancels all invite links', async (t) => { + const testnet = await createTestnet(1) + + t.onTestFinished(() => { + return testnet.destroy() + }) + + const invitor = setupCoreIpc({ + managerOverrides: { swarm: { dht: testnet.nodes[0] } }, + }) + + t.onTestFinished(() => { + return invitor.cleanup() + }) + + await invitor.manager.setDeviceInfo({ + name: 'invitor', + deviceType: 'desktop', + }) + + const projectId = await invitor.manager.createProject({ name: 'mapeo' }) + + const invitorWrapper = createWrapper({ + clientApi: invitor.client, + }) + + const createInviteLinkHook = renderHook(() => useCreateInviteLink(), { + wrapper: invitorWrapper, + }) + const cancelInviteLinkHook = renderHook(() => useCancelInviteLink(), { + wrapper: invitorWrapper, + }) + + // 1. Invitor creates invite links + await act(() => { + return Promise.all([ + createInviteLinkHook.result.current.mutateAsync({ + projectId, + roleId: MEMBER_ROLE_ID, + }), + createInviteLinkHook.result.current.mutateAsync({ + projectId, + roleId: MEMBER_ROLE_ID, + }), + ]) + }) + + const manyInviteLinksHook = renderHook( + ({ projectId }) => useManyInviteLinks({ projectId }), + { wrapper: invitorWrapper, initialProps: { projectId } }, + ) + + await waitFor(() => { + assert.strictEqual(manyInviteLinksHook.result.current.isRefetching, false) + assert.isNull(manyInviteLinksHook.result.current.error) + assert.ok(manyInviteLinksHook.result.current.data) + }) + + assert.strictEqual(manyInviteLinksHook.result.current.data.length, 2) + + // 2. Invitor cancels all invite links + act(() => { + cancelInviteLinkHook.result.current.mutate({ projectId }) + }) + + await waitFor(() => { + assert.strictEqual(cancelInviteLinkHook.result.current.status, 'success') + }) + + // 3. Updates to relevant read hooks + await waitFor(() => { + assert.strictEqual(manyInviteLinksHook.result.current.isRefetching, false) + }) + + assert.strictEqual(manyInviteLinksHook.result.current.data.length, 0) + }) }) From b71514c6111a5fa30998d964c3da0ef938b44b28 Mon Sep 17 00:00:00 2001 From: Andrew Chou Date: Wed, 7 Oct 2026 15:39:06 -0400 Subject: [PATCH 20/33] install hyperdht as dev dep --- package-lock.json | 7 ++++--- package.json | 1 + test/hooks/hyperdht.d.ts | 4 ++-- 3 files changed, 7 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index e6728cb..40d8c03 100644 --- a/package-lock.json +++ b/package-lock.json @@ -37,6 +37,7 @@ "fastify": "4.29.1", "globals": "17.12.0", "husky": "9.1.7", + "hyperdht": "6.34.1", "jsdom": "30.0.1", "ky": "2.1.0", "lint-staged": "17.5.0", @@ -6939,9 +6940,9 @@ } }, "node_modules/hyperdht": { - "version": "6.34.0", - "resolved": "https://registry.npmjs.org/hyperdht/-/hyperdht-6.34.0.tgz", - "integrity": "sha512-biDDqVvdznvXpS017W9xaUxLGizSZt23YItSmo69GEB4W3Cjzw/fQXHtV9vMJf770V4DqHR3GgxFjv7hBCSRCA==", + "version": "6.34.1", + "resolved": "https://registry.npmjs.org/hyperdht/-/hyperdht-6.34.1.tgz", + "integrity": "sha512-Ki7utRmmxx4PmdxKmDKCJ4AQvri+2LVBdaNvZoLMjXI0eda6ZyjlmQycOkc8va1sfoFyRF4ei2nOGbZJOdR/TA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 6a945bd..9e3d675 100644 --- a/package.json +++ b/package.json @@ -98,6 +98,7 @@ "fastify": "4.29.1", "globals": "17.12.0", "husky": "9.1.7", + "hyperdht": "6.34.1", "jsdom": "30.0.1", "ky": "2.1.0", "lint-staged": "17.5.0", diff --git a/test/hooks/hyperdht.d.ts b/test/hooks/hyperdht.d.ts index 23e7591..623ee00 100644 --- a/test/hooks/hyperdht.d.ts +++ b/test/hooks/hyperdht.d.ts @@ -15,8 +15,8 @@ declare module 'hyperdht/testnet.js' { ) => void | Promise class TestNet { - nodes: HyperDHT[] - bootstrap: string[] + nodes: Array + bootstrap: Array createNode(opts?: object): HyperDHT destroy(): Promise } From 273af077a98c6191054bd80adf672433c786fd34 Mon Sep 17 00:00:00 2001 From: Andrew Chou Date: Wed, 7 Oct 2026 15:48:11 -0400 Subject: [PATCH 21/33] add test for invitor denying join request --- test/hooks/invites.test.ts | 250 ++++++++++++++++++++++++++++++++++++- 1 file changed, 248 insertions(+), 2 deletions(-) diff --git a/test/hooks/invites.test.ts b/test/hooks/invites.test.ts index 1939252..251bb72 100644 --- a/test/hooks/invites.test.ts +++ b/test/hooks/invites.test.ts @@ -1,8 +1,12 @@ // @vitest-environment node import '../helpers/jsdom-setup.js' -import type { InviteApi } from '@comapeo/core' -import { getErrorCode, NotFoundError } from '@comapeo/core/errors.js' +import type { InviteApi, InviteLinkJoiner } from '@comapeo/core' +import { + getErrorCode, + InviteDeniedByInviterError, + NotFoundError, +} from '@comapeo/core/errors.js' import { parseInviteURL } from '@comapeo/core/invite-urls.js' import { QueryClient } from '@tanstack/react-query' import { act, renderHook, waitFor } from '@testing-library/react' @@ -16,6 +20,7 @@ import { useCancelInviteLink, useCreateInviteLink, useCreateJoinRequest, + useDenyInviteLinkRequest, useLeaveProject, useManyInviteLinks, useManyJoinRequests, @@ -450,6 +455,247 @@ test.describe('invite over internet', () => { ) }) + test( + 'invitor denies invitee join request', + { timeout: 30_000 }, + async (t) => { + // 1. Setup + const testnet = await createTestnet(2) + + t.onTestFinished(() => { + return testnet.destroy() + }) + + const invitor = setupCoreIpc({ + managerOverrides: { swarm: { dht: testnet.nodes[0] } }, + }) + + const invitee = setupCoreIpc({ + managerOverrides: { swarm: { dht: testnet.nodes[0] } }, + }) + + t.onTestFinished(async () => { + await Promise.all([invitor.cleanup(), invitee.cleanup()]) + }) + + await invitor.manager.setDeviceInfo({ + name: 'invitor', + deviceType: 'desktop', + }) + + await invitee.manager.setDeviceInfo({ + name: 'invitee', + deviceType: 'mobile', + }) + + const projectId = await invitor.manager.createProject({ name: 'mapeo' }) + + const invitorWrapper = createWrapper({ + clientApi: invitor.client, + }) + + const inviteeWrapper = createWrapper({ + clientApi: invitee.client, + }) + + const manyMembersHook = renderHook( + ({ projectId }) => useManyMembers({ projectId, includeLeft: false }), + { wrapper: invitorWrapper, initialProps: { projectId } }, + ) + + await waitFor( + () => { + assert.isNotNull(manyMembersHook.result.current) + assert.isNull(manyMembersHook.result.current.error) + assert.ok(manyMembersHook.result.current.data) + }, + { timeout: 10_000 }, + ) + + assert.strictEqual(manyMembersHook.result.current.data.length, 1) + + // 2. Invitor: create invite link and wait for Invitee to send join request + const createInviteLinkHook = renderHook(() => useCreateInviteLink(), { + wrapper: invitorWrapper, + }) + + const inviteUrl = await act(() => { + return createInviteLinkHook.result.current.mutateAsync({ + projectId, + roleId: MEMBER_ROLE_ID, + }) + }) + + const inviteIdFromUrl = parseInviteURL(inviteUrl).inviteIdString + + const deferredInviteLinkJoinRequest = Promise.withResolvers<{ + projectId: string + deviceId: string + inviteId: string + }>() + + invitor.manager.on( + 'invite-link-join-request', + (projectId, deviceId, inviteId) => { + if (inviteId !== inviteIdFromUrl) { + return + } + + deferredInviteLinkJoinRequest.resolve({ + projectId, + deviceId, + inviteId, + }) + }, + ) + + invitor.manager.on( + 'invite-link-join-request-error', + (error, deviceId, inviteId) => { + if (inviteId !== inviteIdFromUrl) { + return + } + + deferredInviteLinkJoinRequest.reject( + new Error( + `Invite link join error for device ${deviceId} and invite ${inviteId}`, + { cause: error }, + ), + ) + }, + ) + + // 3. Invitee: create invite link request and wait for it to be denied + const createJoinRequestHook = renderHook(() => useCreateJoinRequest(), { + wrapper: inviteeWrapper, + }) + const manyJoinRequestsHook = renderHook(() => useManyJoinRequests(), { + wrapper: inviteeWrapper, + }) + + const createdJoinRequest = await act(() => { + return createJoinRequestHook.result.current.mutateAsync({ + url: inviteUrl, + }) + }) + + const singleJoinRequestHook = renderHook( + ({ inviteId }) => useSingleJoinRequest({ inviteId }), + { + wrapper: inviteeWrapper, + initialProps: { inviteId: createdJoinRequest.inviteId }, + }, + ) + + await waitFor(() => { + assert.strictEqual( + manyJoinRequestsHook.result.current.isRefetching, + false, + ) + assert.isNull(manyJoinRequestsHook.result.current.error) + assert.ok(manyJoinRequestsHook.result.current.data) + + assert.strictEqual( + singleJoinRequestHook.result.current.isRefetching, + false, + ) + assert.isNull(singleJoinRequestHook.result.current.error) + assert.ok(singleJoinRequestHook.result.current.data) + }) + + assert.strictEqual(manyJoinRequestsHook.result.current.data.length, 1) + + // Changes to the `status` field are managed by internal implementation details. + { + const { status: _, ...joinRequestFromManyJoinRequestsHook } = + manyJoinRequestsHook.result.current.data[0]! + + const { status: __, ...joinRequestFromSingleJoinRequestHook } = + singleJoinRequestHook.result.current.data + + const { status: ___, ...createdJoinRequestWithoutStatus } = + createdJoinRequest + + assert.deepStrictEqual( + joinRequestFromManyJoinRequestsHook, + createdJoinRequestWithoutStatus, + ) + assert.deepStrictEqual( + joinRequestFromSingleJoinRequestHook, + createdJoinRequestWithoutStatus, + ) + } + + const deferredJoinRequestDenied = + Promise.withResolvers() + + invitee.manager.inviteLinks.on('join-request-update', (update) => { + if (update.inviteId !== createdJoinRequest.inviteId) { + return + } + + if (update.status === 'failed') { + deferredJoinRequestDenied.resolve(update) + } else { + deferredJoinRequestDenied.reject( + new Error('Unexpected join request update', { cause: update }), + ) + } + }) + + const linkJoinRequestPayload = await deferredInviteLinkJoinRequest.promise + + const denyInviteLinkRequestHook = renderHook( + () => useDenyInviteLinkRequest(), + { wrapper: invitorWrapper }, + ) + + act(() => { + denyInviteLinkRequestHook.result.current.mutate(linkJoinRequestPayload) + }) + + await waitFor( + () => { + assert.strictEqual( + denyInviteLinkRequestHook.result.current.status, + 'success', + `deny invite link request failed: ${denyInviteLinkRequestHook.result.current.error?.stack}`, + ) + }, + { timeout: 10_000 }, + ) + + const joinRequestUpdatePayload = await deferredJoinRequestDenied.promise + + assert.strictEqual( + getErrorCode(joinRequestUpdatePayload.error), + InviteDeniedByInviterError.code, + ) + + // 4. Updates to relevant read hooks + await waitFor(() => { + assert.strictEqual(manyMembersHook.result.current.isRefetching, false) + + assert.strictEqual( + manyJoinRequestsHook.result.current.isRefetching, + false, + ) + + assert.strictEqual( + singleJoinRequestHook.result.current.isRefetching, + false, + ) + }) + + assert.strictEqual(manyMembersHook.result.current.data.length, 1) + assert.strictEqual(manyJoinRequestsHook.result.current.data.length, 0) + assert.ok( + getErrorCode(singleJoinRequestHook.result.current.error), + NotFoundError.code, + ) + }, + ) + test('invitor cancels specific invite link', async (t) => { const testnet = await createTestnet(1) From deed238d96140940c225681338daaf10cb779d97 Mon Sep 17 00:00:00 2001 From: Andrew Chou Date: Wed, 7 Oct 2026 15:48:54 -0400 Subject: [PATCH 22/33] update test name --- test/hooks/invites.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/hooks/invites.test.ts b/test/hooks/invites.test.ts index 251bb72..aaf760d 100644 --- a/test/hooks/invites.test.ts +++ b/test/hooks/invites.test.ts @@ -223,7 +223,7 @@ test( ) test.describe('invite over internet', () => { - test('join from URL', { timeout: 30_000 }, async (t) => { + test('invitee joins from URL', { timeout: 30_000 }, async (t) => { // 1. Setup const testnet = await createTestnet(2) From 329979283068b78ffb26c84cc9658742cc6d4144 Mon Sep 17 00:00:00 2001 From: Andrew Chou Date: Wed, 7 Oct 2026 15:50:38 -0400 Subject: [PATCH 23/33] update useCancelJoinRequest to use invite ID instead of URL --- docs/API.md | 2 +- src/hooks/invites.ts | 14 ++++++++++---- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/docs/API.md b/docs/API.md index 472fa6d..51ec205 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1168,7 +1168,7 @@ Parameters: | Function | Type | | ---------- | ---------- | -| `useCancelJoinRequest` | `() => FilteredMutationResult>` | +| `useCancelJoinRequest` | `() => FilteredMutationResult>` | ### useMapStyleUrl diff --git a/src/hooks/invites.ts b/src/hooks/invites.ts index 7477d3c..595c563 100644 --- a/src/hooks/invites.ts +++ b/src/hooks/invites.ts @@ -283,19 +283,25 @@ FilteredMutationResult< export function useCancelJoinRequest(): // NOTE: Needs explicit return type due to TS struggles with inference (TS2883) FilteredMutationResult< - UseMutationResult + UseMutationResult > { const clientApi = useClientApi() return filterMutationResult( useMutation({ ...baseMutationOptions(), - mutationFn: async ({ url, reason }: { url: string; reason?: Error }) => { + mutationFn: async ({ + inviteId, + reason, + }: { + inviteId: string + reason?: Error + }) => { // Have to avoid passing `undefined` explicitly // See https://github.com/digidem/rpc-reflector/issues/21 return reason === undefined - ? clientApi.inviteLinks.cancelJoinRequest(url, reason) - : clientApi.inviteLinks.cancelJoinRequest(url) + ? clientApi.inviteLinks.cancelJoinRequest(inviteId, reason) + : clientApi.inviteLinks.cancelJoinRequest(inviteId) }, onSuccess: async (_data, _variables, _onMutateResult, context) => { context.client.invalidateQueries({ From 02eaa1a7e3cfe02569375da8135205e1811d9be8 Mon Sep 17 00:00:00 2001 From: Andrew Chou Date: Wed, 7 Oct 2026 15:59:59 -0400 Subject: [PATCH 24/33] add test for invitee canceling join request --- test/hooks/invites.test.ts | 162 +++++++++++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) diff --git a/test/hooks/invites.test.ts b/test/hooks/invites.test.ts index aaf760d..db80922 100644 --- a/test/hooks/invites.test.ts +++ b/test/hooks/invites.test.ts @@ -18,6 +18,7 @@ import { useAcceptInvite, useAcceptInviteLinkRequest, useCancelInviteLink, + useCancelJoinRequest, useCreateInviteLink, useCreateJoinRequest, useDenyInviteLinkRequest, @@ -858,4 +859,165 @@ test.describe('invite over internet', () => { assert.strictEqual(manyInviteLinksHook.result.current.data.length, 0) }) + + test('invitee cancels join request', { timeout: 30_000 }, async (t) => { + // 1. Setup + const testnet = await createTestnet(2) + + t.onTestFinished(() => { + return testnet.destroy() + }) + + const invitor = setupCoreIpc({ + managerOverrides: { swarm: { dht: testnet.nodes[0] } }, + }) + + const invitee = setupCoreIpc({ + managerOverrides: { swarm: { dht: testnet.nodes[0] } }, + }) + + t.onTestFinished(async () => { + await Promise.all([invitor.cleanup(), invitee.cleanup()]) + }) + + await invitor.manager.setDeviceInfo({ + name: 'invitor', + deviceType: 'desktop', + }) + + await invitee.manager.setDeviceInfo({ + name: 'invitee', + deviceType: 'mobile', + }) + + const projectId = await invitor.manager.createProject({ name: 'mapeo' }) + + const invitorWrapper = createWrapper({ + clientApi: invitor.client, + }) + + const inviteeWrapper = createWrapper({ + clientApi: invitee.client, + }) + + const manyMembersHook = renderHook( + ({ projectId }) => useManyMembers({ projectId, includeLeft: false }), + { wrapper: invitorWrapper, initialProps: { projectId } }, + ) + + await waitFor( + () => { + assert.isNotNull(manyMembersHook.result.current) + assert.isNull(manyMembersHook.result.current.error) + assert.ok(manyMembersHook.result.current.data) + }, + { timeout: 10_000 }, + ) + + assert.strictEqual(manyMembersHook.result.current.data.length, 1) + + // 2. Invitor: create invite link + const createInviteLinkHook = renderHook(() => useCreateInviteLink(), { + wrapper: invitorWrapper, + }) + + const inviteUrl = await act(() => { + return createInviteLinkHook.result.current.mutateAsync({ + projectId, + roleId: MEMBER_ROLE_ID, + }) + }) + + // 3. Invitee: create join request + const createJoinRequestHook = renderHook(() => useCreateJoinRequest(), { + wrapper: inviteeWrapper, + }) + const manyJoinRequestsHook = renderHook(() => useManyJoinRequests(), { + wrapper: inviteeWrapper, + }) + + const createdJoinRequest = await act(() => { + return createJoinRequestHook.result.current.mutateAsync({ + url: inviteUrl, + }) + }) + + const singleJoinRequestHook = renderHook( + ({ inviteId }) => useSingleJoinRequest({ inviteId }), + { + wrapper: inviteeWrapper, + initialProps: { inviteId: createdJoinRequest.inviteId }, + }, + ) + + await waitFor(() => { + assert.strictEqual( + manyJoinRequestsHook.result.current.isRefetching, + false, + ) + assert.isNull(manyJoinRequestsHook.result.current.error) + assert.ok(manyJoinRequestsHook.result.current.data) + + assert.strictEqual( + singleJoinRequestHook.result.current.isRefetching, + false, + ) + assert.isNull(singleJoinRequestHook.result.current.error) + assert.ok(singleJoinRequestHook.result.current.data) + }) + + assert.strictEqual(manyJoinRequestsHook.result.current.data.length, 1) + + // Changes to the `status` field are managed by internal implementation details. + { + const { status: __, ...joinRequestFromSingleJoinRequestHook } = + singleJoinRequestHook.result.current.data + + const { status: ___, ...createdJoinRequestWithoutStatus } = + createdJoinRequest + + assert.deepStrictEqual( + joinRequestFromSingleJoinRequestHook, + createdJoinRequestWithoutStatus, + ) + } + + // 4. Invitee: cancel join request + const cancelJoinRequestHook = renderHook(() => useCancelJoinRequest(), { + wrapper: inviteeWrapper, + }) + + act(() => { + cancelJoinRequestHook.result.current.mutate({ + inviteId: createdJoinRequest.inviteId, + }) + }) + + await waitFor(() => { + assert.strictEqual( + cancelJoinRequestHook.result.current.status, + 'success', + `cancel join request failed: ${cancelJoinRequestHook.result.current.error?.stack}`, + ) + }) + + // 4. Updates to relevant read hooks + await waitFor(() => { + assert.strictEqual( + manyJoinRequestsHook.result.current.isRefetching, + false, + ) + + assert.strictEqual( + singleJoinRequestHook.result.current.isRefetching, + false, + ) + }) + + assert.strictEqual(manyJoinRequestsHook.result.current.data.length, 0) + assert.ok( + getErrorCode(singleJoinRequestHook.result.current.error), + NotFoundError.code, + ) + }) }) From bd80ba78e5efe1260919e2461ae7713c8a892159 Mon Sep 17 00:00:00 2001 From: Andrew Chou Date: Wed, 7 Oct 2026 16:00:05 -0400 Subject: [PATCH 25/33] test cleanup --- test/hooks/invites.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/test/hooks/invites.test.ts b/test/hooks/invites.test.ts index db80922..a7e628b 100644 --- a/test/hooks/invites.test.ts +++ b/test/hooks/invites.test.ts @@ -331,7 +331,7 @@ test.describe('invite over internet', () => { }, ) - // 3. Invitee: create invite link request and wait for it to be accepted and completed + // 3. Invitee: create join request and wait for it to be accepted and completed const createJoinRequestHook = renderHook(() => useCreateJoinRequest(), { wrapper: inviteeWrapper, }) @@ -566,7 +566,7 @@ test.describe('invite over internet', () => { }, ) - // 3. Invitee: create invite link request and wait for it to be denied + // 3. Invitee: create join request and wait for it to be denied const createJoinRequestHook = renderHook(() => useCreateJoinRequest(), { wrapper: inviteeWrapper, }) @@ -758,7 +758,6 @@ test.describe('invite over internet', () => { assert.strictEqual(manyInviteLinksHook.result.current.data.length, 2) // 2. Invitor cancels first invite link - act(() => { cancelInviteLinkHook.result.current.mutate({ projectId, From 6d982cda5c7ebbdeb0b2f5049799638e38979d8e Mon Sep 17 00:00:00 2001 From: Andrew Chou Date: Wed, 7 Oct 2026 16:02:51 -0400 Subject: [PATCH 26/33] more test fixes --- test/hooks/invites.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/test/hooks/invites.test.ts b/test/hooks/invites.test.ts index a7e628b..b4f1777 100644 --- a/test/hooks/invites.test.ts +++ b/test/hooks/invites.test.ts @@ -969,12 +969,19 @@ test.describe('invite over internet', () => { // Changes to the `status` field are managed by internal implementation details. { + const { status: _, ...joinRequestFromManyJoinRequestsHook } = + manyJoinRequestsHook.result.current.data[0]! + const { status: __, ...joinRequestFromSingleJoinRequestHook } = singleJoinRequestHook.result.current.data const { status: ___, ...createdJoinRequestWithoutStatus } = createdJoinRequest + assert.deepStrictEqual( + joinRequestFromManyJoinRequestsHook, + createdJoinRequestWithoutStatus, + ) assert.deepStrictEqual( joinRequestFromSingleJoinRequestHook, createdJoinRequestWithoutStatus, From b7d3068c5786d83c7ace5ed4538be7b1ea4432b6 Mon Sep 17 00:00:00 2001 From: Andrew Chou Date: Thu, 8 Oct 2026 10:58:56 -0400 Subject: [PATCH 27/33] more reliable read hook assertions after write hook calls --- test/hooks/invites.test.ts | 80 ++++++++++---------------------------- 1 file changed, 21 insertions(+), 59 deletions(-) diff --git a/test/hooks/invites.test.ts b/test/hooks/invites.test.ts index b4f1777..7536b41 100644 --- a/test/hooks/invites.test.ts +++ b/test/hooks/invites.test.ts @@ -435,25 +435,13 @@ test.describe('invite over internet', () => { // 4. Updates to relevant read hooks await waitFor(() => { - assert.strictEqual(manyMembersHook.result.current.isRefetching, false) - - assert.strictEqual( - manyJoinRequestsHook.result.current.isRefetching, - false, - ) - - assert.strictEqual( - singleJoinRequestHook.result.current.isRefetching, - false, + assert.strictEqual(manyMembersHook.result.current.data.length, 2) + assert.strictEqual(manyJoinRequestsHook.result.current.data.length, 0) + assert.ok( + getErrorCode(singleJoinRequestHook.result.current.error), + NotFoundError.code, ) }) - - assert.strictEqual(manyMembersHook.result.current.data.length, 2) - assert.strictEqual(manyJoinRequestsHook.result.current.data.length, 0) - assert.ok( - getErrorCode(singleJoinRequestHook.result.current.error), - NotFoundError.code, - ) }) test( @@ -675,25 +663,13 @@ test.describe('invite over internet', () => { // 4. Updates to relevant read hooks await waitFor(() => { - assert.strictEqual(manyMembersHook.result.current.isRefetching, false) - - assert.strictEqual( - manyJoinRequestsHook.result.current.isRefetching, - false, - ) - - assert.strictEqual( - singleJoinRequestHook.result.current.isRefetching, - false, + assert.strictEqual(manyMembersHook.result.current.data.length, 1) + assert.strictEqual(manyJoinRequestsHook.result.current.data.length, 0) + assert.ok( + getErrorCode(singleJoinRequestHook.result.current.error), + NotFoundError.code, ) }) - - assert.strictEqual(manyMembersHook.result.current.data.length, 1) - assert.strictEqual(manyJoinRequestsHook.result.current.data.length, 0) - assert.ok( - getErrorCode(singleJoinRequestHook.result.current.error), - NotFoundError.code, - ) }, ) @@ -771,15 +747,13 @@ test.describe('invite over internet', () => { // 3. Updates to relevant read hooks await waitFor(() => { - assert.strictEqual(manyInviteLinksHook.result.current.isRefetching, false) - }) - - assert.strictEqual(manyInviteLinksHook.result.current.data.length, 1) + assert.strictEqual(manyInviteLinksHook.result.current.data.length, 1) - assert.strictEqual( - manyInviteLinksHook.result.current.data[0]!.inviteId, - parseInviteURL(inviteUrl2).inviteIdString, - ) + assert.strictEqual( + manyInviteLinksHook.result.current.data[0]!.inviteId, + parseInviteURL(inviteUrl2).inviteIdString, + ) + }) }) test('invitor cancels all invite links', async (t) => { @@ -853,10 +827,8 @@ test.describe('invite over internet', () => { // 3. Updates to relevant read hooks await waitFor(() => { - assert.strictEqual(manyInviteLinksHook.result.current.isRefetching, false) + assert.strictEqual(manyInviteLinksHook.result.current.data.length, 0) }) - - assert.strictEqual(manyInviteLinksHook.result.current.data.length, 0) }) test('invitee cancels join request', { timeout: 30_000 }, async (t) => { @@ -1009,21 +981,11 @@ test.describe('invite over internet', () => { // 4. Updates to relevant read hooks await waitFor(() => { - assert.strictEqual( - manyJoinRequestsHook.result.current.isRefetching, - false, - ) - - assert.strictEqual( - singleJoinRequestHook.result.current.isRefetching, - false, + assert.strictEqual(manyJoinRequestsHook.result.current.data.length, 0) + assert.ok( + getErrorCode(singleJoinRequestHook.result.current.error), + NotFoundError.code, ) }) - - assert.strictEqual(manyJoinRequestsHook.result.current.data.length, 0) - assert.ok( - getErrorCode(singleJoinRequestHook.result.current.error), - NotFoundError.code, - ) }) }) From ae43b63778eb0438c9d18160deb0f34b4a24e16d Mon Sep 17 00:00:00 2001 From: Andrew Chou Date: Thu, 8 Oct 2026 14:58:36 -0400 Subject: [PATCH 28/33] add some docs --- docs/API.md | 58 +++++++++++++++++++++++++- src/hooks/invites.ts | 64 ++++++++++++++++++++++------- src/hooks/projects.ts | 96 ++++++++++++++++++++++++++++--------------- 3 files changed, 170 insertions(+), 48 deletions(-) diff --git a/docs/API.md b/docs/API.md index 51ec205..85fde24 100644 --- a/docs/API.md +++ b/docs/API.md @@ -844,30 +844,56 @@ Parameters: ### useManyInviteLinks +Get all active invite links. + | Function | Type | | ---------- | ---------- | | `useManyInviteLinks` | `({ projectId, }: { projectId: string; }) => Pick, "data" or "error" or "isRefetching">` | ### useCreateInviteLink +Create an invite link for a project. + | Function | Type | | ---------- | ---------- | | `useCreateInviteLink` | `() => FilteredMutationResult>>` | ### useCancelInviteLink +Cancel a created invite link or all invite links for a project. If `inviteId` is not specified, +all invite links are cancelled. + | Function | Type | | ---------- | ---------- | | `useCancelInviteLink` | `() => Pick, { mutate: UseMutateFunction; }> and { ...; }, "error" or ... 3 more ... or "mutateAsync"> or Pick<...> or Pick<...> or Pick<...>` | +Examples: + +ts``` +function Example() { + const cancelInviteLink = useCancelInviteLink() + + // Cancel a specific invite link + cancelInviteLink.mutate({ projectId: '...', inviteId: '...' }) + + // Cancel all invite links + cancelInviteLink.mutate({ projectId: '...' }) +} +``` + + ### useAcceptInviteLinkRequest +Accept an invite link join request for a project. + | Function | Type | | ---------- | ---------- | | `useAcceptInviteLinkRequest` | `() => FilteredMutationResult>` | ### useDenyInviteLinkRequest +Deny an invite link join request for a project. + | Function | Type | | ---------- | ---------- | | `useDenyInviteLinkRequest` | `() => FilteredMutationResult>` | @@ -1148,27 +1174,57 @@ Parameters: ### useManyJoinRequests +Get all active join requests. + | Function | Type | | ---------- | ---------- | | `useManyJoinRequests` | `() => Pick, "data" or "error" or "isRefetching">` | ### useSingleJoinRequest +Get an active join request. + +If the join request is no longer active after using this hook, the read result +will update such that the `error` field is populated with the relevant error from core. + | Function | Type | | ---------- | ---------- | | `useSingleJoinRequest` | `({ inviteId, }: { inviteId: string; }) => Pick, "data" or "error" or "isRefetching">` | +Parameters: + +* `opts.inviteId`: ID of relevant invite. + + +Examples: + +```ts +import { getErrorCode } from '@comapeo/core/errors.js' + +function ErrorExample({ inviteId }) { + const joinRequestQuery = useSingleJoinRequest({ inviteId }) + + if (joinRequestQuery.error) { + const comapeoErrorCode = getErrorCode(joinRequestQuery.error) + + // Match error code with errors from + + ### useCreateJoinRequest +Create a join request for a received invite. + | Function | Type | | ---------- | ---------- | | `useCreateJoinRequest` | `() => FilteredMutationResult>` | ### useCancelJoinRequest +Cancel a join request that has been created for a received invite. + | Function | Type | | ---------- | ---------- | -| `useCancelJoinRequest` | `() => FilteredMutationResult>` | +| `useCancelJoinRequest` | `() => FilteredMutationResult>` | ### useMapStyleUrl diff --git a/src/hooks/invites.ts b/src/hooks/invites.ts index 595c563..d3b95e1 100644 --- a/src/hooks/invites.ts +++ b/src/hooks/invites.ts @@ -206,6 +206,9 @@ export function useRequestCancelInvite({ projectId }: { projectId: string }) { ) } +/** + * Get all active join requests. + */ export function useManyJoinRequests(): // NOTE: Needs explicit return type due to TS2742 Pick< UseSuspenseQueryResult>, @@ -224,6 +227,30 @@ Pick< return { data, error, isRefetching } } +/** + * Get an active join request. + * + * If the join request is no longer active after using this hook, the read result + * will update such that the `error` field is populated with the relevant error from core. + * + * @param opts.inviteId ID of relevant invite. + * + * @example + * ```ts + * import { getErrorCode } from '@comapeo/core/errors.js' + * + * function ErrorExample({ inviteId }) { + * const joinRequestQuery = useSingleJoinRequest({ inviteId }) + * + * if (joinRequestQuery.error) { + * const comapeoErrorCode = getErrorCode(joinRequestQuery.error) + * + * // Match error code with errors from @comapeo/core and handle as needed... + * } + * } + * ``` + * + */ export function useSingleJoinRequest({ inviteId, }: { @@ -246,12 +273,20 @@ Pick< return { data, error, isRefetching } } +/** + * Create a join request for a received invite. + */ export function useCreateJoinRequest(): // NOTE: Needs explicit return type due to TS struggles with inference (TS2883) FilteredMutationResult< UseMutationResult< InviteLinkJoiner.JoinRequest, Error, - { url: string; timeout?: number } + { + /** URL for the received invite link. */ + url: string + /** Connection timeout in ms. */ + timeout?: number + } > > { const clientApi = useClientApi() @@ -281,27 +316,28 @@ FilteredMutationResult< ) } +/** + * Cancel a join request that has been created for a received invite. + */ export function useCancelJoinRequest(): // NOTE: Needs explicit return type due to TS struggles with inference (TS2883) FilteredMutationResult< - UseMutationResult + UseMutationResult< + void, + Error, + // TODO: Expose reason? + { + /** ID of invite to cancel request for. */ + inviteId: string + } + > > { const clientApi = useClientApi() return filterMutationResult( useMutation({ ...baseMutationOptions(), - mutationFn: async ({ - inviteId, - reason, - }: { - inviteId: string - reason?: Error - }) => { - // Have to avoid passing `undefined` explicitly - // See https://github.com/digidem/rpc-reflector/issues/21 - return reason === undefined - ? clientApi.inviteLinks.cancelJoinRequest(inviteId, reason) - : clientApi.inviteLinks.cancelJoinRequest(inviteId) + mutationFn: async ({ inviteId }) => { + return clientApi.inviteLinks.cancelJoinRequest(inviteId) }, onSuccess: async (_data, _variables, _onMutateResult, context) => { context.client.invalidateQueries({ diff --git a/src/hooks/projects.ts b/src/hooks/projects.ts index 7ee7e8a..5475ca0 100644 --- a/src/hooks/projects.ts +++ b/src/hooks/projects.ts @@ -1059,6 +1059,9 @@ export function useExportZipFile({ projectId }: { projectId: string }) { ) } +/** + * Get all active invite links. + */ export function useManyInviteLinks({ projectId, }: { @@ -1081,12 +1084,18 @@ Pick< return { data, error, isRefetching } } +/** + * Create an invite link for a project. + */ export function useCreateInviteLink(): // NOTE: Needs explicit return type due to TS struggles with inference (TS2883) FilteredMutationResult< UseMutationResult< string, Error, - { projectId: string } & Omit + { + /** Public ID of relevant project. */ + projectId: string + } & Omit > > { const clientApi = useClientApi() @@ -1111,16 +1120,39 @@ FilteredMutationResult< ) } +/** + * Cancel a created invite link or all invite links for a project. If `inviteId` is not specified, + * all invite links are cancelled. + * + * @example + * ts``` + * function Example() { + * const cancelInviteLink = useCancelInviteLink() + * + * // Cancel a specific invite link + * cancelInviteLink.mutate({ projectId: '...', inviteId: '...' }) + * + * // Cancel all invite links + * cancelInviteLink.mutate({ projectId: '...' }) + * } + * ``` + */ export function useCancelInviteLink() { const clientApi = useClientApi() return filterMutationResult( - useMutation({ + useMutation< + void, + Error, + { + /** Public ID of relevant project. */ + projectId: string + /** ID of relevant invite. */ + inviteId?: string + } + >({ ...baseMutationOptions(), - mutationFn: async ( - { projectId, inviteId }: { projectId: string; inviteId?: string }, - context, - ) => { + mutationFn: async ({ projectId, inviteId }, context) => { const projectApi = await context.client.query({ ...getProjectByIdQueryOptions({ clientApi, projectId }), staleTime: 'static', @@ -1141,12 +1173,22 @@ export function useCancelInviteLink() { ) } +/** + * Accept an invite link join request for a project. + */ export function useAcceptInviteLinkRequest(): // NOTE: Needs explicit return type due to TS struggles with inference (TS2883) FilteredMutationResult< UseMutationResult< MemberApi.InviteDecision, Error, - { projectId: string; inviteId: string; deviceId: string } + { + /** Public ID of relevant project. */ + projectId: string + /** ID of relevant invite. */ + inviteId: string + /** ID of device who sent join request. */ + deviceId: string + } > > { const clientApi = useClientApi() @@ -1154,18 +1196,7 @@ FilteredMutationResult< return filterMutationResult( useMutation({ ...baseMutationOptions(), - mutationFn: async ( - { - projectId, - inviteId, - deviceId, - }: { - projectId: string - inviteId: string - deviceId: string - }, - context, - ) => { + mutationFn: async ({ projectId, inviteId, deviceId }, context) => { const projectApi = await context.client.query({ ...getProjectByIdQueryOptions({ clientApi, projectId }), staleTime: 'static', @@ -1185,12 +1216,23 @@ FilteredMutationResult< ) } +/** + * Deny an invite link join request for a project. + */ export function useDenyInviteLinkRequest(): // NOTE: Needs explicit return type due to TS struggles with inference (TS2883) FilteredMutationResult< UseMutationResult< void, Error, - { projectId: string; inviteId: string; deviceId: string } + // TODO: Expose reason? + { + /** Public ID of relevant project. */ + projectId: string + /** ID of relevant invite. */ + inviteId: string + /** ID of device who sent join request. */ + deviceId: string + } > > { const clientApi = useClientApi() @@ -1198,19 +1240,7 @@ FilteredMutationResult< return filterMutationResult( useMutation({ ...baseMutationOptions(), - mutationFn: async ( - { - projectId, - inviteId, - deviceId, - }: { - projectId: string - inviteId: string - deviceId: string - // TODO: Expose reason? - }, - context, - ) => { + mutationFn: async ({ projectId, inviteId, deviceId }, context) => { const projectApi = await context.client.query({ ...getProjectByIdQueryOptions({ clientApi, projectId }), staleTime: 'static', From 36c7b124aaa6af36e2b19ad1da08f00ddfe98c9d Mon Sep 17 00:00:00 2001 From: Andrew Chou Date: Mon, 26 Oct 2026 11:18:51 -0400 Subject: [PATCH 29/33] update core --- package-lock.json | 10 +++++----- package.json | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1c74bf9..57ba00b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,7 +15,7 @@ "type-fest": "^5.9.0" }, "devDependencies": { - "@comapeo/core": "7.6.1", + "@comapeo/core": "7.7.0", "@comapeo/ipc": "11.0.0", "@eslint/js": "10.0.1", "@ianvs/prettier-plugin-sort-imports": "4.7.1", @@ -55,7 +55,7 @@ "vitest": "5.0.5" }, "peerDependencies": { - "@comapeo/core": "^7.6.1", + "@comapeo/core": "^7.7.0", "@comapeo/ipc": "^11.0.0", "@tanstack/react-query": "^5.102.8", "react": "^18 || ^19" @@ -471,9 +471,9 @@ } }, "node_modules/@comapeo/core": { - "version": "7.6.1", - "resolved": "https://registry.npmjs.org/@comapeo/core/-/core-7.6.1.tgz", - "integrity": "sha512-m+ZlqYOeMS43frI2I4b65fLJdyw7TiB+oDqkah+itToyWO//nbNUoS94rctBCM+GJgAAewgi+LSemuEHmwjBzg==", + "version": "7.7.0", + "resolved": "https://registry.npmjs.org/@comapeo/core/-/core-7.7.0.tgz", + "integrity": "sha512-4UqmfRJdHUAg+wR88yc687iSf9gqNA1NaYtzjubVMVNB9YlUh6Czv3l+8g0XOdfgXT8jZTucAPuEGf8P1VsQMw==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 0c93917..ea4c2ef 100644 --- a/package.json +++ b/package.json @@ -70,13 +70,13 @@ "type-fest": "^5.9.0" }, "peerDependencies": { - "@comapeo/core": "^7.6.1", + "@comapeo/core": "^7.7.0", "@comapeo/ipc": "^11.0.0", "@tanstack/react-query": "^5.102.8", "react": "^18 || ^19" }, "devDependencies": { - "@comapeo/core": "7.6.1", + "@comapeo/core": "7.7.0", "@comapeo/ipc": "11.0.0", "@eslint/js": "10.0.1", "@ianvs/prettier-plugin-sort-imports": "4.7.1", From 23b444a2b44c6edf4885b335f102e24c8db8d61a Mon Sep 17 00:00:00 2001 From: Andrew Chou Date: Mon, 26 Oct 2026 11:18:57 -0400 Subject: [PATCH 30/33] clean up test --- test/hooks/invites.test.ts | 376 ++++++++++++++++++------------------- 1 file changed, 188 insertions(+), 188 deletions(-) diff --git a/test/hooks/invites.test.ts b/test/hooks/invites.test.ts index bed3622..fc137d7 100644 --- a/test/hooks/invites.test.ts +++ b/test/hooks/invites.test.ts @@ -223,7 +223,7 @@ test( ) test.describe('invite over internet', () => { - test('invitee joins from URL', { timeout: 30_000 }, async (t) => { + test('invitee joins from URL', async (t) => { // 1. Setup const testnet = await createTestnet(2) @@ -297,11 +297,12 @@ test.describe('invite over internet', () => { projectId: string deviceId: string inviteId: string + name: string }>() invitor.manager.on( 'invite-link-join-request', - (projectId, deviceId, inviteId) => { + (projectId, deviceId, inviteId, name) => { if (inviteId !== inviteIdFromUrl) { return } @@ -310,6 +311,7 @@ test.describe('invite over internet', () => { projectId, deviceId, inviteId, + name, }) }, ) @@ -443,234 +445,232 @@ test.describe('invite over internet', () => { }) }) - test( - 'invitor denies invitee join request', - { timeout: 30_000 }, - async (t) => { - // 1. Setup - const testnet = await createTestnet(2) + test('invitor denies invitee join request', async (t) => { + // 1. Setup + const testnet = await createTestnet(2) - t.onTestFinished(() => { - return testnet.destroy() - }) + t.onTestFinished(() => { + return testnet.destroy() + }) - const invitor = setupCoreIpc({ - managerOverrides: { swarm: { dht: testnet.nodes[0] } }, - }) + const invitor = setupCoreIpc({ + managerOverrides: { swarm: { dht: testnet.nodes[0] } }, + }) - const invitee = setupCoreIpc({ - managerOverrides: { swarm: { dht: testnet.nodes[0] } }, - }) + const invitee = setupCoreIpc({ + managerOverrides: { swarm: { dht: testnet.nodes[0] } }, + }) - t.onTestFinished(async () => { - await Promise.all([invitor.cleanup(), invitee.cleanup()]) - }) + t.onTestFinished(async () => { + await Promise.all([invitor.cleanup(), invitee.cleanup()]) + }) - await invitor.manager.setDeviceInfo({ - name: 'invitor', - deviceType: 'desktop', - }) + await invitor.manager.setDeviceInfo({ + name: 'invitor', + deviceType: 'desktop', + }) - await invitee.manager.setDeviceInfo({ - name: 'invitee', - deviceType: 'mobile', - }) + await invitee.manager.setDeviceInfo({ + name: 'invitee', + deviceType: 'mobile', + }) - const projectId = await invitor.manager.createProject({ name: 'mapeo' }) + const projectId = await invitor.manager.createProject({ name: 'mapeo' }) - const invitorWrapper = createWrapper({ - clientApi: invitor.client, - }) + const invitorWrapper = createWrapper({ + clientApi: invitor.client, + }) - const inviteeWrapper = createWrapper({ - clientApi: invitee.client, - }) + const inviteeWrapper = createWrapper({ + clientApi: invitee.client, + }) - const manyMembersHook = renderHook( - ({ projectId }) => useManyMembers({ projectId, includeLeft: false }), - { wrapper: invitorWrapper, initialProps: { projectId } }, - ) + const manyMembersHook = renderHook( + ({ projectId }) => useManyMembers({ projectId, includeLeft: false }), + { wrapper: invitorWrapper, initialProps: { projectId } }, + ) - await waitFor( - () => { - assert.isNotNull(manyMembersHook.result.current) - assert.isNull(manyMembersHook.result.current.error) - assert.ok(manyMembersHook.result.current.data) - }, - { timeout: 10_000 }, - ) + await waitFor( + () => { + assert.isNotNull(manyMembersHook.result.current) + assert.isNull(manyMembersHook.result.current.error) + assert.ok(manyMembersHook.result.current.data) + }, + { timeout: 10_000 }, + ) - assert.strictEqual(manyMembersHook.result.current.data.length, 1) + assert.strictEqual(manyMembersHook.result.current.data.length, 1) - // 2. Invitor: create invite link and wait for Invitee to send join request - const createInviteLinkHook = renderHook(() => useCreateInviteLink(), { - wrapper: invitorWrapper, + // 2. Invitor: create invite link and wait for Invitee to send join request + const createInviteLinkHook = renderHook(() => useCreateInviteLink(), { + wrapper: invitorWrapper, + }) + + const inviteUrl = await act(() => { + return createInviteLinkHook.result.current.mutateAsync({ + projectId, + roleId: MEMBER_ROLE_ID, }) + }) + + const inviteIdFromUrl = parseInviteURL(inviteUrl).inviteIdString + + const deferredInviteLinkJoinRequest = Promise.withResolvers<{ + projectId: string + deviceId: string + inviteId: string + name: string + }>() + + invitor.manager.on( + 'invite-link-join-request', + (projectId, deviceId, inviteId, name) => { + if (inviteId !== inviteIdFromUrl) { + return + } - const inviteUrl = await act(() => { - return createInviteLinkHook.result.current.mutateAsync({ + deferredInviteLinkJoinRequest.resolve({ projectId, - roleId: MEMBER_ROLE_ID, + deviceId, + inviteId, + name, }) - }) + }, + ) - const inviteIdFromUrl = parseInviteURL(inviteUrl).inviteIdString - - const deferredInviteLinkJoinRequest = Promise.withResolvers<{ - projectId: string - deviceId: string - inviteId: string - }>() - - invitor.manager.on( - 'invite-link-join-request', - (projectId, deviceId, inviteId) => { - if (inviteId !== inviteIdFromUrl) { - return - } - - deferredInviteLinkJoinRequest.resolve({ - projectId, - deviceId, - inviteId, - }) - }, - ) + invitor.manager.on( + 'invite-link-join-request-error', + (error, deviceId, inviteId) => { + if (inviteId !== inviteIdFromUrl) { + return + } - invitor.manager.on( - 'invite-link-join-request-error', - (error, deviceId, inviteId) => { - if (inviteId !== inviteIdFromUrl) { - return - } - - deferredInviteLinkJoinRequest.reject( - new Error( - `Invite link join error for device ${deviceId} and invite ${inviteId}`, - { cause: error }, - ), - ) - }, - ) + deferredInviteLinkJoinRequest.reject( + new Error( + `Invite link join error for device ${deviceId} and invite ${inviteId}`, + { cause: error }, + ), + ) + }, + ) - // 3. Invitee: create join request and wait for it to be denied - const createJoinRequestHook = renderHook(() => useCreateJoinRequest(), { - wrapper: inviteeWrapper, + // 3. Invitee: create join request and wait for it to be denied + const createJoinRequestHook = renderHook(() => useCreateJoinRequest(), { + wrapper: inviteeWrapper, + }) + const manyJoinRequestsHook = renderHook(() => useManyJoinRequests(), { + wrapper: inviteeWrapper, + }) + + const createdJoinRequest = await act(() => { + return createJoinRequestHook.result.current.mutateAsync({ + url: inviteUrl, }) - const manyJoinRequestsHook = renderHook(() => useManyJoinRequests(), { + }) + + const singleJoinRequestHook = renderHook( + ({ inviteId }) => useSingleJoinRequest({ inviteId }), + { wrapper: inviteeWrapper, - }) + initialProps: { inviteId: createdJoinRequest.inviteId }, + }, + ) - const createdJoinRequest = await act(() => { - return createJoinRequestHook.result.current.mutateAsync({ - url: inviteUrl, - }) - }) + await waitFor(() => { + assert.strictEqual( + manyJoinRequestsHook.result.current.isRefetching, + false, + ) + assert.isNull(manyJoinRequestsHook.result.current.error) + assert.ok(manyJoinRequestsHook.result.current.data) - const singleJoinRequestHook = renderHook( - ({ inviteId }) => useSingleJoinRequest({ inviteId }), - { - wrapper: inviteeWrapper, - initialProps: { inviteId: createdJoinRequest.inviteId }, - }, + assert.strictEqual( + singleJoinRequestHook.result.current.isRefetching, + false, ) + assert.isNull(singleJoinRequestHook.result.current.error) + assert.ok(singleJoinRequestHook.result.current.data) + }) - await waitFor(() => { - assert.strictEqual( - manyJoinRequestsHook.result.current.isRefetching, - false, - ) - assert.isNull(manyJoinRequestsHook.result.current.error) - assert.ok(manyJoinRequestsHook.result.current.data) + assert.strictEqual(manyJoinRequestsHook.result.current.data.length, 1) - assert.strictEqual( - singleJoinRequestHook.result.current.isRefetching, - false, - ) - assert.isNull(singleJoinRequestHook.result.current.error) - assert.ok(singleJoinRequestHook.result.current.data) - }) + // Changes to the `status` field are managed by internal implementation details. + { + const { status: _, ...joinRequestFromManyJoinRequestsHook } = + manyJoinRequestsHook.result.current.data[0]! - assert.strictEqual(manyJoinRequestsHook.result.current.data.length, 1) + const { status: __, ...joinRequestFromSingleJoinRequestHook } = + singleJoinRequestHook.result.current.data - // Changes to the `status` field are managed by internal implementation details. - { - const { status: _, ...joinRequestFromManyJoinRequestsHook } = - manyJoinRequestsHook.result.current.data[0]! + const { status: ___, ...createdJoinRequestWithoutStatus } = + createdJoinRequest + + assert.deepStrictEqual( + joinRequestFromManyJoinRequestsHook, + createdJoinRequestWithoutStatus, + ) + assert.deepStrictEqual( + joinRequestFromSingleJoinRequestHook, + createdJoinRequestWithoutStatus, + ) + } - const { status: __, ...joinRequestFromSingleJoinRequestHook } = - singleJoinRequestHook.result.current.data + const deferredJoinRequestDenied = + Promise.withResolvers() - const { status: ___, ...createdJoinRequestWithoutStatus } = - createdJoinRequest + invitee.manager.inviteLinks.on('join-request-update', (update) => { + if (update.inviteId !== createdJoinRequest.inviteId) { + return + } - assert.deepStrictEqual( - joinRequestFromManyJoinRequestsHook, - createdJoinRequestWithoutStatus, - ) - assert.deepStrictEqual( - joinRequestFromSingleJoinRequestHook, - createdJoinRequestWithoutStatus, + if (update.status === 'failed') { + deferredJoinRequestDenied.resolve(update) + } else { + deferredJoinRequestDenied.reject( + new Error('Unexpected join request update', { cause: update }), ) } + }) - const deferredJoinRequestDenied = - Promise.withResolvers() - - invitee.manager.inviteLinks.on('join-request-update', (update) => { - if (update.inviteId !== createdJoinRequest.inviteId) { - return - } - - if (update.status === 'failed') { - deferredJoinRequestDenied.resolve(update) - } else { - deferredJoinRequestDenied.reject( - new Error('Unexpected join request update', { cause: update }), - ) - } - }) + const linkJoinRequestPayload = await deferredInviteLinkJoinRequest.promise - const linkJoinRequestPayload = await deferredInviteLinkJoinRequest.promise + const denyInviteLinkRequestHook = renderHook( + () => useDenyInviteLinkRequest(), + { wrapper: invitorWrapper }, + ) - const denyInviteLinkRequestHook = renderHook( - () => useDenyInviteLinkRequest(), - { wrapper: invitorWrapper }, - ) + act(() => { + denyInviteLinkRequestHook.result.current.mutate(linkJoinRequestPayload) + }) - act(() => { - denyInviteLinkRequestHook.result.current.mutate(linkJoinRequestPayload) - }) + await waitFor( + () => { + assert.strictEqual( + denyInviteLinkRequestHook.result.current.status, + 'success', + `deny invite link request failed: ${denyInviteLinkRequestHook.result.current.error?.stack}`, + ) + }, + { timeout: 10_000 }, + ) - await waitFor( - () => { - assert.strictEqual( - denyInviteLinkRequestHook.result.current.status, - 'success', - `deny invite link request failed: ${denyInviteLinkRequestHook.result.current.error?.stack}`, - ) - }, - { timeout: 10_000 }, - ) + const joinRequestUpdatePayload = await deferredJoinRequestDenied.promise - const joinRequestUpdatePayload = await deferredJoinRequestDenied.promise + assert.strictEqual( + getErrorCode(joinRequestUpdatePayload.error), + InviteDeniedByInviterError.code, + ) - assert.strictEqual( - getErrorCode(joinRequestUpdatePayload.error), - InviteDeniedByInviterError.code, + // 4. Updates to relevant read hooks + await waitFor(() => { + assert.strictEqual(manyMembersHook.result.current.data.length, 1) + assert.strictEqual(manyJoinRequestsHook.result.current.data.length, 0) + assert.ok( + getErrorCode(singleJoinRequestHook.result.current.error), + NotFoundError.code, ) - - // 4. Updates to relevant read hooks - await waitFor(() => { - assert.strictEqual(manyMembersHook.result.current.data.length, 1) - assert.strictEqual(manyJoinRequestsHook.result.current.data.length, 0) - assert.ok( - getErrorCode(singleJoinRequestHook.result.current.error), - NotFoundError.code, - ) - }) - }, - ) + }) + }) test('invitor cancels specific invite link', async (t) => { const testnet = await createTestnet(1) From 3c3a1523fe74df4f650313d6612b09fd223867a4 Mon Sep 17 00:00:00 2001 From: Andrew Chou Date: Mon, 26 Oct 2026 12:36:53 -0400 Subject: [PATCH 31/33] fix docs for useSingleJoinRequest --- docs/API.md | 6 ++++-- src/hooks/invites.ts | 4 +--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/API.md b/docs/API.md index 85fde24..8ac07fe 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1206,8 +1206,10 @@ function ErrorExample({ inviteId }) { if (joinRequestQuery.error) { const comapeoErrorCode = getErrorCode(joinRequestQuery.error) - - // Match error code with errors from + // Match error code with errors from `@comapeo/core` and handle as needed... + } +} +``` ### useCreateJoinRequest diff --git a/src/hooks/invites.ts b/src/hooks/invites.ts index d3b95e1..39a12d5 100644 --- a/src/hooks/invites.ts +++ b/src/hooks/invites.ts @@ -244,12 +244,10 @@ Pick< * * if (joinRequestQuery.error) { * const comapeoErrorCode = getErrorCode(joinRequestQuery.error) - * - * // Match error code with errors from @comapeo/core and handle as needed... + * // Match error code with errors from `@comapeo/core` and handle as needed... * } * } * ``` - * */ export function useSingleJoinRequest({ inviteId, From fce87621fd6aec36c67917f137ae299c8464b7ea Mon Sep 17 00:00:00 2001 From: Andrew Chou Date: Mon, 26 Oct 2026 15:37:05 -0400 Subject: [PATCH 32/33] fix tests --- test/hooks/invites.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/test/hooks/invites.test.ts b/test/hooks/invites.test.ts index fc137d7..8277dd3 100644 --- a/test/hooks/invites.test.ts +++ b/test/hooks/invites.test.ts @@ -5,6 +5,7 @@ import type { InviteApi, InviteLinkJoiner } from '@comapeo/core' import { getErrorCode, InviteDeniedByInviterError, + JoinRequestNotFoundError, NotFoundError, } from '@comapeo/core/errors.js' import { parseInviteURL } from '@comapeo/core/invite-urls.js' @@ -438,9 +439,9 @@ test.describe('invite over internet', () => { await waitFor(() => { assert.strictEqual(manyMembersHook.result.current.data.length, 2) assert.strictEqual(manyJoinRequestsHook.result.current.data.length, 0) - assert.ok( + assert.strictEqual( getErrorCode(singleJoinRequestHook.result.current.error), - NotFoundError.code, + JoinRequestNotFoundError.code, ) }) }) @@ -981,9 +982,9 @@ test.describe('invite over internet', () => { // 4. Updates to relevant read hooks await waitFor(() => { assert.strictEqual(manyJoinRequestsHook.result.current.data.length, 0) - assert.ok( + assert.strictEqual( getErrorCode(singleJoinRequestHook.result.current.error), - NotFoundError.code, + JoinRequestNotFoundError.code, ) }) }) From 483bd37451c1f2d9f00370d41c7a1ad254b0a73d Mon Sep 17 00:00:00 2001 From: Andrew Chou Date: Mon, 26 Oct 2026 16:59:43 -0400 Subject: [PATCH 33/33] wip: alternative implementation of useSingleJoinRequest --- docs/API.md | 17 ++++++++++------- src/contexts/ClientApi.ts | 24 ++++++++++++++++++++++-- src/hooks/invites.ts | 21 ++++++++++++++------- test/hooks/invites.test.ts | 34 ++++++++++++++++++++++++++-------- 4 files changed, 72 insertions(+), 24 deletions(-) diff --git a/docs/API.md b/docs/API.md index 8ac07fe..d7e5278 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1182,10 +1182,12 @@ Get all active join requests. ### useSingleJoinRequest -Get an active join request. +Get a specific join request. -If the join request is no longer active after using this hook, the read result -will update such that the `error` field is populated with the relevant error from core. +If the join request is not available when the consuming component mounts, it will trigger the closest error boundary. +If the join request is cancelled while this hook is active, the `error` field will be populated with the relevant error from core. +Updates from the '`join-request-update`' event are reflected in the `data` field. Updates that indicate failure are represented by +`data.status` and `data.error`, not the top-level error field from the hook's return value. | Function | Type | | ---------- | ---------- | @@ -1199,14 +1201,15 @@ Parameters: Examples: ```ts -import { getErrorCode } from '@comapeo/core/errors.js' - function ErrorExample({ inviteId }) { const joinRequestQuery = useSingleJoinRequest({ inviteId }) if (joinRequestQuery.error) { - const comapeoErrorCode = getErrorCode(joinRequestQuery.error) - // Match error code with errors from `@comapeo/core` and handle as needed... + // Handle error from refetch due to write hook call (e.g. cancelling the join request) + } + + if (joinRequestQuery.data.status === 'failed') { + // Handle failure state based on updates happening in the background. } } ``` diff --git a/src/contexts/ClientApi.ts b/src/contexts/ClientApi.ts index f92be85..e657fbd 100644 --- a/src/contexts/ClientApi.ts +++ b/src/contexts/ClientApi.ts @@ -1,3 +1,4 @@ +import type { InviteLinkJoiner } from '@comapeo/core' import type { ComapeoCoreClientApi } from '@comapeo/ipc' import { useQueryClient } from '@tanstack/react-query' import { @@ -11,6 +12,7 @@ import { import { getInvitesQueryKey, + getJoinRequestsByIdQueryKey, getJoinRequestsQueryKey, } from '../lib/react-query.js' @@ -42,8 +44,26 @@ export function ClientApiProvider({ clientApi.invite.addListener('invite-received', handleInviteEvent) clientApi.invite.addListener('invite-updated', handleInviteEvent) - function handleJoinRequestUpdateEvent() { - queryClient.invalidateQueries({ queryKey: getJoinRequestsQueryKey() }) + function handleJoinRequestUpdateEvent( + update: InviteLinkJoiner.JoinRequestUpdate, + ) { + queryClient.invalidateQueries({ + queryKey: getJoinRequestsQueryKey(), + // NOTE: Only want to invalidate the query for getting all join requests (no cascade) + exact: true, + }) + + // NOTE: Directly update query for getting specific join request + queryClient.setQueryData( + getJoinRequestsByIdQueryKey({ inviteId: update.inviteId }), + (prev) => { + if (!prev) { + return prev + } + + return { ...prev, ...update } + }, + ) } clientApi.inviteLinks.addListener( diff --git a/src/hooks/invites.ts b/src/hooks/invites.ts index 39a12d5..91906b3 100644 --- a/src/hooks/invites.ts +++ b/src/hooks/invites.ts @@ -228,23 +228,26 @@ Pick< } /** - * Get an active join request. + * Get a specific join request. * - * If the join request is no longer active after using this hook, the read result - * will update such that the `error` field is populated with the relevant error from core. + * If the join request is not available when the consuming component mounts, it will trigger the closest error boundary. + * If the join request is cancelled while this hook is active, the `error` field will be populated with the relevant error from core. + * Updates from the '`join-request-update`' event are reflected in the `data` field. Updates that indicate failure are represented by + * `data.status` and `data.error`, not the top-level error field from the hook's return value. * * @param opts.inviteId ID of relevant invite. * * @example * ```ts - * import { getErrorCode } from '@comapeo/core/errors.js' - * * function ErrorExample({ inviteId }) { * const joinRequestQuery = useSingleJoinRequest({ inviteId }) * * if (joinRequestQuery.error) { - * const comapeoErrorCode = getErrorCode(joinRequestQuery.error) - * // Match error code with errors from `@comapeo/core` and handle as needed... + * // Handle error from refetch due to write hook call (e.g. cancelling the join request) + * } + * + * if (joinRequestQuery.data.status === 'failed') { + * // Handle failure state based on updates happening in the background. * } * } * ``` @@ -262,6 +265,10 @@ Pick< const { data, error, isRefetching } = useSuspenseQuery({ ...baseQueryOptions(), + // NOTE: Ensure garbage collection occurs immediately when all observers are unmounted + // so that newly active instances only use data from refetch. + // https://tanstack.com/query/latest/docs/framework/react/guides/caching + gcTime: 0, queryKey: getJoinRequestsByIdQueryKey({ inviteId }), queryFn: async () => { return clientApi.inviteLinks.getJoinRequestById(inviteId) diff --git a/test/hooks/invites.test.ts b/test/hooks/invites.test.ts index 8277dd3..37d98c1 100644 --- a/test/hooks/invites.test.ts +++ b/test/hooks/invites.test.ts @@ -6,7 +6,6 @@ import { getErrorCode, InviteDeniedByInviterError, JoinRequestNotFoundError, - NotFoundError, } from '@comapeo/core/errors.js' import { parseInviteURL } from '@comapeo/core/invite-urls.js' import { QueryClient } from '@tanstack/react-query' @@ -394,7 +393,8 @@ test.describe('invite over internet', () => { ) } - const deferredJoinRequestCompleted = Promise.withResolvers() + const deferredJoinRequestCompleted = + Promise.withResolvers() invitee.manager.inviteLinks.on('join-request-update', (update) => { if (update.inviteId !== createdJoinRequest.inviteId) { @@ -407,7 +407,7 @@ test.describe('invite over internet', () => { } if (update.status === 'completed') { - deferredJoinRequestCompleted.resolve() + deferredJoinRequestCompleted.resolve(update) } }) @@ -439,11 +439,18 @@ test.describe('invite over internet', () => { await waitFor(() => { assert.strictEqual(manyMembersHook.result.current.data.length, 2) assert.strictEqual(manyJoinRequestsHook.result.current.data.length, 0) + assert.strictEqual( - getErrorCode(singleJoinRequestHook.result.current.error), - JoinRequestNotFoundError.code, + singleJoinRequestHook.result.current.data.status, + 'completed', + ) + assert.strictEqual( + singleJoinRequestHook.result.current.data.projectId, + projectId, ) }) + + // TODO: Assert error behavior when read hook is reinitialized }) test('invitor denies invitee join request', async (t) => { @@ -666,11 +673,20 @@ test.describe('invite over internet', () => { await waitFor(() => { assert.strictEqual(manyMembersHook.result.current.data.length, 1) assert.strictEqual(manyJoinRequestsHook.result.current.data.length, 0) - assert.ok( - getErrorCode(singleJoinRequestHook.result.current.error), - NotFoundError.code, + + assert.strictEqual( + singleJoinRequestHook.result.current.data.status, + 'failed', ) + + // TODO: Should be enabled. Custom error fields seem to be missing... + // assert.strictEqual( + // getErrorCode(singleJoinRequestHook.result.current.data.error), + // InviteDeniedByInviterError.code, + // ) }) + + // TODO: Assert error behavior when read hook is reinitialized }) test('invitor cancels specific invite link', async (t) => { @@ -829,6 +845,8 @@ test.describe('invite over internet', () => { await waitFor(() => { assert.strictEqual(manyInviteLinksHook.result.current.data.length, 0) }) + + // TODO: Assert error behavior when read hook is reinitialized }) test('invitee cancels join request', { timeout: 30_000 }, async (t) => {