From 654bca0a00a53c9deff816bc204d8e44fb4ef614 Mon Sep 17 00:00:00 2001 From: robonen Date: Thu, 28 May 2026 14:14:29 +0700 Subject: [PATCH] feat: implement vue-sync-engine with tab synchronization and transport layers --- vue-sync-engine/.gitignore | 24 + vue-sync-engine/.vscode/extensions.json | 3 + vue-sync-engine/index.html | 13 + vue-sync-engine/package.json | 27 + vue-sync-engine/pnpm-lock.yaml | 1453 +++++++++++++++++ vue-sync-engine/src/App.vue | 144 ++ vue-sync-engine/src/PostCard.vue | 73 + vue-sync-engine/src/demo.defs.ts | 87 + vue-sync-engine/src/engine.worker.ts | 12 + .../src/engine/__tests__/core.test.ts | 46 + .../src/engine/__tests__/engine.test.ts | 219 +++ .../src/engine/__tests__/fixtures.ts | 69 + .../src/engine/adapters/idbManager.ts | 109 ++ .../src/engine/adapters/idbStore.ts | 58 + .../src/engine/adapters/memoryStore.ts | 45 + .../src/engine/adapters/storageAdapter.ts | 28 + .../src/engine/composables/useEngine.ts | 10 + .../src/engine/composables/useEntity.ts | 15 + .../engine/composables/useInfiniteQuery.ts | 54 + .../src/engine/composables/useMutation.ts | 42 + .../src/engine/composables/useQuery.ts | 49 + vue-sync-engine/src/engine/core/flags.ts | 33 + vue-sync-engine/src/engine/core/keyedStore.ts | 11 + vue-sync-engine/src/engine/core/patches.ts | 48 + vue-sync-engine/src/engine/core/queryKey.ts | 43 + vue-sync-engine/src/engine/core/types.ts | 101 ++ vue-sync-engine/src/engine/createEngine.ts | 73 + vue-sync-engine/src/engine/define.ts | 85 + vue-sync-engine/src/engine/index.ts | 31 + vue-sync-engine/src/engine/plugin.ts | 59 + vue-sync-engine/src/engine/tab/mirror.ts | 95 ++ vue-sync-engine/src/engine/tab/runtime.ts | 113 ++ .../src/engine/transport/InlineTransport.ts | 56 + .../engine/transport/SharedWorkerTransport.ts | 69 + .../src/engine/transport/protocol.ts | 57 + .../src/engine/worker/mutationQueue.ts | 128 ++ .../src/engine/worker/queryGraph.ts | 449 +++++ vue-sync-engine/src/env.d.ts | 12 + vue-sync-engine/src/main.ts | 16 + vue-sync-engine/tsconfig.app.json | 14 + vue-sync-engine/tsconfig.json | 7 + vue-sync-engine/tsconfig.node.json | 24 + vue-sync-engine/vite.config.ts | 24 + 43 files changed, 4128 insertions(+) create mode 100644 vue-sync-engine/.gitignore create mode 100644 vue-sync-engine/.vscode/extensions.json create mode 100644 vue-sync-engine/index.html create mode 100644 vue-sync-engine/package.json create mode 100644 vue-sync-engine/pnpm-lock.yaml create mode 100644 vue-sync-engine/src/App.vue create mode 100644 vue-sync-engine/src/PostCard.vue create mode 100644 vue-sync-engine/src/demo.defs.ts create mode 100644 vue-sync-engine/src/engine.worker.ts create mode 100644 vue-sync-engine/src/engine/__tests__/core.test.ts create mode 100644 vue-sync-engine/src/engine/__tests__/engine.test.ts create mode 100644 vue-sync-engine/src/engine/__tests__/fixtures.ts create mode 100644 vue-sync-engine/src/engine/adapters/idbManager.ts create mode 100644 vue-sync-engine/src/engine/adapters/idbStore.ts create mode 100644 vue-sync-engine/src/engine/adapters/memoryStore.ts create mode 100644 vue-sync-engine/src/engine/adapters/storageAdapter.ts create mode 100644 vue-sync-engine/src/engine/composables/useEngine.ts create mode 100644 vue-sync-engine/src/engine/composables/useEntity.ts create mode 100644 vue-sync-engine/src/engine/composables/useInfiniteQuery.ts create mode 100644 vue-sync-engine/src/engine/composables/useMutation.ts create mode 100644 vue-sync-engine/src/engine/composables/useQuery.ts create mode 100644 vue-sync-engine/src/engine/core/flags.ts create mode 100644 vue-sync-engine/src/engine/core/keyedStore.ts create mode 100644 vue-sync-engine/src/engine/core/patches.ts create mode 100644 vue-sync-engine/src/engine/core/queryKey.ts create mode 100644 vue-sync-engine/src/engine/core/types.ts create mode 100644 vue-sync-engine/src/engine/createEngine.ts create mode 100644 vue-sync-engine/src/engine/define.ts create mode 100644 vue-sync-engine/src/engine/index.ts create mode 100644 vue-sync-engine/src/engine/plugin.ts create mode 100644 vue-sync-engine/src/engine/tab/mirror.ts create mode 100644 vue-sync-engine/src/engine/tab/runtime.ts create mode 100644 vue-sync-engine/src/engine/transport/InlineTransport.ts create mode 100644 vue-sync-engine/src/engine/transport/SharedWorkerTransport.ts create mode 100644 vue-sync-engine/src/engine/transport/protocol.ts create mode 100644 vue-sync-engine/src/engine/worker/mutationQueue.ts create mode 100644 vue-sync-engine/src/engine/worker/queryGraph.ts create mode 100644 vue-sync-engine/src/env.d.ts create mode 100644 vue-sync-engine/src/main.ts create mode 100644 vue-sync-engine/tsconfig.app.json create mode 100644 vue-sync-engine/tsconfig.json create mode 100644 vue-sync-engine/tsconfig.node.json create mode 100644 vue-sync-engine/vite.config.ts diff --git a/vue-sync-engine/.gitignore b/vue-sync-engine/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/vue-sync-engine/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/vue-sync-engine/.vscode/extensions.json b/vue-sync-engine/.vscode/extensions.json new file mode 100644 index 0000000..a7cea0b --- /dev/null +++ b/vue-sync-engine/.vscode/extensions.json @@ -0,0 +1,3 @@ +{ + "recommendations": ["Vue.volar"] +} diff --git a/vue-sync-engine/index.html b/vue-sync-engine/index.html new file mode 100644 index 0000000..aab39ec --- /dev/null +++ b/vue-sync-engine/index.html @@ -0,0 +1,13 @@ + + + + + + + vue-sync-engine + + +
+ + + diff --git a/vue-sync-engine/package.json b/vue-sync-engine/package.json new file mode 100644 index 0000000..07388e1 --- /dev/null +++ b/vue-sync-engine/package.json @@ -0,0 +1,27 @@ +{ + "name": "vue-sync-engine", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc -b && vite build", + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "vue": "^3.5.34" + }, + "devDependencies": { + "@types/node": "^24.12.3", + "@vitejs/plugin-vue": "^6.0.6", + "@vue/test-utils": "^2.4.10", + "@vue/tsconfig": "^0.9.1", + "happy-dom": "^20.9.0", + "typescript": "~6.0.2", + "vite": "^8.0.12", + "vitest": "^4.1.7", + "vue-tsc": "^3.2.8" + } +} diff --git a/vue-sync-engine/pnpm-lock.yaml b/vue-sync-engine/pnpm-lock.yaml new file mode 100644 index 0000000..0238d90 --- /dev/null +++ b/vue-sync-engine/pnpm-lock.yaml @@ -0,0 +1,1453 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + vue: + specifier: ^3.5.34 + version: 3.5.34(typescript@6.0.3) + devDependencies: + '@types/node': + specifier: ^24.12.3 + version: 24.12.4 + '@vitejs/plugin-vue': + specifier: ^6.0.6 + version: 6.0.7(vite@8.0.14(@types/node@24.12.4))(vue@3.5.34(typescript@6.0.3)) + '@vue/test-utils': + specifier: ^2.4.10 + version: 2.4.10(@vue/compiler-dom@3.5.34)(@vue/server-renderer@3.5.34(vue@3.5.34(typescript@6.0.3)))(vue@3.5.34(typescript@6.0.3)) + '@vue/tsconfig': + specifier: ^0.9.1 + version: 0.9.1(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3)) + happy-dom: + specifier: ^20.9.0 + version: 20.9.0 + typescript: + specifier: ~6.0.2 + version: 6.0.3 + vite: + specifier: ^8.0.12 + version: 8.0.14(@types/node@24.12.4) + vitest: + specifier: ^4.1.7 + version: 4.1.7(@types/node@24.12.4)(happy-dom@20.9.0)(vite@8.0.14(@types/node@24.12.4)) + vue-tsc: + specifier: ^3.2.8 + version: 3.3.1(typescript@6.0.3) + +packages: + + '@babel/helper-string-parser@7.27.1': + resolution: {integrity: sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.28.5': + resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.3': + resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.0': + resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==} + engines: {node: '>=6.9.0'} + + '@emnapi/core@1.10.0': + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + + '@emnapi/runtime@1.10.0': + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + + '@emnapi/wasi-threads@1.2.1': + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + + '@isaacs/cliui@8.0.2': + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@napi-rs/wasm-runtime@1.1.4': + resolution: {integrity: sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==} + peerDependencies: + '@emnapi/core': ^1.7.1 + '@emnapi/runtime': ^1.7.1 + + '@one-ini/wasm@0.1.1': + resolution: {integrity: sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==} + + '@oxc-project/types@0.132.0': + resolution: {integrity: sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==} + + '@pkgjs/parseargs@0.11.0': + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + + '@rolldown/binding-android-arm64@1.0.2': + resolution: {integrity: sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + + '@rolldown/binding-darwin-arm64@1.0.2': + resolution: {integrity: sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + + '@rolldown/binding-darwin-x64@1.0.2': + resolution: {integrity: sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + + '@rolldown/binding-freebsd-x64@1.0.2': + resolution: {integrity: sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + + '@rolldown/binding-linux-arm-gnueabihf@1.0.2': + resolution: {integrity: sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + + '@rolldown/binding-linux-arm64-gnu@1.0.2': + resolution: {integrity: sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-arm64-musl@1.0.2': + resolution: {integrity: sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + + '@rolldown/binding-linux-ppc64-gnu@1.0.2': + resolution: {integrity: sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + + '@rolldown/binding-linux-s390x-gnu@1.0.2': + resolution: {integrity: sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + + '@rolldown/binding-linux-x64-gnu@1.0.2': + resolution: {integrity: sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-linux-x64-musl@1.0.2': + resolution: {integrity: sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + + '@rolldown/binding-openharmony-arm64@1.0.2': + resolution: {integrity: sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + + '@rolldown/binding-wasm32-wasi@1.0.2': + resolution: {integrity: sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + + '@rolldown/binding-win32-arm64-msvc@1.0.2': + resolution: {integrity: sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + + '@rolldown/binding-win32-x64-msvc@1.0.2': + resolution: {integrity: sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + + '@rolldown/pluginutils@1.0.1': + resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@tybys/wasm-util@0.10.2': + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@types/node@24.12.4': + resolution: {integrity: sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==} + + '@types/whatwg-mimetype@3.0.2': + resolution: {integrity: sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==} + + '@types/ws@8.18.1': + resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==} + + '@vitejs/plugin-vue@6.0.7': + resolution: {integrity: sha512-km+p+XdSz9Sxm5rqUbqcSfZYaAniKxWBj1KURl+Jr7UaPvvX7BmaWMdP69I5rrFDeQGyxAG7NXdc57vz+snhWg==} + engines: {node: ^20.19.0 || >=22.12.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + vue: ^3.2.25 + + '@vitest/expect@4.1.7': + resolution: {integrity: sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==} + + '@vitest/mocker@4.1.7': + resolution: {integrity: sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.7': + resolution: {integrity: sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==} + + '@vitest/runner@4.1.7': + resolution: {integrity: sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==} + + '@vitest/snapshot@4.1.7': + resolution: {integrity: sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==} + + '@vitest/spy@4.1.7': + resolution: {integrity: sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==} + + '@vitest/utils@4.1.7': + resolution: {integrity: sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==} + + '@volar/language-core@2.4.28': + resolution: {integrity: sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==} + + '@volar/source-map@2.4.28': + resolution: {integrity: sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==} + + '@volar/typescript@2.4.28': + resolution: {integrity: sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==} + + '@vue/compiler-core@3.5.34': + resolution: {integrity: sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==} + + '@vue/compiler-dom@3.5.34': + resolution: {integrity: sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==} + + '@vue/compiler-sfc@3.5.34': + resolution: {integrity: sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==} + + '@vue/compiler-ssr@3.5.34': + resolution: {integrity: sha512-cDtTHKibkThKGHH1SP+WdccquNRYQDFH6rRjQCqT9G2ltFAfoR5pUftpab/z+aM5mW9HLLVQW7hfKKQe/1GBeQ==} + + '@vue/language-core@3.3.1': + resolution: {integrity: sha512-NP8g6V7x81NVOXbLupUvYY6i6LqUkjkVowe2epRedmpgaFCOdjgWHE/rQBvEJ4r7koAYODIjGeBWEdt6n7jYXQ==} + + '@vue/reactivity@3.5.34': + resolution: {integrity: sha512-y9XDjCEuBp+98k+UL5dbYkh57AHU4o6cxZedOPXw3bmrZZYLQsVHguGurq7hVrPCSrQtrnz1f9dssyFr+dMXfQ==} + + '@vue/runtime-core@3.5.34': + resolution: {integrity: sha512-mKeBYvu8tcMSLhypAHBmriUFfWXKTCF/23Z4jiCoYK3UtWepkliViNLuR90V9XOyD62mUxs9p1jsrpK3CCGIzw==} + + '@vue/runtime-dom@3.5.34': + resolution: {integrity: sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==} + + '@vue/server-renderer@3.5.34': + resolution: {integrity: sha512-nHxmJoTrKsmrkbILRhkC9gY1G3moZbJTqCzDd7DOOzG5KH9oeJ0Unqrff5f9v0pW//jES05ZkJcNtfE8JjOIew==} + peerDependencies: + vue: 3.5.34 + + '@vue/shared@3.5.34': + resolution: {integrity: sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==} + + '@vue/test-utils@2.4.10': + resolution: {integrity: sha512-SmoZ5EA1kYiAFs9NkYdiFFQF+cSnUwnvlYEbY+DogWQZUiqOm/Y29eSbc5T6yi75SgSF9863SBeXniIEoPajCA==} + peerDependencies: + '@vue/compiler-dom': 3.x + '@vue/server-renderer': 3.x + vue: 3.x + peerDependenciesMeta: + '@vue/server-renderer': + optional: true + + '@vue/tsconfig@0.9.1': + resolution: {integrity: sha512-buvjm+9NzLCJL29KY1j1991YYJ5e6275OiK+G4jtmfIb+z4POywbdm0wXusT9adVWqe0xqg70TbI7+mRx4uU9w==} + peerDependencies: + typescript: '>= 5.8' + vue: ^3.4.0 + peerDependenciesMeta: + typescript: + optional: true + vue: + optional: true + + abbrev@2.0.0: + resolution: {integrity: sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + + alien-signals@3.2.1: + resolution: {integrity: sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==} + + ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + + ansi-regex@6.2.2: + resolution: {integrity: sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==} + engines: {node: '>=12'} + + ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + + ansi-styles@6.2.3: + resolution: {integrity: sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==} + engines: {node: '>=12'} + + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + + balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + + brace-expansion@2.1.1: + resolution: {integrity: sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA==} + + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + + color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + + color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + + commander@10.0.1: + resolution: {integrity: sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==} + engines: {node: '>=14'} + + config-chain@1.1.13: + resolution: {integrity: sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==} + + convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + + editorconfig@1.0.7: + resolution: {integrity: sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==} + engines: {node: '>=14'} + hasBin: true + + emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + + emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + es-module-lexer@2.1.0: + resolution: {integrity: sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==} + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + glob@10.5.0: + resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + hasBin: true + + happy-dom@20.9.0: + resolution: {integrity: sha512-GZZ9mKe8r646NUAf/zemnGbjYh4Bt8/MqASJY+pSm5ZDtc3YQox+4gsLI7yi1hba6o+eCsGxpHn5+iEVn31/FQ==} + engines: {node: '>=20.0.0'} + + ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + + is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + + js-beautify@1.15.4: + resolution: {integrity: sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==} + engines: {node: '>=14'} + hasBin: true + + js-cookie@3.0.7: + resolution: {integrity: sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==} + engines: {node: '>=20'} + + lightningcss-android-arm64@1.32.0: + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [android] + + lightningcss-darwin-arm64@1.32.0: + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.32.0: + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.32.0: + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.32.0: + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.32.0: + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-arm64-musl@1.32.0: + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + + lightningcss-linux-x64-gnu@1.32.0: + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-linux-x64-musl@1.32.0: + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + + lightningcss-win32-arm64-msvc@1.32.0: + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.32.0: + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.32.0: + resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} + engines: {node: '>= 12.0.0'} + + lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + nopt@7.2.1: + resolution: {integrity: sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==} + engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + hasBin: true + + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} + engines: {node: '>=12'} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + proto-list@1.2.4: + resolution: {integrity: sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==} + + rolldown@1.0.2: + resolution: {integrity: sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + + semver@7.8.1: + resolution: {integrity: sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg==} + engines: {node: '>=10'} + hasBin: true + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.1.0: + resolution: {integrity: sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==} + + string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + + string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + + strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.2.2: + resolution: {integrity: sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g==} + engines: {node: '>=18'} + + tinyglobby@0.2.16: + resolution: {integrity: sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + + tslib@2.8.1: + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + + typescript@6.0.3: + resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@7.16.0: + resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==} + + vite@8.0.14: + resolution: {integrity: sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.18 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.1.7: + resolution: {integrity: sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.1.7 + '@vitest/browser-preview': 4.1.7 + '@vitest/browser-webdriverio': 4.1.7 + '@vitest/coverage-istanbul': 4.1.7 + '@vitest/coverage-v8': 4.1.7 + '@vitest/ui': 4.1.7 + happy-dom: '*' + jsdom: '*' + vite: ^6.0.0 || ^7.0.0 || ^8.0.0 + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/coverage-istanbul': + optional: true + '@vitest/coverage-v8': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + vscode-uri@3.1.0: + resolution: {integrity: sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ==} + + vue-component-type-helpers@3.3.2: + resolution: {integrity: sha512-l4Z2Y34m7nFMlx8vrslJaVtXxUpzgDMSESC7TakG/c5kwjYT/do+E0NcT2/vWDzaoIhsShg/2OKwX7Q4nbzC0g==} + + vue-tsc@3.3.1: + resolution: {integrity: sha512-webBP3jhlxzhELZ2g+11KJ6pg5OVY1xWhWrj7N/yQMi1CrtxJnW+tUACyRVeDK0cQNLP2Va5HNYK8pe+7c+msw==} + hasBin: true + peerDependencies: + typescript: '>=5.0.0' + + vue@3.5.34: + resolution: {integrity: sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + whatwg-mimetype@3.0.0: + resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} + engines: {node: '>=12'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + + wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + + wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + + ws@8.21.0: + resolution: {integrity: sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + +snapshots: + + '@babel/helper-string-parser@7.27.1': {} + + '@babel/helper-validator-identifier@7.28.5': {} + + '@babel/parser@7.29.3': + dependencies: + '@babel/types': 7.29.0 + + '@babel/types@7.29.0': + dependencies: + '@babel/helper-string-parser': 7.27.1 + '@babel/helper-validator-identifier': 7.28.5 + + '@emnapi/core@1.10.0': + dependencies: + '@emnapi/wasi-threads': 1.2.1 + tslib: 2.8.1 + optional: true + + '@emnapi/runtime@1.10.0': + dependencies: + tslib: 2.8.1 + optional: true + + '@emnapi/wasi-threads@1.2.1': + dependencies: + tslib: 2.8.1 + optional: true + + '@isaacs/cliui@8.0.2': + dependencies: + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.2.0 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@tybys/wasm-util': 0.10.2 + optional: true + + '@one-ini/wasm@0.1.1': {} + + '@oxc-project/types@0.132.0': {} + + '@pkgjs/parseargs@0.11.0': + optional: true + + '@rolldown/binding-android-arm64@1.0.2': + optional: true + + '@rolldown/binding-darwin-arm64@1.0.2': + optional: true + + '@rolldown/binding-darwin-x64@1.0.2': + optional: true + + '@rolldown/binding-freebsd-x64@1.0.2': + optional: true + + '@rolldown/binding-linux-arm-gnueabihf@1.0.2': + optional: true + + '@rolldown/binding-linux-arm64-gnu@1.0.2': + optional: true + + '@rolldown/binding-linux-arm64-musl@1.0.2': + optional: true + + '@rolldown/binding-linux-ppc64-gnu@1.0.2': + optional: true + + '@rolldown/binding-linux-s390x-gnu@1.0.2': + optional: true + + '@rolldown/binding-linux-x64-gnu@1.0.2': + optional: true + + '@rolldown/binding-linux-x64-musl@1.0.2': + optional: true + + '@rolldown/binding-openharmony-arm64@1.0.2': + optional: true + + '@rolldown/binding-wasm32-wasi@1.0.2': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + + '@rolldown/binding-win32-arm64-msvc@1.0.2': + optional: true + + '@rolldown/binding-win32-x64-msvc@1.0.2': + optional: true + + '@rolldown/pluginutils@1.0.1': {} + + '@standard-schema/spec@1.1.0': {} + + '@tybys/wasm-util@0.10.2': + dependencies: + tslib: 2.8.1 + optional: true + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.9': {} + + '@types/node@24.12.4': + dependencies: + undici-types: 7.16.0 + + '@types/whatwg-mimetype@3.0.2': {} + + '@types/ws@8.18.1': + dependencies: + '@types/node': 24.12.4 + + '@vitejs/plugin-vue@6.0.7(vite@8.0.14(@types/node@24.12.4))(vue@3.5.34(typescript@6.0.3))': + dependencies: + '@rolldown/pluginutils': 1.0.1 + vite: 8.0.14(@types/node@24.12.4) + vue: 3.5.34(typescript@6.0.3) + + '@vitest/expect@4.1.7': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.7 + '@vitest/utils': 4.1.7 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.7(vite@8.0.14(@types/node@24.12.4))': + dependencies: + '@vitest/spy': 4.1.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.14(@types/node@24.12.4) + + '@vitest/pretty-format@4.1.7': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.7': + dependencies: + '@vitest/utils': 4.1.7 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.7': + dependencies: + '@vitest/pretty-format': 4.1.7 + '@vitest/utils': 4.1.7 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.7': {} + + '@vitest/utils@4.1.7': + dependencies: + '@vitest/pretty-format': 4.1.7 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + + '@volar/language-core@2.4.28': + dependencies: + '@volar/source-map': 2.4.28 + + '@volar/source-map@2.4.28': {} + + '@volar/typescript@2.4.28': + dependencies: + '@volar/language-core': 2.4.28 + path-browserify: 1.0.1 + vscode-uri: 3.1.0 + + '@vue/compiler-core@3.5.34': + dependencies: + '@babel/parser': 7.29.3 + '@vue/shared': 3.5.34 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.34': + dependencies: + '@vue/compiler-core': 3.5.34 + '@vue/shared': 3.5.34 + + '@vue/compiler-sfc@3.5.34': + dependencies: + '@babel/parser': 7.29.3 + '@vue/compiler-core': 3.5.34 + '@vue/compiler-dom': 3.5.34 + '@vue/compiler-ssr': 3.5.34 + '@vue/shared': 3.5.34 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.15 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.34': + dependencies: + '@vue/compiler-dom': 3.5.34 + '@vue/shared': 3.5.34 + + '@vue/language-core@3.3.1': + dependencies: + '@volar/language-core': 2.4.28 + '@vue/compiler-dom': 3.5.34 + '@vue/shared': 3.5.34 + alien-signals: 3.2.1 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + picomatch: 4.0.4 + + '@vue/reactivity@3.5.34': + dependencies: + '@vue/shared': 3.5.34 + + '@vue/runtime-core@3.5.34': + dependencies: + '@vue/reactivity': 3.5.34 + '@vue/shared': 3.5.34 + + '@vue/runtime-dom@3.5.34': + dependencies: + '@vue/reactivity': 3.5.34 + '@vue/runtime-core': 3.5.34 + '@vue/shared': 3.5.34 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.34(vue@3.5.34(typescript@6.0.3))': + dependencies: + '@vue/compiler-ssr': 3.5.34 + '@vue/shared': 3.5.34 + vue: 3.5.34(typescript@6.0.3) + + '@vue/shared@3.5.34': {} + + '@vue/test-utils@2.4.10(@vue/compiler-dom@3.5.34)(@vue/server-renderer@3.5.34(vue@3.5.34(typescript@6.0.3)))(vue@3.5.34(typescript@6.0.3))': + dependencies: + '@vue/compiler-dom': 3.5.34 + js-beautify: 1.15.4 + vue: 3.5.34(typescript@6.0.3) + vue-component-type-helpers: 3.3.2 + optionalDependencies: + '@vue/server-renderer': 3.5.34(vue@3.5.34(typescript@6.0.3)) + + '@vue/tsconfig@0.9.1(typescript@6.0.3)(vue@3.5.34(typescript@6.0.3))': + optionalDependencies: + typescript: 6.0.3 + vue: 3.5.34(typescript@6.0.3) + + abbrev@2.0.0: {} + + alien-signals@3.2.1: {} + + ansi-regex@5.0.1: {} + + ansi-regex@6.2.2: {} + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + ansi-styles@6.2.3: {} + + assertion-error@2.0.1: {} + + balanced-match@1.0.2: {} + + brace-expansion@2.1.1: + dependencies: + balanced-match: 1.0.2 + + chai@6.2.2: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + commander@10.0.1: {} + + config-chain@1.1.13: + dependencies: + ini: 1.3.8 + proto-list: 1.2.4 + + convert-source-map@2.0.0: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + csstype@3.2.3: {} + + detect-libc@2.1.2: {} + + eastasianwidth@0.2.0: {} + + editorconfig@1.0.7: + dependencies: + '@one-ini/wasm': 0.1.1 + commander: 10.0.1 + minimatch: 9.0.9 + semver: 7.8.1 + + emoji-regex@8.0.0: {} + + emoji-regex@9.2.2: {} + + entities@7.0.1: {} + + es-module-lexer@2.1.0: {} + + estree-walker@2.0.2: {} + + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.9 + + expect-type@1.3.0: {} + + fdir@6.5.0(picomatch@4.0.4): + optionalDependencies: + picomatch: 4.0.4 + + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + + fsevents@2.3.3: + optional: true + + glob@10.5.0: + dependencies: + foreground-child: 3.3.1 + jackspeak: 3.4.3 + minimatch: 9.0.9 + minipass: 7.1.3 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + + happy-dom@20.9.0: + dependencies: + '@types/node': 24.12.4 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.1 + entities: 7.0.1 + whatwg-mimetype: 3.0.0 + ws: 8.21.0 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + + ini@1.3.8: {} + + is-fullwidth-code-point@3.0.0: {} + + isexe@2.0.0: {} + + jackspeak@3.4.3: + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + + js-beautify@1.15.4: + dependencies: + config-chain: 1.1.13 + editorconfig: 1.0.7 + glob: 10.5.0 + js-cookie: 3.0.7 + nopt: 7.2.1 + + js-cookie@3.0.7: {} + + lightningcss-android-arm64@1.32.0: + optional: true + + lightningcss-darwin-arm64@1.32.0: + optional: true + + lightningcss-darwin-x64@1.32.0: + optional: true + + lightningcss-freebsd-x64@1.32.0: + optional: true + + lightningcss-linux-arm-gnueabihf@1.32.0: + optional: true + + lightningcss-linux-arm64-gnu@1.32.0: + optional: true + + lightningcss-linux-arm64-musl@1.32.0: + optional: true + + lightningcss-linux-x64-gnu@1.32.0: + optional: true + + lightningcss-linux-x64-musl@1.32.0: + optional: true + + lightningcss-win32-arm64-msvc@1.32.0: + optional: true + + lightningcss-win32-x64-msvc@1.32.0: + optional: true + + lightningcss@1.32.0: + dependencies: + detect-libc: 2.1.2 + optionalDependencies: + lightningcss-android-arm64: 1.32.0 + lightningcss-darwin-arm64: 1.32.0 + lightningcss-darwin-x64: 1.32.0 + lightningcss-freebsd-x64: 1.32.0 + lightningcss-linux-arm-gnueabihf: 1.32.0 + lightningcss-linux-arm64-gnu: 1.32.0 + lightningcss-linux-arm64-musl: 1.32.0 + lightningcss-linux-x64-gnu: 1.32.0 + lightningcss-linux-x64-musl: 1.32.0 + lightningcss-win32-arm64-msvc: 1.32.0 + lightningcss-win32-x64-msvc: 1.32.0 + + lru-cache@10.4.3: {} + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.1 + + minipass@7.1.3: {} + + muggle-string@0.4.1: {} + + nanoid@3.3.12: {} + + nopt@7.2.1: + dependencies: + abbrev: 2.0.0 + + obug@2.1.1: {} + + package-json-from-dist@1.0.1: {} + + path-browserify@1.0.1: {} + + path-key@3.1.1: {} + + path-scurry@1.11.1: + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.3 + + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.4: {} + + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + proto-list@1.2.4: {} + + rolldown@1.0.2: + dependencies: + '@oxc-project/types': 0.132.0 + '@rolldown/pluginutils': 1.0.1 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.2 + '@rolldown/binding-darwin-arm64': 1.0.2 + '@rolldown/binding-darwin-x64': 1.0.2 + '@rolldown/binding-freebsd-x64': 1.0.2 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.2 + '@rolldown/binding-linux-arm64-gnu': 1.0.2 + '@rolldown/binding-linux-arm64-musl': 1.0.2 + '@rolldown/binding-linux-ppc64-gnu': 1.0.2 + '@rolldown/binding-linux-s390x-gnu': 1.0.2 + '@rolldown/binding-linux-x64-gnu': 1.0.2 + '@rolldown/binding-linux-x64-musl': 1.0.2 + '@rolldown/binding-openharmony-arm64': 1.0.2 + '@rolldown/binding-wasm32-wasi': 1.0.2 + '@rolldown/binding-win32-arm64-msvc': 1.0.2 + '@rolldown/binding-win32-x64-msvc': 1.0.2 + + semver@7.8.1: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + siginfo@2.0.0: {} + + signal-exit@4.1.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@4.1.0: {} + + string-width@4.2.3: + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + + string-width@5.1.2: + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.2.0 + + strip-ansi@6.0.1: + dependencies: + ansi-regex: 5.0.1 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.2.2 + + tinybench@2.9.0: {} + + tinyexec@1.2.2: {} + + tinyglobby@0.2.16: + dependencies: + fdir: 6.5.0(picomatch@4.0.4) + picomatch: 4.0.4 + + tinyrainbow@3.1.0: {} + + tslib@2.8.1: + optional: true + + typescript@6.0.3: {} + + undici-types@7.16.0: {} + + vite@8.0.14(@types/node@24.12.4): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.15 + rolldown: 1.0.2 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 24.12.4 + fsevents: 2.3.3 + + vitest@4.1.7(@types/node@24.12.4)(happy-dom@20.9.0)(vite@8.0.14(@types/node@24.12.4)): + dependencies: + '@vitest/expect': 4.1.7 + '@vitest/mocker': 4.1.7(vite@8.0.14(@types/node@24.12.4)) + '@vitest/pretty-format': 4.1.7 + '@vitest/runner': 4.1.7 + '@vitest/snapshot': 4.1.7 + '@vitest/spy': 4.1.7 + '@vitest/utils': 4.1.7 + es-module-lexer: 2.1.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.4 + std-env: 4.1.0 + tinybench: 2.9.0 + tinyexec: 1.2.2 + tinyglobby: 0.2.16 + tinyrainbow: 3.1.0 + vite: 8.0.14(@types/node@24.12.4) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.12.4 + happy-dom: 20.9.0 + transitivePeerDependencies: + - msw + + vscode-uri@3.1.0: {} + + vue-component-type-helpers@3.3.2: {} + + vue-tsc@3.3.1(typescript@6.0.3): + dependencies: + '@volar/typescript': 2.4.28 + '@vue/language-core': 3.3.1 + typescript: 6.0.3 + + vue@3.5.34(typescript@6.0.3): + dependencies: + '@vue/compiler-dom': 3.5.34 + '@vue/compiler-sfc': 3.5.34 + '@vue/runtime-dom': 3.5.34 + '@vue/server-renderer': 3.5.34(vue@3.5.34(typescript@6.0.3)) + '@vue/shared': 3.5.34 + optionalDependencies: + typescript: 6.0.3 + + whatwg-mimetype@3.0.0: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + wrap-ansi@7.0.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + + wrap-ansi@8.1.0: + dependencies: + ansi-styles: 6.2.3 + string-width: 5.1.2 + strip-ansi: 7.2.0 + + ws@8.21.0: {} diff --git a/vue-sync-engine/src/App.vue b/vue-sync-engine/src/App.vue new file mode 100644 index 0000000..b4925ac --- /dev/null +++ b/vue-sync-engine/src/App.vue @@ -0,0 +1,144 @@ + + + + + diff --git a/vue-sync-engine/src/PostCard.vue b/vue-sync-engine/src/PostCard.vue new file mode 100644 index 0000000..13ab01a --- /dev/null +++ b/vue-sync-engine/src/PostCard.vue @@ -0,0 +1,73 @@ + + + + + diff --git a/vue-sync-engine/src/demo.defs.ts b/vue-sync-engine/src/demo.defs.ts new file mode 100644 index 0000000..b64e864 --- /dev/null +++ b/vue-sync-engine/src/demo.defs.ts @@ -0,0 +1,87 @@ +import { defineEntity, defineInfiniteQuery, defineMutation, defineQuery, idbStore } from './engine' + +export interface Post { + id: number + userId: number + title: string + body: string +} + +export interface User { + id: number + name: string + email: string + username: string +} + +export const PostEntity = defineEntity({ + name: 'post', + id: (p) => p.id, + storage: idbStore({ dbName: 'demo-sync-engine' }), +}) +export const UserEntity = defineEntity({ + name: 'user', + id: (u) => u.id, + storage: idbStore({ dbName: 'demo-sync-engine' }), +}) + +const BASE = 'https://jsonplaceholder.typicode.com' + +async function http(url: string, init?: RequestInit, signal?: AbortSignal): Promise { + const res = await fetch(url, { ...init, signal }) + if (!res.ok) throw new Error(`HTTP ${res.status} for ${url}`) + return (await res.json()) as T +} + +export const usersQuery = defineQuery({ + name: 'users.list', + key: () => ['users'], + fetch: (_, ctx) => http(`${BASE}/users`, undefined, ctx.signal), + normalize: (items) => ({ + entities: { user: items }, + result: { ids: items.map((u) => u.id) }, + }), + tags: () => ['users'], + staleTime: 60_000, +}) + +export const postsInfinite = defineInfiniteQuery< + { userId?: number }, + Post[], + number, + { ids: number[]; nextPage: number | null } +>({ + name: 'posts.infinite', + key: (args) => ['posts', args.userId ?? 'all'], + initialPageParam: 1, + getNextPageParam: (last) => last.nextPage, + fetch: (args, ctx) => { + const params = new URLSearchParams({ _page: String(ctx.pageParam), _limit: '10' }) + if (args.userId != null) params.set('userId', String(args.userId)) + return http(`${BASE}/posts?${params}`, undefined, ctx.signal) + }, + normalize: (items, _args, pageParam) => ({ + entities: { post: items }, + result: { + ids: items.map((p) => p.id), + nextPage: items.length === 10 ? (pageParam as number) + 1 : null, + }, + }), + tags: () => ['posts'], + staleTime: 60_000, +}) + +export const updatePostTitle = defineMutation<{ id: number; title: string }, Post>({ + name: 'post.updateTitle', + fetch: (input, ctx) => + http( + `${BASE}/posts/${input.id}`, + { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ title: input.title }), + }, + ctx.signal, + ), + optimistic: (input, ctx) => ctx.patchEntity(PostEntity, input.id, { title: input.title }), +}) diff --git a/vue-sync-engine/src/engine.worker.ts b/vue-sync-engine/src/engine.worker.ts new file mode 100644 index 0000000..65e5bb0 --- /dev/null +++ b/vue-sync-engine/src/engine.worker.ts @@ -0,0 +1,12 @@ +import { bootstrapWorker, indexedDBAdapter, createSharedWorkerServerEndpoint } from './engine' +import registry from 'virtual:sync-engine-registry' + +interface SharedWorkerScopeLike { + onconnect: ((ev: { ports: readonly MessagePort[] }) => void) | null +} + +bootstrapWorker({ + ...registry, + storage: indexedDBAdapter({ dbName: 'demo-sync-engine' }), + endpoint: createSharedWorkerServerEndpoint(self as unknown as SharedWorkerScopeLike), +}) diff --git a/vue-sync-engine/src/engine/__tests__/core.test.ts b/vue-sync-engine/src/engine/__tests__/core.test.ts new file mode 100644 index 0000000..815eefb --- /dev/null +++ b/vue-sync-engine/src/engine/__tests__/core.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest' +import { hashKey } from '../core/queryKey' +import { applyPatch, invertEntityPatch } from '../core/patches' +import { Op } from '../core/flags' + +describe('queryKey.hashKey', () => { + it('produces stable hash regardless of key order', () => { + const a = hashKey(['users', { search: 'x', page: 1 }]) + const b = hashKey(['users', { page: 1, search: 'x' }]) + expect(a).toBe(b) + }) + + it('different args produce different hashes', () => { + expect(hashKey(['u', 1])).not.toBe(hashKey(['u', 2])) + }) +}) + +describe('patches', () => { + it('set at root', () => { + expect(applyPatch({ a: 1 }, { op: Op.Set, path: [], value: { b: 2 } })).toEqual({ b: 2 }) + }) + + it('merge does not mutate input', () => { + const input = { a: 1, b: 2 } + const out = applyPatch(input, { op: Op.Merge, path: [], value: { b: 9 } }) + expect(out).toEqual({ a: 1, b: 9 }) + expect(input).toEqual({ a: 1, b: 2 }) + }) + + it('delete removes nested key', () => { + const out = applyPatch({ a: { b: 1, c: 2 } }, { op: Op.Delete, path: ['a', 'b'] }) + expect(out).toEqual({ a: { c: 2 } }) + }) + + it('inverts a set on undefined prev as delete', () => { + const inv = invertEntityPatch(undefined, { op: Op.Set, path: [], value: { x: 1 } }) + expect(inv).toEqual({ op: Op.Delete, path: [] }) + }) + + it('inverts a merge to previous slice', () => { + const prev = { a: 1, b: 2 } + const inv = invertEntityPatch(prev, { op: Op.Merge, path: [], value: { b: 9 } }) + expect(inv).toEqual({ op: Op.Merge, path: [], value: { b: 2 } }) + expect(applyPatch(applyPatch(prev, { op: Op.Merge, path: [], value: { b: 9 } }), inv)).toEqual(prev) + }) +}) diff --git a/vue-sync-engine/src/engine/__tests__/engine.test.ts b/vue-sync-engine/src/engine/__tests__/engine.test.ts new file mode 100644 index 0000000..fee431f --- /dev/null +++ b/vue-sync-engine/src/engine/__tests__/engine.test.ts @@ -0,0 +1,219 @@ +import { describe, expect, it, vi } from 'vitest' +import { effectScope } from 'vue' +import { createInlineTransport } from '../transport/InlineTransport' +import { createMirror } from '../tab/mirror' +import { createTabRuntime } from '../tab/runtime' +import { createQueryGraph, type AnyQueryDef } from '../worker/queryGraph' +import { memoryAdapter } from '../adapters/storageAdapter' +import { Status } from '../core/flags' +import { flush, makeUserDefs, type ListUsersResp, type User, UserEntity } from './fixtures' + +function setup(api: { list: any; update: any }) { + const defs = makeUserDefs(api) + const storage = memoryAdapter() + const { client, server } = createInlineTransport() + let onlineCb: (() => void) | null = null + let online = true + createQueryGraph({ + storage, + endpoint: server, + registry: { + entities: new Map([[UserEntity.name, UserEntity]]), + queries: new Map([ + [defs.usersList.name, defs.usersList], + [defs.usersInfinite.name, defs.usersInfinite], + ]), + mutations: new Map([[defs.updateUser.name, defs.updateUser]]), + }, + isOnline: () => online, + onOnline: (cb) => { + onlineCb = cb + return () => {} + }, + }) + const mirror = createMirror() + const runtime = createTabRuntime({ transport: client, mirror, staleSubGcMs: 10 }) + return { + runtime, + defs, + storage, + setOnline(v: boolean) { + online = v + if (v && onlineCb) onlineCb() + }, + } +} + +describe('useQuery + QueryGraph', () => { + it('fetches, normalizes entities, and exposes result via mirror', async () => { + const list = vi.fn(async (): Promise => ({ + items: [ + { id: '1', name: 'Ada', age: 30 }, + { id: '2', name: 'Bob', age: 40 }, + ], + nextCursor: null, + })) + const { runtime, defs } = setup({ list, update: vi.fn() }) + + const scope = effectScope() + let handle!: ReturnType + scope.run(() => { + handle = runtime.subscribeQuery(defs.usersList.name, defs.usersList.key({}), {}) + }) + + await flush() + await flush() + + const state = runtime.mirror.ensureQuery<{ ids: string[] }>(handle.subId) + expect(state.value.status).toBe(Status.Success) + expect(state.value.data).toEqual({ ids: ['1', '2'] }) + expect(runtime.mirror.getEntity("user", "1")).toEqual({ id: '1', name: 'Ada', age: 30 }) + + scope.stop() + }) + + it('dedupes parallel subscriptions to the same key (single fetch)', async () => { + const list = vi.fn(async () => ({ items: [{ id: '1', name: 'A', age: 1 }], nextCursor: null })) + const { runtime, defs } = setup({ list, update: vi.fn() }) + + const scope = effectScope() + scope.run(() => { + runtime.subscribeQuery(defs.usersList.name, defs.usersList.key({}), {}) + runtime.subscribeQuery(defs.usersList.name, defs.usersList.key({}), {}) + runtime.subscribeQuery(defs.usersList.name, defs.usersList.key({}), {}) + }) + + await flush() + await flush() + expect(list).toHaveBeenCalledTimes(1) + scope.stop() + }) + + it('hydrates from storage before network', async () => { + const list = vi.fn(async () => ({ items: [{ id: '1', name: 'Fresh', age: 10 }], nextCursor: null })) + const { runtime, defs, storage } = setup({ list, update: vi.fn() }) + + await storage.queries.write([{ + key: JSON.stringify(defs.usersList.key({})), + value: { + status: Status.Success, + result: { ids: ['cached'] }, + updatedAt: Date.now() - 10_000, + entityRefs: [], + }, + }]) + + const scope = effectScope() + let handle!: ReturnType + scope.run(() => { + handle = runtime.subscribeQuery(defs.usersList.name, defs.usersList.key({}), {}) + }) + + await flush() + const state = runtime.mirror.ensureQuery<{ ids: string[] }>(handle.subId) + expect(state.value.data).toEqual({ ids: ['cached'] }) + + await flush() + await flush() + expect(state.value.data).toEqual({ ids: ['1'] }) + scope.stop() + }) +}) + +describe('useMutation + queue', () => { + it('optimistic update is visible immediately, then confirmed by server response', async () => { + const serverDb = new Map([['1', { id: '1', name: 'A', age: 1 }]]) + const list = vi.fn(async () => ({ items: [...serverDb.values()], nextCursor: null })) + const update = vi.fn(async (i: { id: string; patch: Partial }) => { + const next = { ...serverDb.get(i.id)!, ...i.patch } + serverDb.set(i.id, next) + return next + }) + const { runtime, defs } = setup({ list, update }) + + const scope = effectScope() + scope.run(() => runtime.subscribeQuery(defs.usersList.name, defs.usersList.key({}), {})) + await flush() + await flush() + expect(runtime.mirror.getEntity("user", "1")?.name).toBe('A') + + const p = runtime.mutate(defs.updateUser.name, { id: '1', patch: { name: 'Renamed' } }) + await flush() + expect(runtime.mirror.getEntity("user", "1")?.name).toBe('Renamed') + + await p + await flush() + await flush() + expect(runtime.mirror.getEntity("user", "1")?.name).toBe('Renamed') + scope.stop() + }) + + it('rolls back on server rejection', async () => { + const list = vi.fn(async () => ({ items: [{ id: '1', name: 'A', age: 1 }], nextCursor: null })) + const update = vi.fn(async () => { + throw new Error('boom') + }) + const { runtime, defs } = setup({ list, update }) + + const scope = effectScope() + scope.run(() => runtime.subscribeQuery(defs.usersList.name, defs.usersList.key({}), {})) + await flush() + await flush() + + await expect( + runtime.mutate(defs.updateUser.name, { id: '1', patch: { name: 'Renamed' } }), + ).rejects.toThrow('boom') + + expect(runtime.mirror.getEntity("user", "1")?.name).toBe('A') + scope.stop() + }) +}) + +describe('useInfiniteQuery', () => { + it('appends pages on fetchNextPage', async () => { + let call = 0 + const list = vi.fn(async (args: { cursor?: string | null }): Promise => { + call++ + if (call === 1) return { items: [{ id: '1', name: 'A', age: 1 }], nextCursor: 'c1' } + if (call === 2) return { items: [{ id: '2', name: 'B', age: 2 }], nextCursor: null } + expect(args).toBeDefined() + throw new Error('no more') + }) + const { runtime, defs } = setup({ list, update: vi.fn() }) + + const scope = effectScope() + let handle!: ReturnType + scope.run(() => { + handle = runtime.subscribeQuery(defs.usersInfinite.name, defs.usersInfinite.key({}), {}) + }) + await flush() + await flush() + + type R = { ids: string[]; nextCursor: string | null } + const state = runtime.mirror.ensureQuery<{ pages: R[]; pageParams: unknown[] }>(handle.subId) + expect(state.value.data?.pages).toEqual([{ ids: ['1'], nextCursor: 'c1' }]) + + handle.fetchNextPage() + await flush() + await flush() + expect(state.value.data?.pages.length).toBe(2) + expect(state.value.data?.pages[1].ids).toEqual(['2']) + scope.stop() + }) +}) + +describe('GC', () => { + it('stops the scope after staleSubGcMs once refCount hits 0', async () => { + vi.useFakeTimers() + try { + const list = vi.fn(async () => ({ items: [], nextCursor: null })) + const { runtime, defs } = setup({ list, update: vi.fn() }) + const handle = runtime.subscribeQuery(defs.usersList.name, defs.usersList.key({}), {}) + handle.release() + vi.advanceTimersByTime(20) + expect(handle.scope.active).toBe(false) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/vue-sync-engine/src/engine/__tests__/fixtures.ts b/vue-sync-engine/src/engine/__tests__/fixtures.ts new file mode 100644 index 0000000..8fb037d --- /dev/null +++ b/vue-sync-engine/src/engine/__tests__/fixtures.ts @@ -0,0 +1,69 @@ +import { defineEntity, defineInfiniteQuery, defineMutation, defineQuery } from '../define' + +export interface User { + id: string + name: string + age: number +} + +export const UserEntity = defineEntity({ + name: 'user', + id: (u) => u.id, +}) + +export interface ListUsersResp { + items: User[] + nextCursor: string | null +} + +export const flush = () => + new Promise((r) => + queueMicrotask(() => + queueMicrotask(() => + queueMicrotask(() => queueMicrotask(() => queueMicrotask(r))), + ), + ), + ) + +export function makeUserDefs(api: { + list: (args: { search?: string; cursor?: string | null }) => Promise + update: (input: { id: string; patch: Partial }) => Promise +}) { + const usersList = defineQuery<{ search?: string }, ListUsersResp, { ids: string[] }>({ + name: 'users.list', + key: (args) => ['users', 'list', args.search ?? ''], + fetch: (args) => api.list({ search: args.search, cursor: null }), + normalize: (resp) => ({ + entities: { user: resp.items }, + result: { ids: resp.items.map((u) => u.id) }, + }), + tags: () => ['users'], + staleTime: 1000, + }) + + const usersInfinite = defineInfiniteQuery< + { search?: string }, + ListUsersResp, + string | null, + { ids: string[]; nextCursor: string | null } + >({ + name: 'users.infinite', + key: (args) => ['users', 'infinite', args.search ?? ''], + initialPageParam: null, + getNextPageParam: (last) => last.nextCursor, + fetch: (args, ctx) => api.list({ search: args.search, cursor: ctx.pageParam }), + normalize: (resp) => ({ + entities: { user: resp.items }, + result: { ids: resp.items.map((u) => u.id), nextCursor: resp.nextCursor }, + }), + }) + + const updateUser = defineMutation<{ id: string; patch: Partial }, User>({ + name: 'users.update', + fetch: (input) => api.update(input), + optimistic: (input, ctx) => ctx.patchEntity(UserEntity, input.id, input.patch), + invalidate: () => ['users'], + }) + + return { usersList, usersInfinite, updateUser } +} diff --git a/vue-sync-engine/src/engine/adapters/idbManager.ts b/vue-sync-engine/src/engine/adapters/idbManager.ts new file mode 100644 index 0000000..2e7a149 --- /dev/null +++ b/vue-sync-engine/src/engine/adapters/idbManager.ts @@ -0,0 +1,109 @@ +export interface StoreSpec { + name: string + keyPath?: string +} + +class IdbManager { + readonly dbName: string + private pending = new Map() + private dbPromise: Promise | null = null + + constructor(dbName: string) { + this.dbName = dbName + } + + registerStore(spec: StoreSpec | string): void { + const s: StoreSpec = typeof spec === 'string' ? { name: spec } : spec + const cur = this.pending.get(s.name) + if (cur === undefined || (cur.keyPath === undefined && s.keyPath !== undefined)) { + this.pending.set(s.name, s) + } + } + + async getDb(): Promise { + if (this.dbPromise) { + const db = await this.dbPromise + const missing = this.missing(db) + if (missing.length === 0) return db + db.close() + this.dbPromise = this.open(db.version + 1, missing) + return this.dbPromise + } + this.dbPromise = (async () => { + const initial = [...this.pending.values()] + const db = await this.open(undefined, initial) + const missing = this.missing(db) + if (missing.length === 0) return db + db.close() + return this.open(db.version + 1, missing) + })() + return this.dbPromise + } + + async run( + storeName: string, + mode: IDBTransactionMode, + fn: (store: IDBObjectStore) => IDBRequest, + ): Promise { + const db = await this.getDb() + return new Promise((resolve, reject) => { + const tx = db.transaction(storeName, mode) + const req = fn(tx.objectStore(storeName)) + req.onsuccess = () => resolve(req.result) + req.onerror = () => reject(req.error) + }) + } + + async runTx( + storeName: string, + mode: IDBTransactionMode, + fn: (store: IDBObjectStore) => void, + ): Promise { + const db = await this.getDb() + return new Promise((resolve, reject) => { + const tx = db.transaction(storeName, mode) + fn(tx.objectStore(storeName)) + tx.oncomplete = () => resolve() + tx.onerror = () => reject(tx.error) + }) + } + + private missing(db: IDBDatabase): StoreSpec[] { + const out: StoreSpec[] = [] + for (const s of this.pending.values()) if (!db.objectStoreNames.contains(s.name)) out.push(s) + return out + } + + private open(version: number | undefined, create: readonly StoreSpec[]): Promise { + return new Promise((resolve, reject) => { + const req = version === undefined ? indexedDB.open(this.dbName) : indexedDB.open(this.dbName, version) + req.onupgradeneeded = () => { + const db = req.result + for (const s of create) { + if (db.objectStoreNames.contains(s.name)) continue + db.createObjectStore(s.name, s.keyPath ? { keyPath: s.keyPath } : undefined) + } + } + req.onsuccess = () => { + const db = req.result + db.onversionchange = () => db.close() + resolve(db) + } + req.onerror = () => reject(req.error) + req.onblocked = () => reject(new Error(`IDB open blocked: ${this.dbName}`)) + }) + } +} + +const managers = new Map() + +export function getIdbManager(dbName: string): IdbManager { + let m = managers.get(dbName) + if (!m) { + m = new IdbManager(dbName) + managers.set(dbName, m) + } + return m +} + +export type { IdbManager } diff --git a/vue-sync-engine/src/engine/adapters/idbStore.ts b/vue-sync-engine/src/engine/adapters/idbStore.ts new file mode 100644 index 0000000..60f9caa --- /dev/null +++ b/vue-sync-engine/src/engine/adapters/idbStore.ts @@ -0,0 +1,58 @@ +import type { EntityId } from '../core/types' +import type { KeyedStore, KeyedStoreFactory } from '../core/keyedStore' +import { getIdbManager } from './idbManager' + +export interface IdbStoreOptions { + dbName: string + storeName?: string +} + +export function idbStore(opts: IdbStoreOptions): KeyedStoreFactory { + const mgr = getIdbManager(opts.dbName) + return (name) => { + const store = opts.storeName ?? name + mgr.registerStore(store) + return { + read(key: EntityId) { + return mgr.run(store, 'readonly', (s) => s.get(asKey(key)) as IDBRequest) + }, + async readMany(keys: readonly EntityId[]) { + if (keys.length === 0) return [] + const db = await mgr.getDb() + return new Promise>((resolve, reject) => { + const tx = db.transaction(store, 'readonly') + const os = tx.objectStore(store) + const out: Array = new Array(keys.length) + let pending = keys.length + for (let i = 0; i < keys.length; i++) { + const req = os.get(asKey(keys[i])) + const idx = i + req.onsuccess = () => { + out[idx] = req.result as T | undefined + if (--pending === 0) resolve(out) + } + req.onerror = () => reject(req.error) + } + }) + }, + readAll() { + return mgr.run(store, 'readonly', (s) => s.getAll() as IDBRequest) + }, + write(items) { + if (items.length === 0) return Promise.resolve() + return mgr.runTx(store, 'readwrite', (os) => { + for (let i = 0; i < items.length; i++) os.put(items[i].value, asKey(items[i].key)) + }) + }, + delete(key: EntityId) { + return mgr.runTx(store, 'readwrite', (os) => { + os.delete(asKey(key)) + }) + }, + } satisfies KeyedStore + } +} + +function asKey(k: EntityId): IDBValidKey { + return typeof k === 'number' ? k : String(k) +} diff --git a/vue-sync-engine/src/engine/adapters/memoryStore.ts b/vue-sync-engine/src/engine/adapters/memoryStore.ts new file mode 100644 index 0000000..4c61bba --- /dev/null +++ b/vue-sync-engine/src/engine/adapters/memoryStore.ts @@ -0,0 +1,45 @@ +import type { EntityId } from '../core/types' +import type { KeyedStore, KeyedStoreFactory } from '../core/keyedStore' + +export function memoryStore(): KeyedStoreFactory { + return () => { + const m = new Map() + return { + async read(key) { + return m.get(key) + }, + async readMany(keys) { + const out: Array = new Array(keys.length) + for (let i = 0; i < keys.length; i++) out[i] = m.get(keys[i]) + return out + }, + async readAll() { + return [...m.values()] + }, + async write(items) { + for (let i = 0; i < items.length; i++) m.set(items[i].key, items[i].value) + }, + async delete(key) { + m.delete(key) + }, + } satisfies KeyedStore + } +} + +export function noopStore(): KeyedStoreFactory { + return () => noop as KeyedStore +} + +const noop: KeyedStore = { + async read() { + return undefined + }, + async readMany(keys) { + return new Array(keys.length).fill(undefined) + }, + async readAll() { + return [] + }, + async write() {}, + async delete() {}, +} diff --git a/vue-sync-engine/src/engine/adapters/storageAdapter.ts b/vue-sync-engine/src/engine/adapters/storageAdapter.ts new file mode 100644 index 0000000..0ad47ca --- /dev/null +++ b/vue-sync-engine/src/engine/adapters/storageAdapter.ts @@ -0,0 +1,28 @@ +import type { QueuedMutation, QuerySnapshot } from '../core/types' +import type { KeyedStore } from '../core/keyedStore' +import { memoryStore } from './memoryStore' +import { idbStore } from './idbStore' + +export interface StorageAdapter { + queries: KeyedStore + mutations: KeyedStore +} + +export function memoryAdapter(): StorageAdapter { + return { + queries: memoryStore()('queries'), + mutations: memoryStore()('mutations'), + } +} + +export interface IndexedDBAdapterOptions { + dbName?: string +} + +export function indexedDBAdapter(opts: IndexedDBAdapterOptions = {}): StorageAdapter { + const dbName = opts.dbName ?? 'sync-engine' + return { + queries: idbStore({ dbName })('queries'), + mutations: idbStore({ dbName })('mutations'), + } +} diff --git a/vue-sync-engine/src/engine/composables/useEngine.ts b/vue-sync-engine/src/engine/composables/useEngine.ts new file mode 100644 index 0000000..8334130 --- /dev/null +++ b/vue-sync-engine/src/engine/composables/useEngine.ts @@ -0,0 +1,10 @@ +import { inject, type InjectionKey } from 'vue' +import type { TabRuntime } from '../tab/runtime' + +export const EngineKey: InjectionKey = Symbol('SyncEngine') + +export function useEngine(): TabRuntime { + const rt = inject(EngineKey) + if (!rt) throw new Error('SyncEngine is not provided. Call app.provide(EngineKey, runtime).') + return rt +} diff --git a/vue-sync-engine/src/engine/composables/useEntity.ts b/vue-sync-engine/src/engine/composables/useEntity.ts new file mode 100644 index 0000000..847950a --- /dev/null +++ b/vue-sync-engine/src/engine/composables/useEntity.ts @@ -0,0 +1,15 @@ +import { computed, type ComputedRef, type MaybeRefOrGetter, toValue } from 'vue' +import type { EntityDef, EntityId } from '../core/types' +import { useEngine } from './useEngine' + +export function useEntity( + def: EntityDef, + id: MaybeRefOrGetter, +): ComputedRef { + const engine = useEngine() + return computed(() => { + const v = toValue(id) + if (v === undefined || v === null) return undefined + return engine.mirror.getEntity(def.name, v) + }) +} diff --git a/vue-sync-engine/src/engine/composables/useInfiniteQuery.ts b/vue-sync-engine/src/engine/composables/useInfiniteQuery.ts new file mode 100644 index 0000000..5a7d10c --- /dev/null +++ b/vue-sync-engine/src/engine/composables/useInfiniteQuery.ts @@ -0,0 +1,54 @@ +import { computed, onScopeDispose, watch, type ComputedRef, type MaybeRefOrGetter, toValue } from 'vue' +import type { InfiniteQueryDef, QueryStatus } from '../core/types' +import { Status } from '../core/flags' +import { hashKey } from '../core/queryKey' +import { useEngine } from './useEngine' + +export interface UseInfiniteQueryReturn { + pages: ComputedRef + pageParams: ComputedRef + status: ComputedRef + error: ComputedRef<{ message: string } | undefined> + isLoading: ComputedRef + fetchNextPage: () => void +} + +interface InfinitePayload { + pages: T[] + pageParams: unknown[] +} + +export function useInfiniteQuery( + def: InfiniteQueryDef & { name: string }, + args: MaybeRefOrGetter, +): UseInfiniteQueryReturn { + const engine = useEngine() + + const initial = toValue(args) + let handle = engine.subscribeQuery(def.name, def.key(initial), initial) + let stateRef = engine.mirror.ensureQuery>(handle.subId) + + if (!def.staticHash) { + watch( + () => hashKey(def.key(toValue(args))), + () => { + const next = toValue(args) + const prev = handle + handle = engine.subscribeQuery(def.name, def.key(next), next) + stateRef = engine.mirror.ensureQuery>(handle.subId) + prev.release() + }, + ) + } + + onScopeDispose(() => handle.release()) + + return { + pages: computed(() => stateRef.value.data?.pages ?? []), + pageParams: computed(() => stateRef.value.data?.pageParams ?? []), + status: computed(() => stateRef.value.status), + error: computed(() => stateRef.value.error), + isLoading: computed(() => stateRef.value.status === Status.Pending), + fetchNextPage: () => handle.fetchNextPage(), + } +} diff --git a/vue-sync-engine/src/engine/composables/useMutation.ts b/vue-sync-engine/src/engine/composables/useMutation.ts new file mode 100644 index 0000000..e540b33 --- /dev/null +++ b/vue-sync-engine/src/engine/composables/useMutation.ts @@ -0,0 +1,42 @@ +import { shallowRef, type ShallowRef } from 'vue' +import type { MutationDef, QueryStatus } from '../core/types' +import { Status } from '../core/flags' +import { useEngine } from './useEngine' + +export interface UseMutationReturn { + mutate: (input: TInput) => void + mutateAsync: (input: TInput) => Promise + status: ShallowRef + error: ShallowRef + data: ShallowRef +} + +export function useMutation( + def: MutationDef, +): UseMutationReturn { + const engine = useEngine() + const status = shallowRef(Status.Idle) + const error = shallowRef(undefined) + const data = shallowRef(undefined) + + async function mutateAsync(input: TInput): Promise { + status.value = Status.Pending + error.value = undefined + try { + const resp = (await engine.mutate(def.name, input)) as TResp + data.value = resp + status.value = Status.Success + return resp + } catch (e) { + error.value = e as Error + status.value = Status.Error + throw e + } + } + + function mutate(input: TInput): void { + void mutateAsync(input).catch(() => {}) + } + + return { mutate, mutateAsync, status, error, data } +} diff --git a/vue-sync-engine/src/engine/composables/useQuery.ts b/vue-sync-engine/src/engine/composables/useQuery.ts new file mode 100644 index 0000000..ce91668 --- /dev/null +++ b/vue-sync-engine/src/engine/composables/useQuery.ts @@ -0,0 +1,49 @@ +import { computed, onScopeDispose, watch, type ComputedRef, type MaybeRefOrGetter, toValue } from 'vue' +import type { InfiniteQueryDef, QueryDef, QueryStatus } from '../core/types' +import { Status } from '../core/flags' +import { hashKey } from '../core/queryKey' +import { useEngine } from './useEngine' + +export interface UseQueryReturn { + data: ComputedRef + status: ComputedRef + error: ComputedRef<{ message: string } | undefined> + isLoading: ComputedRef + isSuccess: ComputedRef + isError: ComputedRef +} + +export function useQuery( + def: (QueryDef | InfiniteQueryDef) & { name: string }, + args: MaybeRefOrGetter, +): UseQueryReturn { + const engine = useEngine() + + const initial = toValue(args) + let currentHandle = engine.subscribeQuery(def.name, def.key(initial), initial) + let currentRef = engine.mirror.ensureQuery(currentHandle.subId) + + if (!def.staticHash) { + watch( + () => hashKey(def.key(toValue(args))), + () => { + const next = toValue(args) + const prev = currentHandle + currentHandle = engine.subscribeQuery(def.name, def.key(next), next) + currentRef = engine.mirror.ensureQuery(currentHandle.subId) + prev.release() + }, + ) + } + + onScopeDispose(() => currentHandle.release()) + + return { + data: computed(() => currentRef.value.data), + status: computed(() => currentRef.value.status), + error: computed(() => currentRef.value.error), + isLoading: computed(() => currentRef.value.status === Status.Pending), + isSuccess: computed(() => currentRef.value.status === Status.Success), + isError: computed(() => currentRef.value.status === Status.Error), + } +} diff --git a/vue-sync-engine/src/engine/core/flags.ts b/vue-sync-engine/src/engine/core/flags.ts new file mode 100644 index 0000000..efa0ddf --- /dev/null +++ b/vue-sync-engine/src/engine/core/flags.ts @@ -0,0 +1,33 @@ +export const Op = { + Set: 1, + Merge: 2, + Delete: 4, +} as const +export type OpFlag = 1 | 2 | 4 + +export const Status = { + Idle: 0, + Pending: 1, + Success: 2, + Error: 3, +} as const +export type StatusFlag = 0 | 1 | 2 | 3 + +export const Msg = { + Subscribe: 1, + Unsubscribe: 2, + Mutate: 3, + FetchNextPage: 4, + QueryPatch: 5, + EntityPatch: 6, + MutateResult: 7, +} as const +export type MsgKind = 1 | 2 | 3 | 4 | 5 | 6 | 7 + +export const Kind = { + Entity: 1, + Query: 2, + Infinite: 3, + Mutation: 4, +} as const +export type KindFlag = 1 | 2 | 3 | 4 diff --git a/vue-sync-engine/src/engine/core/keyedStore.ts b/vue-sync-engine/src/engine/core/keyedStore.ts new file mode 100644 index 0000000..bb7bb4b --- /dev/null +++ b/vue-sync-engine/src/engine/core/keyedStore.ts @@ -0,0 +1,11 @@ +import type { EntityId } from './types' + +export interface KeyedStore { + read(key: EntityId): Promise + readMany(keys: readonly EntityId[]): Promise> + readAll(): Promise + write(items: ReadonlyArray<{ key: EntityId; value: T }>): Promise + delete(key: EntityId): Promise +} + +export type KeyedStoreFactory = (name: string) => KeyedStore diff --git a/vue-sync-engine/src/engine/core/patches.ts b/vue-sync-engine/src/engine/core/patches.ts new file mode 100644 index 0000000..513e423 --- /dev/null +++ b/vue-sync-engine/src/engine/core/patches.ts @@ -0,0 +1,48 @@ +import type { Patch } from './types' +import { Op } from './flags' + +export function applyPatch(target: T, patch: Patch): T { + if (patch.path.length === 0) { + if (patch.op === Op.Set) return patch.value as T + if (patch.op === Op.Merge) return { ...(target as object), ...patch.value } as T + return undefined as T + } + const next: any = Array.isArray(target) ? [...target] : { ...(target as any) } + let cur = next + for (let i = 0; i < patch.path.length - 1; i++) { + const k = patch.path[i] as any + const child = cur[k] + cur[k] = Array.isArray(child) ? [...child] : { ...(child ?? {}) } + cur = cur[k] + } + const last = patch.path[patch.path.length - 1] as any + if (patch.op === Op.Set) cur[last] = patch.value + else if (patch.op === Op.Merge) cur[last] = { ...(cur[last] ?? {}), ...patch.value } + else cur[last] = undefined + return next +} + +export function invertEntityPatch(prev: T | undefined, patch: Patch): Patch { + if (patch.op === Op.Set) { + return prev === undefined + ? { op: Op.Delete, path: patch.path } + : { op: Op.Set, path: patch.path, value: getAt(prev, patch.path) } + } + if (patch.op === Op.Delete) { + return { op: Op.Set, path: patch.path, value: prev === undefined ? undefined : getAt(prev, patch.path) } + } + const prevSlice: Record = {} + for (const k of Object.keys(patch.value)) { + prevSlice[k] = prev === undefined ? undefined : (getAt(prev, [...patch.path, k]) as unknown) + } + return { op: Op.Merge, path: patch.path, value: prevSlice } +} + +function getAt(obj: any, path: readonly (string | number)[]): unknown { + let cur = obj + for (const k of path) { + if (cur == null) return undefined + cur = cur[k as any] + } + return cur +} diff --git a/vue-sync-engine/src/engine/core/queryKey.ts b/vue-sync-engine/src/engine/core/queryKey.ts new file mode 100644 index 0000000..e378831 --- /dev/null +++ b/vue-sync-engine/src/engine/core/queryKey.ts @@ -0,0 +1,43 @@ +import type { QueryKey } from './types' + +export function hashKey(key: QueryKey): string { + let s = '[' + for (let i = 0; i < key.length; i++) { + if (i > 0) s += ',' + s += stringify(key[i]) + } + return s + ']' +} + +export function entityKey(type: string, id: string | number): string { + return `${type}\u0000${id}` +} + +function stringify(v: unknown): string { + if (v === null) return 'null' + const t = typeof v + if (t === 'string') return JSON.stringify(v) + if (t === 'number') return v === v && v !== Infinity && v !== -Infinity ? String(v) : 'null' + if (t === 'boolean') return v ? 'true' : 'false' + if (t === 'undefined') return 'null' + if (Array.isArray(v)) { + let s = '[' + for (let i = 0; i < v.length; i++) { + if (i > 0) s += ',' + s += stringify(v[i]) + } + return s + ']' + } + if (t === 'object') { + const o = v as Record + const keys = Object.keys(o).sort() + let s = '{' + for (let i = 0; i < keys.length; i++) { + if (i > 0) s += ',' + const k = keys[i] + s += JSON.stringify(k) + ':' + stringify(o[k]) + } + return s + '}' + } + return 'null' +} diff --git a/vue-sync-engine/src/engine/core/types.ts b/vue-sync-engine/src/engine/core/types.ts new file mode 100644 index 0000000..e66434f --- /dev/null +++ b/vue-sync-engine/src/engine/core/types.ts @@ -0,0 +1,101 @@ +export type EntityId = string | number + +import type { StatusFlag, Kind } from './flags' +import type { KeyedStore } from './keyedStore' + +export interface EntityDef { + readonly kind: typeof Kind.Entity + readonly name: string + readonly id: (entity: T) => EntityId + readonly storage?: KeyedStore +} + +export interface NormalizedResult { + entities: Record> + result: unknown +} + +export interface ExecCtx { + readonly signal: AbortSignal + readonly pageParam: unknown +} +export interface ExecResult { + readonly pageResult: unknown + readonly entities: Record> | null +} + +export interface QueryDef { + readonly kind: typeof Kind.Query + readonly key: (args: TArgs) => readonly unknown[] + readonly fetch: (args: TArgs, ctx: FetchCtx) => Promise + readonly normalize?: (resp: TResp, args: TArgs) => { entities?: Record>; result: TResult } + readonly tags?: (args: TArgs) => readonly string[] + readonly staleTime?: number + readonly gcTime?: number + readonly staticHash?: string | null + readonly exec?: (args: TArgs, ctx: ExecCtx) => Promise +} + +export interface InfiniteQueryDef + extends Omit, 'kind' | 'fetch' | 'normalize' | 'exec'> { + readonly kind: typeof Kind.Infinite + readonly initialPageParam: TPageParam + readonly getNextPageParam: (lastPage: TResult, allPages: TResult[]) => TPageParam | null | undefined + readonly fetch: (args: TArgs, ctx: FetchCtx & { pageParam: TPageParam }) => Promise + readonly normalize?: (resp: TResp, args: TArgs, pageParam: TPageParam) => { entities?: Record>; result: TResult } + readonly exec?: (args: TArgs, ctx: ExecCtx) => Promise +} + +export interface MutationDef { + readonly kind: typeof Kind.Mutation + readonly name: string + readonly fetch: (input: TInput, ctx: FetchCtx) => Promise + readonly optimistic?: (input: TInput, ctx: OptimisticCtx) => void + readonly onSuccess?: (resp: TResp, input: TInput, ctx: OptimisticCtx) => void + readonly invalidate?: (input: TInput, resp?: TResp) => ReadonlyArray + readonly maxRetries?: number +} + +export interface FetchCtx { + readonly signal: AbortSignal +} + +export interface OptimisticCtx { + patchEntity(def: EntityDef, id: EntityId, patch: Partial): void + removeEntity(def: EntityDef, id: EntityId): void + upsertEntity(def: EntityDef, entity: T): void +} + +export type Patch = + | { op: 1; path: readonly (string | number)[]; value: unknown } + | { op: 2; path: readonly (string | number)[]; value: Record } + | { op: 4; path: readonly (string | number)[] } + +export interface EntityPatch { + type: string + id: EntityId + patch: Patch +} + +export type QueryStatus = StatusFlag + +export interface QuerySnapshot { + status: QueryStatus + result?: TResult + error?: { message: string } + updatedAt?: number + entityRefs?: ReadonlyArray<{ type: string; id: EntityId }> +} + +export interface QueuedMutation { + id: string + seq: number + name: string + input: unknown + inversePatches?: EntityPatch[] + createdAt: number + attempts: number + state: 'pending' | 'inflight' | 'failed' +} + +export type QueryKey = readonly unknown[] diff --git a/vue-sync-engine/src/engine/createEngine.ts b/vue-sync-engine/src/engine/createEngine.ts new file mode 100644 index 0000000..a660922 --- /dev/null +++ b/vue-sync-engine/src/engine/createEngine.ts @@ -0,0 +1,73 @@ +import type { App } from 'vue' +import type { EntityDef, InfiniteQueryDef, MutationDef, QueryDef } from './core/types' +import type { StorageAdapter } from './adapters/storageAdapter' +import { memoryAdapter } from './adapters/storageAdapter' +import { createInlineTransport } from './transport/InlineTransport' +import { createQueryGraph } from './worker/queryGraph' +import type { ServerEndpoint, Transport } from './transport/protocol' +import { createMirror } from './tab/mirror' +import { createTabRuntime, type TabRuntime } from './tab/runtime' +import { EngineKey } from './composables/useEngine' + +export interface WorkerBootstrapOptions { + entities: ReadonlyArray + queries: ReadonlyArray<(QueryDef | InfiniteQueryDef) & { name: string }> + mutations: ReadonlyArray + storage: StorageAdapter + endpoint: ServerEndpoint + defaultStaleTime?: number + defaultGcTime?: number +} + +export function bootstrapWorker(opts: WorkerBootstrapOptions): void { + const registry = { + entities: new Map(opts.entities.map((e) => [e.name, e])), + queries: new Map(opts.queries.map((q) => [q.name, q])), + mutations: new Map(opts.mutations.map((m) => [m.name, m])), + } + createQueryGraph({ + storage: opts.storage, + endpoint: opts.endpoint, + registry, + defaultStaleTime: opts.defaultStaleTime, + defaultGcTime: opts.defaultGcTime, + }) +} + +export interface TabEngineOptions { + transport: Transport + staleSubGcMs?: number +} + +export function createTabEngine(opts: TabEngineOptions): TabRuntime { + const mirror = createMirror() + return createTabRuntime({ transport: opts.transport, mirror, staleSubGcMs: opts.staleSubGcMs }) +} + +export interface EngineOptions { + entities: ReadonlyArray + queries: ReadonlyArray<(QueryDef | InfiniteQueryDef) & { name: string }> + mutations: ReadonlyArray + storage?: StorageAdapter + defaultStaleTime?: number + defaultGcTime?: number +} + +export function createEngine(opts: EngineOptions): TabRuntime { + const storage = opts.storage ?? memoryAdapter() + const { client, server } = createInlineTransport() + bootstrapWorker({ + entities: opts.entities, + queries: opts.queries, + mutations: opts.mutations, + storage, + endpoint: server, + defaultStaleTime: opts.defaultStaleTime, + defaultGcTime: opts.defaultGcTime, + }) + return createTabEngine({ transport: client }) +} + +export function installEngine(app: App, runtime: TabRuntime): void { + app.provide(EngineKey, runtime) +} diff --git a/vue-sync-engine/src/engine/define.ts b/vue-sync-engine/src/engine/define.ts new file mode 100644 index 0000000..f971437 --- /dev/null +++ b/vue-sync-engine/src/engine/define.ts @@ -0,0 +1,85 @@ +import type { EntityDef, ExecCtx, ExecResult, FetchCtx, InfiniteQueryDef, MutationDef, QueryDef } from './core/types' +import type { KeyedStoreFactory } from './core/keyedStore' +import { Kind } from './core/flags' +import { hashKey } from './core/queryKey' + +export function defineEntity(def: { + name: string + id: (e: T) => string | number + storage?: KeyedStoreFactory +}): EntityDef { + const storage = def.storage ? def.storage(def.name) : undefined + return Object.freeze({ kind: Kind.Entity, name: def.name, id: def.id, storage }) +} + +export function defineQuery( + def: Omit, 'kind' | 'staticHash' | 'exec'> & { name: string }, +): QueryDef & { name: string } { + return Object.freeze({ + kind: Kind.Query, + ...def, + staticHash: precomputeStaticHash(def.key), + exec: makeQueryExec(def.fetch, def.normalize), + }) +} + +export function defineInfiniteQuery( + def: Omit, 'kind' | 'staticHash' | 'exec'> & { name: string }, +): InfiniteQueryDef & { name: string } { + return Object.freeze({ + kind: Kind.Infinite, + ...def, + staticHash: precomputeStaticHash(def.key), + exec: makeInfiniteExec(def.fetch, def.normalize), + }) +} + +export function defineMutation( + def: Omit, 'kind'>, +): MutationDef { + return Object.freeze({ kind: Kind.Mutation, ...def }) +} + +function precomputeStaticHash(key: (args: any) => readonly unknown[]): string | null { + if (key.length !== 0) return null + try { + return hashKey(key(undefined)) + } catch { + return null + } +} + +function makeQueryExec( + fetch: (args: TArgs, ctx: FetchCtx) => Promise, + normalize?: (resp: TResp, args: TArgs) => { entities?: Record>; result: TResult }, +): (args: TArgs, ctx: ExecCtx) => Promise { + if (normalize) { + return async (args, ctx) => { + const resp = await fetch(args, { signal: ctx.signal }) + const norm = normalize(resp, args) + return { pageResult: norm.result, entities: norm.entities ?? null } + } + } + return async (args, ctx) => { + const resp = await fetch(args, { signal: ctx.signal }) + return { pageResult: resp, entities: null } + } +} + +function makeInfiniteExec( + fetch: (args: TArgs, ctx: FetchCtx & { pageParam: TPageParam }) => Promise, + normalize?: (resp: TResp, args: TArgs, pageParam: TPageParam) => { entities?: Record>; result: TResult }, +): (args: TArgs, ctx: ExecCtx) => Promise { + if (normalize) { + return async (args, ctx) => { + const pp = ctx.pageParam as TPageParam + const resp = await fetch(args, { signal: ctx.signal, pageParam: pp }) + const norm = normalize(resp, args, pp) + return { pageResult: norm.result, entities: norm.entities ?? null } + } + } + return async (args, ctx) => { + const resp = await fetch(args, { signal: ctx.signal, pageParam: ctx.pageParam as TPageParam }) + return { pageResult: resp, entities: null } + } +} diff --git a/vue-sync-engine/src/engine/index.ts b/vue-sync-engine/src/engine/index.ts new file mode 100644 index 0000000..1fc328a --- /dev/null +++ b/vue-sync-engine/src/engine/index.ts @@ -0,0 +1,31 @@ +export * from './core/types' +export type { KeyedStore, KeyedStoreFactory } from './core/keyedStore' +export { hashKey, entityKey } from './core/queryKey' +export { Op, Status, Msg, Kind } from './core/flags' +export type { OpFlag, StatusFlag, MsgKind, KindFlag } from './core/flags' +export { defineEntity, defineQuery, defineInfiniteQuery, defineMutation } from './define' +export { + createEngine, + installEngine, + bootstrapWorker, + createTabEngine, + type EngineOptions, + type TabEngineOptions, + type WorkerBootstrapOptions, +} from './createEngine' +export { EngineKey, useEngine } from './composables/useEngine' +export { useQuery } from './composables/useQuery' +export { useInfiniteQuery } from './composables/useInfiniteQuery' +export { useEntity } from './composables/useEntity' +export { useMutation } from './composables/useMutation' +export type { StorageAdapter } from './adapters/storageAdapter' +export { memoryAdapter, indexedDBAdapter, type IndexedDBAdapterOptions } from './adapters/storageAdapter' +export { memoryStore, noopStore } from './adapters/memoryStore' +export { idbStore, type IdbStoreOptions } from './adapters/idbStore' +export { createInlineTransport } from './transport/InlineTransport' +export { createSharedWorkerClientTransport, createSharedWorkerServerEndpoint } from './transport/SharedWorkerTransport' +export type { Transport, ServerEndpoint, ClientMsg, ServerMsg } from './transport/protocol' +export { createMirror } from './tab/mirror' +export { createTabRuntime, type TabRuntime } from './tab/runtime' +export { createQueryGraph } from './worker/queryGraph' +export { syncEnginePlugin, type SyncEnginePluginOptions } from './plugin' diff --git a/vue-sync-engine/src/engine/plugin.ts b/vue-sync-engine/src/engine/plugin.ts new file mode 100644 index 0000000..0879d4d --- /dev/null +++ b/vue-sync-engine/src/engine/plugin.ts @@ -0,0 +1,59 @@ +import type { Plugin } from 'vite' + +const VIRTUAL_ID = 'virtual:sync-engine-registry' +const RESOLVED_ID = '\0' + VIRTUAL_ID + +export interface SyncEnginePluginOptions { + definitions: string | readonly string[] +} + +export function syncEnginePlugin(opts: SyncEnginePluginOptions): Plugin { + const patterns = Array.isArray(opts.definitions) ? opts.definitions : [opts.definitions] + return { + name: 'vue-sync-engine:registry', + enforce: 'pre', + resolveId(id) { + if (id === VIRTUAL_ID) return RESOLVED_ID + return null + }, + load(id) { + if (id !== RESOLVED_ID) return null + const globs = patterns.map((p) => JSON.stringify(p)).join(', ') + return ` +const KIND_ENTITY = 1 +const KIND_QUERY = 2 +const KIND_INFINITE = 3 +const KIND_MUTATION = 4 +const modules = import.meta.glob([${globs}], { eager: true }) +const entities = [] +const queries = [] +const mutations = [] +const seenEntities = new Set() +const seenQueries = new Set() +const seenMutations = new Set() +for (const path in modules) { + const mod = modules[path] + for (const key in mod) { + const v = mod[key] + if (!v || typeof v !== 'object') continue + const k = v.kind + if (k === KIND_QUERY || k === KIND_INFINITE) { + if (typeof v.name !== 'string' || seenQueries.has(v.name)) continue + seenQueries.add(v.name) + queries.push(v) + } else if (k === KIND_MUTATION) { + if (typeof v.name !== 'string' || seenMutations.has(v.name)) continue + seenMutations.add(v.name) + mutations.push(v) + } else if (k === KIND_ENTITY) { + if (typeof v.name !== 'string' || seenEntities.has(v.name)) continue + seenEntities.add(v.name) + entities.push(v) + } + } +} +export default { entities, queries, mutations } +` + }, + } +} diff --git a/vue-sync-engine/src/engine/tab/mirror.ts b/vue-sync-engine/src/engine/tab/mirror.ts new file mode 100644 index 0000000..dbd77fb --- /dev/null +++ b/vue-sync-engine/src/engine/tab/mirror.ts @@ -0,0 +1,95 @@ +import { shallowRef, triggerRef, type ShallowRef } from 'vue' +import type { EntityId, EntityPatch, Patch, QueryStatus } from '../core/types' +import { Op, Status } from '../core/flags' +import { applyPatch } from '../core/patches' + +export interface QueryState { + status: QueryStatus + data: T | undefined + error: { message: string } | undefined +} + +export function createMirror() { + const entities = new Map>() + const versions = new Map>() + const queries = new Map>() + + function typeVersion(type: string): ShallowRef { + let v = versions.get(type) + if (!v) { + v = shallowRef(0) + versions.set(type, v) + } + return v + } + + function entityBucket(type: string): Map { + let b = entities.get(type) + if (!b) { + b = new Map() + entities.set(type, b) + } + return b + } + + function getEntity(type: string, id: EntityId): T | undefined { + typeVersion(type).value + const b = entities.get(type) + return b === undefined ? undefined : (b.get(id) as T | undefined) + } + + function applyEntityPatches(patches: EntityPatch[]): void { + if (patches.length === 0) return + let lastType = '' + let bucket: Map | undefined + let touchedFirst: string | undefined + let touchedRest: Set | undefined + for (let i = 0; i < patches.length; i++) { + const p = patches[i] + if (p.type !== lastType) { + lastType = p.type + bucket = entityBucket(lastType) + if (touchedFirst === undefined) touchedFirst = lastType + else if (lastType !== touchedFirst) { + if (touchedRest === undefined) touchedRest = new Set() + touchedRest.add(lastType) + } + } + const patch = p.patch + if (patch.op === Op.Delete && patch.path.length === 0) { + bucket!.delete(p.id) + } else { + bucket!.set(p.id, applyPatch(bucket!.get(p.id), patch)) + } + } + if (touchedFirst !== undefined) triggerRef(typeVersion(touchedFirst)) + if (touchedRest !== undefined) for (const t of touchedRest) triggerRef(typeVersion(t)) + } + + function ensureQuery(subId: string): ShallowRef> { + let r = queries.get(subId) as ShallowRef> | undefined + if (!r) { + r = shallowRef>({ status: Status.Idle, data: undefined, error: undefined }) + queries.set(subId, r as ShallowRef) + } + return r + } + + function applyQueryPatch(subId: string, status: QueryStatus, patch?: Patch, error?: { message: string }): void { + const r = ensureQuery(subId) + const prev = r.value + r.value = { + status, + data: patch ? applyPatch(prev.data, patch) : prev.data, + error: error ?? prev.error, + } + } + + function dropQuery(subId: string): void { + queries.delete(subId) + } + + return { entities, getEntity, applyEntityPatches, ensureQuery, applyQueryPatch, dropQuery } +} + +export type Mirror = ReturnType diff --git a/vue-sync-engine/src/engine/tab/runtime.ts b/vue-sync-engine/src/engine/tab/runtime.ts new file mode 100644 index 0000000..7e5c03a --- /dev/null +++ b/vue-sync-engine/src/engine/tab/runtime.ts @@ -0,0 +1,113 @@ +import { effectScope, type EffectScope } from 'vue' +import type { Transport } from '../transport/protocol' +import type { Mirror } from './mirror' +import { hashKey } from '../core/queryKey' +import { Msg } from '../core/flags' + +interface QuerySubHandle { + subId: string + refCount: number + scope: EffectScope + gcTimer: ReturnType | null + release: () => void + fetchNextPage: () => void +} + +export interface TabRuntime { + mirror: Mirror + transport: Transport + subscribeQuery(defName: string, key: readonly unknown[], args: unknown): QuerySubHandle + mutate(defName: string, input: unknown): Promise + dispose(): void +} + +export interface TabRuntimeOptions { + transport: Transport + mirror: Mirror + staleSubGcMs?: number +} + +export function createTabRuntime(opts: TabRuntimeOptions): TabRuntime { + const { transport, mirror } = opts + const staleSubGcMs = opts.staleSubGcMs ?? 5_000 + + const byKey = new Map() + const pendingMutations = new Map void; reject: (e: unknown) => void }>() + const tabId = + (typeof crypto !== 'undefined' && 'randomUUID' in crypto + ? crypto.randomUUID() + : Math.random().toString(36).slice(2)) + '-' + let subSeq = 0 + let mutSeq = 0 + + const off = transport.onMessage((msg) => { + if (msg.type === Msg.QueryPatch) { + mirror.applyQueryPatch(msg.subId, msg.status, msg.patch, msg.error) + } else if (msg.type === Msg.EntityPatch) { + mirror.applyEntityPatches(msg.patches) + } else if (msg.type === Msg.MutateResult) { + const p = pendingMutations.get(msg.mutId) + if (p) { + pendingMutations.delete(msg.mutId) + if (msg.ok) p.resolve(msg.data) + else p.reject(new Error(msg.error?.message ?? 'mutation failed')) + } + } + }) + + function subscribeQuery(defName: string, key: readonly unknown[], args: unknown): QuerySubHandle { + const hash = hashKey(key) + const existing = byKey.get(hash) + if (existing) { + if (existing.gcTimer !== null) { + clearTimeout(existing.gcTimer) + existing.gcTimer = null + } + existing.refCount++ + return existing + } + + const subId = `${tabId}s${++subSeq}` + const scope = effectScope(true) + mirror.ensureQuery(subId) + transport.send({ type: Msg.Subscribe, subId, defName, args }) + + const handle: QuerySubHandle = { + subId, + refCount: 1, + scope, + gcTimer: null, + fetchNextPage() { + transport.send({ type: Msg.FetchNextPage, subId }) + }, + release() { + handle.refCount-- + if (handle.refCount > 0) return + handle.gcTimer = setTimeout(() => { + byKey.delete(hash) + transport.send({ type: Msg.Unsubscribe, subId }) + mirror.dropQuery(subId) + scope.stop() + }, staleSubGcMs) + }, + } + byKey.set(hash, handle) + return handle + } + + function mutate(defName: string, input: unknown): Promise { + const mutId = `${tabId}m${++mutSeq}` + return new Promise((resolve, reject) => { + pendingMutations.set(mutId, { resolve, reject }) + transport.send({ type: Msg.Mutate, mutId, defName, input }) + }) + } + + function dispose(): void { + off() + for (const h of byKey.values()) h.scope.stop() + byKey.clear() + } + + return { mirror, transport, subscribeQuery, mutate, dispose } +} diff --git a/vue-sync-engine/src/engine/transport/InlineTransport.ts b/vue-sync-engine/src/engine/transport/InlineTransport.ts new file mode 100644 index 0000000..320e6a3 --- /dev/null +++ b/vue-sync-engine/src/engine/transport/InlineTransport.ts @@ -0,0 +1,56 @@ +import type { ClientMsg, ServerEndpoint, ServerMsg, Transport } from './protocol' + +export function createInlineTransport(): { client: Transport; server: ServerEndpoint } { + const clientHandlers = new Set<(m: ServerMsg) => void>() + const serverHandlers = new Set<(m: ClientMsg) => void>() + + let toServer: ClientMsg[] | null = null + let toClient: ServerMsg[] | null = null + + function drainToServer(): void { + const batch = toServer! + toServer = null + for (let i = 0; i < batch.length; i++) for (const h of serverHandlers) h(batch[i]) + } + + function drainToClient(): void { + const batch = toClient! + toClient = null + for (let i = 0; i < batch.length; i++) for (const h of clientHandlers) h(batch[i]) + } + + const client: Transport = { + send(msg) { + if (toServer) { + toServer.push(msg) + return + } + toServer = [msg] + queueMicrotask(drainToServer) + }, + onMessage(handler) { + clientHandlers.add(handler) + return () => clientHandlers.delete(handler) + }, + } + + const server: ServerEndpoint = { + receive(msg) { + for (const h of serverHandlers) h(msg) + }, + broadcast(msg) { + if (toClient) { + toClient.push(msg) + return + } + toClient = [msg] + queueMicrotask(drainToClient) + }, + onClient(handler) { + serverHandlers.add(handler) + return () => serverHandlers.delete(handler) + }, + } + + return { client, server } +} diff --git a/vue-sync-engine/src/engine/transport/SharedWorkerTransport.ts b/vue-sync-engine/src/engine/transport/SharedWorkerTransport.ts new file mode 100644 index 0000000..0a1dd48 --- /dev/null +++ b/vue-sync-engine/src/engine/transport/SharedWorkerTransport.ts @@ -0,0 +1,69 @@ +import type { ClientMsg, ServerEndpoint, ServerMsg, Transport } from './protocol' + +interface SharedWorkerLike { + port: MessagePort +} + +interface SharedWorkerScopeLike { + onconnect: ((ev: { ports: readonly MessagePort[] }) => void) | null +} + +export function createSharedWorkerClientTransport(worker: SharedWorkerLike): Transport { + const handlers = new Set<(m: ServerMsg) => void>() + worker.port.onmessage = (ev: MessageEvent) => { + for (const h of handlers) h(ev.data) + } + worker.port.start() + if (typeof window !== 'undefined') { + window.addEventListener('beforeunload', () => { + try { + worker.port.close() + } catch {} + }) + } + return { + send(msg) { + worker.port.postMessage(msg) + }, + onMessage(handler) { + handlers.add(handler) + return () => handlers.delete(handler) + }, + } +} + +export function createSharedWorkerServerEndpoint(scope: SharedWorkerScopeLike): ServerEndpoint { + const ports = new Set() + const clientHandlers = new Set<(m: ClientMsg) => void>() + + scope.onconnect = (ev) => { + const port = ev.ports[0] + ports.add(port) + port.onmessage = (msg: MessageEvent) => { + for (const h of clientHandlers) h(msg.data) + } + port.start() + } + + return { + receive(msg) { + for (const h of clientHandlers) h(msg) + }, + broadcast(msg) { + let dead: MessagePort[] | null = null + for (const port of ports) { + try { + port.postMessage(msg) + } catch { + if (dead === null) dead = [port] + else dead.push(port) + } + } + if (dead !== null) for (let i = 0; i < dead.length; i++) ports.delete(dead[i]) + }, + onClient(handler) { + clientHandlers.add(handler) + return () => clientHandlers.delete(handler) + }, + } +} diff --git a/vue-sync-engine/src/engine/transport/protocol.ts b/vue-sync-engine/src/engine/transport/protocol.ts new file mode 100644 index 0000000..cf9596d --- /dev/null +++ b/vue-sync-engine/src/engine/transport/protocol.ts @@ -0,0 +1,57 @@ +import type { EntityPatch, Patch, QueryStatus } from '../core/types' +import { Msg } from '../core/flags' + +export interface SubscribeMsg { + type: typeof Msg.Subscribe + subId: string + defName: string + args: unknown +} +export interface UnsubscribeMsg { + type: typeof Msg.Unsubscribe + subId: string +} +export interface MutateMsg { + type: typeof Msg.Mutate + mutId: string + defName: string + input: unknown +} +export interface FetchNextPageMsg { + type: typeof Msg.FetchNextPage + subId: string +} + +export type ClientMsg = SubscribeMsg | UnsubscribeMsg | MutateMsg | FetchNextPageMsg + +export interface QueryPatchMsg { + type: typeof Msg.QueryPatch + subId: string + status: QueryStatus + patch?: Patch + error?: { message: string } +} +export interface EntityPatchMsg { + type: typeof Msg.EntityPatch + patches: EntityPatch[] +} +export interface MutateResultMsg { + type: typeof Msg.MutateResult + mutId: string + ok: boolean + data?: unknown + error?: { message: string } +} + +export type ServerMsg = QueryPatchMsg | EntityPatchMsg | MutateResultMsg + +export interface Transport { + send(msg: ClientMsg): void + onMessage(handler: (msg: ServerMsg) => void): () => void +} + +export interface ServerEndpoint { + receive(msg: ClientMsg): void + broadcast(msg: ServerMsg): void + onClient(handler: (msg: ClientMsg) => void): () => void +} diff --git a/vue-sync-engine/src/engine/worker/mutationQueue.ts b/vue-sync-engine/src/engine/worker/mutationQueue.ts new file mode 100644 index 0000000..27e165c --- /dev/null +++ b/vue-sync-engine/src/engine/worker/mutationQueue.ts @@ -0,0 +1,128 @@ +import type { StorageAdapter } from '../adapters/storageAdapter' +import type { EntityPatch, MutationDef, OptimisticCtx, QueuedMutation } from '../core/types' + +export interface MutationQueueDeps { + storage: StorageAdapter + mutations: Map + emitEntityPatches: (patches: EntityPatch[]) => void + buildCtx: (forward: EntityPatch[], inverse: EntityPatch[]) => OptimisticCtx + buildPostCtx: (post: EntityPatch[]) => OptimisticCtx + invalidate: (def: MutationDef, input: unknown, resp: unknown) => void + isOnline: () => boolean + onOnline: (cb: () => void) => () => void + onResult: (mutId: string, ok: boolean, data?: unknown, error?: { message: string }) => void +} + +interface InMemoryEntry { + queued: QueuedMutation + inverse: EntityPatch[] +} + +export function createMutationQueue(deps: MutationQueueDeps) { + let seq = 0 + const inflight = new Map() + let processing = false + + function persist(m: QueuedMutation): Promise { + return deps.storage.mutations.write([{ key: m.id, value: m }]) + } + + async function init(): Promise { + const persisted = await deps.storage.mutations.readAll() + for (const m of persisted) { + if (m.seq > seq) seq = m.seq + inflight.set(m.id, { queued: m, inverse: m.inversePatches ?? [] }) + } + void drain() + deps.onOnline(() => void drain()) + } + + async function enqueue(mutId: string, defName: string, input: unknown): Promise { + const def = deps.mutations.get(defName) + if (!def) { + if (__SYNC_ENGINE_DEV__) { + deps.onResult(mutId, false, undefined, { message: `Unknown mutation: ${defName}` }) + } + return + } + + const forward: EntityPatch[] = [] + const inverse: EntityPatch[] = [] + if (def.optimistic) { + def.optimistic(input, deps.buildCtx(forward, inverse)) + if (forward.length) deps.emitEntityPatches(forward) + } + + const queued: QueuedMutation = { + id: mutId, + seq: ++seq, + name: defName, + input, + inversePatches: inverse, + createdAt: Date.now(), + attempts: 0, + state: 'pending', + } + await persist(queued) + inflight.set(mutId, { queued, inverse }) + void drain() + } + + async function drain(): Promise { + if (processing) return + processing = true + try { + const ordered = [...inflight.values()].sort((a, b) => a.queued.seq - b.queued.seq) + for (const entry of ordered) { + if (!deps.isOnline()) break + if (entry.queued.state === 'inflight') continue + await runOne(entry) + } + } finally { + processing = false + } + } + + async function runOne(entry: InMemoryEntry): Promise { + const def = deps.mutations.get(entry.queued.name) + if (!def) { + inflight.delete(entry.queued.id) + await deps.storage.mutations.delete(entry.queued.id) + return + } + entry.queued.state = 'inflight' + entry.queued.attempts++ + await persist(entry.queued) + const ctrl = new AbortController() + try { + const resp = await def.fetch(entry.queued.input, { signal: ctrl.signal }) + if (def.onSuccess) { + const post: EntityPatch[] = [] + def.onSuccess(resp, entry.queued.input, deps.buildPostCtx(post)) + if (post.length) deps.emitEntityPatches(post) + } + deps.invalidate(def, entry.queued.input, resp) + inflight.delete(entry.queued.id) + await deps.storage.mutations.delete(entry.queued.id) + deps.onResult(entry.queued.id, true, resp) + } catch (err) { + const networkLike = !deps.isOnline() || isNetworkError(err) + if (networkLike && entry.queued.attempts < (def.maxRetries ?? 5)) { + entry.queued.state = 'pending' + await persist(entry.queued) + return + } + if (entry.inverse.length) deps.emitEntityPatches([...entry.inverse].reverse()) + inflight.delete(entry.queued.id) + await deps.storage.mutations.delete(entry.queued.id) + deps.onResult(entry.queued.id, false, undefined, { message: (err as Error)?.message ?? String(err) }) + } + } + + return { init, enqueue, drain } +} + +function isNetworkError(err: unknown): boolean { + const msg = (err as Error)?.message?.toLowerCase() ?? '' + return msg.includes('network') || msg.includes('fetch') || msg.includes('failed to fetch') +} diff --git a/vue-sync-engine/src/engine/worker/queryGraph.ts b/vue-sync-engine/src/engine/worker/queryGraph.ts new file mode 100644 index 0000000..6100886 --- /dev/null +++ b/vue-sync-engine/src/engine/worker/queryGraph.ts @@ -0,0 +1,449 @@ +import type { StorageAdapter } from '../adapters/storageAdapter' +import type { EntityDef, EntityId, EntityPatch, InfiniteQueryDef, MutationDef, OptimisticCtx, QueryDef, QuerySnapshot, QueryStatus } from '../core/types' +import { Op, Status, Msg, Kind } from '../core/flags' +import { hashKey } from '../core/queryKey' +import type { ServerEndpoint, ClientMsg } from '../transport/protocol' +import { createMutationQueue } from './mutationQueue' + +export type AnyQueryDef = (QueryDef | InfiniteQueryDef) & { name: string } + +const EMPTY_PATH: readonly (string | number)[] = Object.freeze([]) + +interface QueryNode { + key: string + def: AnyQueryDef + args: unknown + subscribers: Set + status: QueryStatus + result: unknown + updatedAt: number + inflight: Promise | null + abort: AbortController | null + gcTimer: ReturnType | null + entityRefs: Array<{ type: string; id: EntityId }> +} + +interface Registry { + queries: Map + mutations: Map + entities: Map +} + +export interface QueryGraphOptions { + storage: StorageAdapter + endpoint: ServerEndpoint + registry: Registry + defaultStaleTime?: number + defaultGcTime?: number + isOnline?: () => boolean + onOnline?: (cb: () => void) => () => void +} + +export function createQueryGraph(opts: QueryGraphOptions) { + const { storage, endpoint, registry } = opts + const defaultStaleTime = opts.defaultStaleTime ?? 30_000 + const defaultGcTime = opts.defaultGcTime ?? 5 * 60_000 + const isOnline = opts.isOnline ?? (() => (typeof navigator !== 'undefined' ? navigator.onLine : true)) + const onOnline = + opts.onOnline ?? + ((cb: () => void) => { + if (typeof self === 'undefined') return () => {} + self.addEventListener('online', cb) + return () => self.removeEventListener('online', cb) + }) + + const nodes = new Map() + const subToNode = new Map() + const entitiesInMemory = new Map>() + + function entityBucket(type: string): Map { + let b = entitiesInMemory.get(type) + if (!b) entitiesInMemory.set(type, (b = new Map())) + return b + } + + function setEntity(type: string, id: EntityId, data: unknown): void { + entityBucket(type).set(id, data) + } + + function getEntity(type: string, id: EntityId): unknown { + return entityBucket(type).get(id) + } + + function emitEntityPatches(patches: EntityPatch[]): Promise { + if (patches.length === 0) return Promise.resolve() + const writesByType = new Map>() + const tasks: Promise[] = [] + for (let i = 0; i < patches.length; i++) { + const p = patches[i] + const def = registry.entities.get(p.type) + if (p.patch.op === Op.Delete) { + if (def?.storage) tasks.push(def.storage.delete(p.id)) + } else if (def?.storage) { + let arr = writesByType.get(p.type) + if (!arr) { + arr = [] + writesByType.set(p.type, arr) + } + arr.push({ key: p.id, value: getEntity(p.type, p.id) }) + } + } + for (const [type, writes] of writesByType) { + const def = registry.entities.get(type) + if (def?.storage) tasks.push(def.storage.write(writes)) + } + endpoint.broadcast({ type: Msg.EntityPatch, patches }) + return tasks.length === 0 ? Promise.resolve() : Promise.all(tasks).then(noop) + } + + function mergeEntity(type: string, id: EntityId, data: unknown): EntityPatch | null { + const prev = getEntity(type, id) as Record | undefined + if (prev && shallowEqual(prev, data as Record)) return null + setEntity(type, id, data) + return { type, id, patch: { op: Op.Set, path: EMPTY_PATH, value: data } } + } + + function ingestEntities( + buckets: Record>, + refs?: Array<{ type: string; id: EntityId }>, + ): EntityPatch[] { + const patches: EntityPatch[] = [] + for (const name in buckets) { + const def = registry.entities.get(name) + if (!def) continue + const arr = buckets[name] + for (let i = 0; i < arr.length; i++) { + const e = arr[i] + const id = def.id(e) + if (refs) refs.push({ type: name, id }) + const p = mergeEntity(name, id, e) + if (p) patches.push(p) + } + } + return patches + } + + function ensureNode(defName: string, args: unknown): QueryNode { + const def = registry.queries.get(defName)! + if (__SYNC_ENGINE_DEV__ && !def) throw new Error(`Unknown query: ${defName}`) + const key = def.staticHash ?? hashKey(def.key(args as never)) + let node = nodes.get(key) + if (!node) { + node = { + key, + def, + args, + subscribers: new Set(), + status: Status.Idle, + result: undefined, + updatedAt: 0, + inflight: null, + abort: null, + gcTimer: null, + entityRefs: [], + } + nodes.set(key, node) + } else if (node.gcTimer !== null) { + clearTimeout(node.gcTimer) + node.gcTimer = null + } + return node + } + + function scheduleGc(node: QueryNode): void { + if (node.subscribers.size > 0) return + const gc = node.def.gcTime ?? defaultGcTime + node.gcTimer = setTimeout(() => { + if (node.subscribers.size === 0) { + nodes.delete(node.key) + void storage.queries.delete(node.key) + } + }, gc) + } + + function isFresh(node: QueryNode): boolean { + if (!node.updatedAt) return false + const stale = node.def.staleTime ?? defaultStaleTime + return Date.now() - node.updatedAt < stale + } + + async function hydrate(node: QueryNode): Promise { + const stored = await storage.queries.read(node.key) + if (!stored || node.status !== Status.Idle) return + if (!stored.entityRefs) { + void storage.queries.delete(node.key) + return + } + node.result = stored.result + node.status = Status.Success + node.updatedAt = stored.updatedAt + if (stored.entityRefs.length > 0) { + node.entityRefs = stored.entityRefs.slice() + const patches = await loadEntityRefs(stored.entityRefs) + if (patches.length > 0) endpoint.broadcast({ type: Msg.EntityPatch, patches }) + } + pushSnapshotToSubscribers(node) + } + + async function loadEntityRefs( + refs: ReadonlyArray<{ type: string; id: EntityId }>, + ): Promise { + const byType = new Map() + for (let i = 0; i < refs.length; i++) { + const r = refs[i] + let list = byType.get(r.type) + if (!list) { + list = [] + byType.set(r.type, list) + } + list.push(r.id) + } + const patches: EntityPatch[] = [] + for (const [type, ids] of byType) { + const def = registry.entities.get(type) + if (!def?.storage) continue + const rows = await def.storage.readMany(ids) + for (let i = 0; i < rows.length; i++) { + const data = rows[i] + if (data === undefined) continue + const id = ids[i] + if (getEntity(type, id) === undefined) setEntity(type, id, data) + patches.push({ type, id, patch: { op: Op.Set, path: EMPTY_PATH, value: data } }) + } + } + return patches + } + + function pushSnapshotToSubscribers(node: QueryNode): void { + for (const subId of node.subscribers) { + endpoint.broadcast({ + type: Msg.QueryPatch, + subId, + status: node.status, + patch: { op: Op.Set, path: EMPTY_PATH, value: node.result }, + }) + } + } + + function broadcastEntityRefs(refs: ReadonlyArray<{ type: string; id: EntityId }>): void { + if (refs.length === 0) return + const patches: EntityPatch[] = [] + for (let i = 0; i < refs.length; i++) { + const r = refs[i] + const data = getEntity(r.type, r.id) + if (data === undefined) continue + patches.push({ type: r.type, id: r.id, patch: { op: Op.Set, path: EMPTY_PATH, value: data } }) + } + if (patches.length > 0) endpoint.broadcast({ type: Msg.EntityPatch, patches }) + } + + async function runFetch(node: QueryNode, pageParam?: unknown, append = false): Promise { + if (node.inflight) return node.inflight + node.status = Status.Pending + for (const subId of node.subscribers) { + endpoint.broadcast({ type: Msg.QueryPatch, subId, status: Status.Pending }) + } + node.abort = new AbortController() + const isInfinite = node.def.kind === Kind.Infinite + const effectivePageParam = isInfinite + ? pageParam ?? (node.def as InfiniteQueryDef).initialPageParam + : undefined + const exec = (async () => { + try { + const pageRefs: Array<{ type: string; id: EntityId }> = [] + const { pageResult, entities } = await node.def.exec!(node.args as never, { + signal: node.abort!.signal, + pageParam: effectivePageParam, + }) + if (entities !== null) await emitEntityPatches(ingestEntities(entities, pageRefs)) + if (isInfinite) { + const prev = (node.result as { pages: unknown[]; pageParams: unknown[] } | undefined) ?? { pages: [], pageParams: [] } + node.result = append + ? { pages: [...prev.pages, pageResult], pageParams: [...prev.pageParams, effectivePageParam] } + : { pages: [pageResult], pageParams: [effectivePageParam] } + node.entityRefs = append ? node.entityRefs.concat(pageRefs) : pageRefs + } else { + node.result = pageResult + node.entityRefs = pageRefs + } + node.status = Status.Success + node.updatedAt = Date.now() + const snap: QuerySnapshot = { + status: Status.Success, + result: node.result, + updatedAt: node.updatedAt, + entityRefs: node.entityRefs, + } + await storage.queries.write([{ key: node.key, value: snap }]) + pushSnapshotToSubscribers(node) + } catch (err) { + node.status = Status.Error + const error = { message: (err as Error)?.message ?? String(err) } + for (const subId of node.subscribers) { + endpoint.broadcast({ type: Msg.QueryPatch, subId, status: Status.Error, error }) + } + } finally { + node.inflight = null + node.abort = null + } + })() + node.inflight = exec + return exec + } + + function fetchNextPage(subId: string): void { + const node = subToNode.get(subId) + if (!node || node.def.kind !== Kind.Infinite) return + const def = node.def as InfiniteQueryDef + const cur = (node.result as { pages: unknown[]; pageParams: unknown[] } | undefined) ?? { pages: [], pageParams: [] } + const last = cur.pages[cur.pages.length - 1] + if (last === undefined) { + void runFetch(node, def.initialPageParam, false) + return + } + const next = def.getNextPageParam(last as never, cur.pages as never[]) + if (next === null || next === undefined) return + void runFetch(node, next, true) + } + + async function subscribe(msg: { subId: string; defName: string; args: unknown }): Promise { + const node = ensureNode(msg.defName, msg.args) + node.subscribers.add(msg.subId) + subToNode.set(msg.subId, node) + + if (node.status === Status.Success) { + broadcastEntityRefs(node.entityRefs) + endpoint.broadcast({ + type: Msg.QueryPatch, + subId: msg.subId, + status: Status.Success, + patch: { op: Op.Set, path: EMPTY_PATH, value: node.result }, + }) + if (!isFresh(node)) void runFetch(node) + return + } + if (node.status === Status.Idle) await hydrate(node) + const status = node.status as QueryNode['status'] + if (status === Status.Pending) endpoint.broadcast({ type: Msg.QueryPatch, subId: msg.subId, status: Status.Pending }) + else if (status === Status.Success) { + broadcastEntityRefs(node.entityRefs) + endpoint.broadcast({ + type: Msg.QueryPatch, + subId: msg.subId, + status: Status.Success, + patch: { op: Op.Set, path: EMPTY_PATH, value: node.result }, + }) + } + if (!isFresh(node)) void runFetch(node) + } + + function unsubscribe(subId: string): void { + const node = subToNode.get(subId) + if (!node) return + subToNode.delete(subId) + node.subscribers.delete(subId) + if (node.subscribers.size === 0) scheduleGc(node) + } + + function buildCtx(forward: EntityPatch[], inverse: EntityPatch[]): OptimisticCtx { + return { + patchEntity: (entDef, id, patch) => { + const prev = getEntity(entDef.name, id) as Record | undefined + const next = { ...(prev ?? {}), ...(patch as Record) } + setEntity(entDef.name, id, next) + forward.push({ type: entDef.name, id, patch: { op: Op.Merge, path: EMPTY_PATH, value: patch as Record } }) + if (prev !== undefined) { + const prevSlice: Record = {} + for (const k of Object.keys(patch as Record)) prevSlice[k] = (prev as any)[k] + inverse.push({ type: entDef.name, id, patch: { op: Op.Merge, path: EMPTY_PATH, value: prevSlice } }) + } else { + inverse.push({ type: entDef.name, id, patch: { op: Op.Delete, path: EMPTY_PATH } }) + } + }, + removeEntity: (entDef, id) => { + const prev = getEntity(entDef.name, id) + entityBucket(entDef.name).delete(id) + forward.push({ type: entDef.name, id, patch: { op: Op.Delete, path: EMPTY_PATH } }) + if (prev !== undefined) inverse.push({ type: entDef.name, id, patch: { op: Op.Set, path: EMPTY_PATH, value: prev } }) + }, + upsertEntity: (entDef, entity) => { + const id = entDef.id(entity) + const prev = getEntity(entDef.name, id) + setEntity(entDef.name, id, entity) + forward.push({ type: entDef.name, id, patch: { op: Op.Set, path: EMPTY_PATH, value: entity } }) + if (prev === undefined) inverse.push({ type: entDef.name, id, patch: { op: Op.Delete, path: EMPTY_PATH } }) + else inverse.push({ type: entDef.name, id, patch: { op: Op.Set, path: EMPTY_PATH, value: prev } }) + }, + } + } + + function buildPostCtx(post: EntityPatch[]): OptimisticCtx { + return { + patchEntity: (entDef, id, patch) => { + const prev = getEntity(entDef.name, id) as Record | undefined + const next = { ...(prev ?? {}), ...(patch as Record) } + setEntity(entDef.name, id, next) + post.push({ type: entDef.name, id, patch: { op: Op.Merge, path: EMPTY_PATH, value: patch as Record } }) + }, + removeEntity: (entDef, id) => { + entityBucket(entDef.name).delete(id) + post.push({ type: entDef.name, id, patch: { op: Op.Delete, path: EMPTY_PATH } }) + }, + upsertEntity: (entDef, entity) => { + const id = entDef.id(entity) + setEntity(entDef.name, id, entity) + post.push({ type: entDef.name, id, patch: { op: Op.Set, path: EMPTY_PATH, value: entity } }) + }, + } + } + + function invalidate(def: MutationDef, input: unknown, resp: unknown): void { + if (!def.invalidate) return + const targets = def.invalidate(input, resp) + for (const t of targets) { + if (typeof t === 'string') { + for (const node of nodes.values()) if (node.def.tags?.(node.args as never).includes(t)) void runFetch(node) + } else { + for (const node of nodes.values()) if (node.def === t) void runFetch(node) + } + } + } + + const queue = createMutationQueue({ + storage, + mutations: registry.mutations, + emitEntityPatches, + buildCtx, + buildPostCtx, + invalidate, + isOnline, + onOnline, + onResult: (mutId, ok, data, error) => + endpoint.broadcast({ type: Msg.MutateResult, mutId, ok, data, error }), + }) + + void queue.init() + + endpoint.onClient((msg: ClientMsg) => { + if (msg.type === Msg.Subscribe) void subscribe(msg) + else if (msg.type === Msg.Unsubscribe) unsubscribe(msg.subId) + else if (msg.type === Msg.Mutate) void queue.enqueue(msg.mutId, msg.defName, msg.input) + else if (msg.type === Msg.FetchNextPage) fetchNextPage(msg.subId) + }) + + return { nodes, subscribe, unsubscribe, fetchNextPage, queue } +} + +function shallowEqual(a: Record, b: Record): boolean { + const ak = Object.keys(a) + let bn = 0 + for (const _ in b) bn++ + if (ak.length !== bn) return false + for (let i = 0; i < ak.length; i++) { + const k = ak[i] + if (a[k] !== b[k]) return false + } + return true +} + +function noop(): void {} diff --git a/vue-sync-engine/src/env.d.ts b/vue-sync-engine/src/env.d.ts new file mode 100644 index 0000000..14515c9 --- /dev/null +++ b/vue-sync-engine/src/env.d.ts @@ -0,0 +1,12 @@ +declare const __SYNC_ENGINE_DEV__: boolean + +declare module 'virtual:sync-engine-registry' { + import type { EntityDef, InfiniteQueryDef, MutationDef, QueryDef } from './engine/core/types' + type AnyQueryDef = (QueryDef | InfiniteQueryDef) & { name: string } + const registry: { + entities: ReadonlyArray + queries: ReadonlyArray + mutations: ReadonlyArray + } + export default registry +} diff --git a/vue-sync-engine/src/main.ts b/vue-sync-engine/src/main.ts new file mode 100644 index 0000000..7801804 --- /dev/null +++ b/vue-sync-engine/src/main.ts @@ -0,0 +1,16 @@ +import { createApp } from 'vue' +import App from './App.vue' +import { createTabEngine, createSharedWorkerClientTransport, installEngine } from './engine' + +const worker = new SharedWorker(new URL('./engine.worker.ts', import.meta.url), { + type: 'module', + name: 'vue-sync-engine', +}) + +const engine = createTabEngine({ + transport: createSharedWorkerClientTransport(worker), +}) + +const app = createApp(App) +installEngine(app, engine) +app.mount('#app') diff --git a/vue-sync-engine/tsconfig.app.json b/vue-sync-engine/tsconfig.app.json new file mode 100644 index 0000000..5c750c5 --- /dev/null +++ b/vue-sync-engine/tsconfig.app.json @@ -0,0 +1,14 @@ +{ + "extends": "@vue/tsconfig/tsconfig.dom.json", + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "types": ["vite/client"], + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"] +} diff --git a/vue-sync-engine/tsconfig.json b/vue-sync-engine/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/vue-sync-engine/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/vue-sync-engine/tsconfig.node.json b/vue-sync-engine/tsconfig.node.json new file mode 100644 index 0000000..d3c52ea --- /dev/null +++ b/vue-sync-engine/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "module": "esnext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/vue-sync-engine/vite.config.ts b/vue-sync-engine/vite.config.ts new file mode 100644 index 0000000..e069926 --- /dev/null +++ b/vue-sync-engine/vite.config.ts @@ -0,0 +1,24 @@ +/// +import { defineConfig } from "vite"; +import vue from "@vitejs/plugin-vue"; +import { syncEnginePlugin } from "./src/engine/plugin"; + +const enginePlugin = syncEnginePlugin({ definitions: ['/src/**/*.defs.ts'] }); + +export default defineConfig({ + plugins: [vue(), enginePlugin], + worker: { + plugins: () => [syncEnginePlugin({ definitions: ['/src/**/*.defs.ts'] })], + }, + define: { + __VUE_OPTIONS_API__: 'false', + __VUE_PROD_DEVTOOLS__: 'false', + __VUE_PROD_HYDRATION_MISMATCH_DETAILS__: 'false', + __SYNC_ENGINE_DEV__: JSON.stringify(process.env.NODE_ENV !== 'production'), + }, + test: { + environment: "happy-dom", + include: ["src/**/*.{test,spec}.ts"], + globals: false, + }, +});