diff --git a/.github/actions/run-tests/action.yml b/.github/actions/run-tests/action.yml new file mode 100644 index 00000000..58ed4475 --- /dev/null +++ b/.github/actions/run-tests/action.yml @@ -0,0 +1,47 @@ +name: Run tests +description: Install deps, start the local Supabase stack, and run the vitest suite + +runs: + using: composite + steps: + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: "npm" + - run: npm i + shell: bash + - uses: supabase/setup-cli@v1 + - id: cli + run: echo "version=$(supabase --version)" >> "$GITHUB_OUTPUT" + shell: bash + # supabase start spends most of its time pulling images (~2.5GB, dominated + # by postgres), so cache them as a tarball keyed on the CLI version, which + # determines the image set. + - id: image-cache + uses: actions/cache@v4 + with: + path: /tmp/supabase-images.tar.zst + key: supabase-images-${{ runner.os }}-${{ steps.cli.outputs.version }} + - if: steps.image-cache.outputs.cache-hit == 'true' + run: zstd -dc /tmp/supabase-images.tar.zst | docker load + shell: bash + - if: steps.image-cache.outputs.cache-hit != 'true' + run: docker images --format '{{.Repository}}:{{.Tag}}' | sort > /tmp/images-before.txt + shell: bash + # The integration tests need the migrated db + PostgREST behind kong; + # everything else is dead weight in CI. + - run: supabase start -x gotrue,studio,imgproxy,realtime,storage-api,edge-runtime,logflare,vector,postgres-meta,supavisor + shell: bash + # Save whatever images supabase start pulled, diffed against the pre-start + # list so it tracks registry/tag changes across CLI versions. + - if: steps.image-cache.outputs.cache-hit != 'true' + run: | + new_images=$(comm -13 /tmp/images-before.txt <(docker images --format '{{.Repository}}:{{.Tag}}' | sort)) + if [ -n "$new_images" ]; then + echo "$new_images" | xargs docker save | zstd -T0 > /tmp/supabase-images.tar.zst + else + echo "No new images pulled; nothing to cache" + fi + shell: bash + - run: npm test + shell: bash diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 4b15be38..62a1fff7 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -18,6 +18,13 @@ jobs: - run: "npm i" - run: "npx tsc" + test: + runs-on: ubuntu-latest + name: vitest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/run-tests + lexicons: runs-on: ubuntu-latest name: lexicon @@ -33,7 +40,7 @@ jobs: - run: "npm i" - run: "npm run publish-lexicons" deploy-supabase: - needs: [typecheck] + needs: [typecheck, test] runs-on: ubuntu-latest name: Deploy Supabase env: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..6ba2b10c --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,28 @@ +name: Test + +# On pushes to main the test job in main.yml runs unconditionally and gates +# the supabase deploy; this workflow only covers PRs. +on: + pull_request: + paths: + - "**/*.test.ts" + - "src/replicache/**" + - "supabase/migrations/**" + - "supabase/config.toml" + - "vitest.config.ts" + - "package.json" + - "package-lock.json" + - ".github/workflows/test.yml" + - ".github/actions/run-tests/**" + +concurrency: + group: test-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + name: vitest + steps: + - uses: actions/checkout@v4 + - uses: ./.github/actions/run-tests diff --git a/app/api/rpc/[command]/pull.ts b/app/api/rpc/[command]/pull.ts index 17bc6aaf..6b284e8b 100644 --- a/app/api/rpc/[command]/pull.ts +++ b/app/api/rpc/[command]/pull.ts @@ -1,15 +1,10 @@ import { z } from "zod"; -import { - PullRequest, - PullResponseV1, - VersionNotSupportedResponse, -} from "replicache"; -import type { Fact } from "src/replicache"; -import { FactWithIndexes } from "src/replicache/utils"; -import type { Attribute } from "src/replicache/attributes"; +import { VersionNotSupportedResponse } from "replicache"; +import Client from "ioredis"; +import { computePull } from "src/replicache/serverPullData"; +import { makeCVRStore } from "src/replicache/cvrStore"; import { makeRoute } from "../lib"; import type { Env } from "./route"; -import type { Json } from "supabase/database.types"; // First define the sub-types for V0 and V1 requests const pullRequestV0 = z.object({ @@ -21,7 +16,9 @@ const pullRequestV0 = z.object({ lastMutationID: z.number(), }); -// For the Cookie type used in V1 +// For the Cookie type used in V1. New clients hold a CVR cookie (an object +// with an order property); pre-CVR clients hold a Date.now() number, which +// computePull answers with a full snapshot. const cookieType = z.union([ z.null(), z.string(), @@ -44,98 +41,21 @@ const pullRequestV1 = z.object({ // Combined PullRequest type const PullRequestSchema = z.union([pullRequestV0, pullRequestV1]); +// Without Redis (dev) every CVR lookup misses and pulls degrade to full +// snapshots — the pre-CVR behavior. +const cvrStore = makeCVRStore( + process.env.NODE_ENV === "production" && process.env.REDIS_URL + ? new Client(process.env.REDIS_URL) + : null, +); + export const pull = makeRoute({ route: "pull", input: z.object({ pullRequest: PullRequestSchema, token_id: z.string() }), handler: async ({ pullRequest, token_id }, { supabase }: Env) => { let body = pullRequest; if (body.pullVersion === 0) return versionNotSupported; - let { data, error } = await supabase.rpc("pull_data", { - token_id, - client_group_id: body.clientGroupID, - }); - if (!data) { - console.log(error); - - return { - error: "ClientStateNotFound", - } as const; - } - - let facts = data.facts as { - attribute: string; - author_did: string | null; - created_at: string; - data: any; - entity: string; - id: string; - updated_at: string | null; - version: number; - }[]; - let publication_data = data.publications as { - description: string; - title: string; - tags: string[]; - cover_image: string | null; - preferences: Json | null; - }[]; - let draft_contributors = (data.draft_contributors as string[] | null) ?? []; - let pub_patch = publication_data?.[0] - ? [ - { - op: "put", - key: "publication_description", - value: publication_data[0].description, - }, - { - op: "put", - key: "publication_title", - value: publication_data[0].title, - }, - { - op: "put", - key: "publication_tags", - value: publication_data[0].tags || [], - }, - { - op: "put", - key: "post_preferences", - value: publication_data[0].preferences || null, - }, - ] - : []; - - let clientGroup = ( - (data.client_groups as { - client_id: string; - client_group: string; - last_mutation: number; - }[]) || [] - ).reduce( - (acc, clientRecord) => { - acc[clientRecord.client_id] = clientRecord.last_mutation; - return acc; - }, - {} as { [clientID: string]: number }, - ); - - return { - cookie: Date.now(), - lastMutationIDChanges: clientGroup, - patch: [ - { op: "clear" }, - { op: "put", key: "initialized", value: true }, - ...(facts || []).map((f) => { - return { - op: "put", - key: f.id, - value: FactWithIndexes(f as unknown as Fact), - } as const; - }), - ...pub_patch, - { op: "put", key: "draft_contributors", value: draft_contributors }, - ], - } as PullResponseV1; + return await computePull(supabase, cvrStore, body, token_id, Date.now()); }, }); diff --git a/package-lock.json b/package-lock.json index a9f43795..74f82034 100644 --- a/package-lock.json +++ b/package-lock.json @@ -121,6 +121,8 @@ "tailwindcss": "^4.1.13", "tsx": "^4.19.3", "typescript": "^5.8.3", + "vite-tsconfig-paths": "^5.1.4", + "vitest": "^3.2.7", "wrangler": "^3.56.0" } }, @@ -8289,6 +8291,356 @@ "resolved": "https://registry.npmjs.org/@rocicorp/undo/-/undo-0.2.1.tgz", "integrity": "sha512-m4hPbRPFI/3XEzCJdyZbT1JY+3+SV+WZQXiujyDr7gynsNFuNE3tr95f1arCHLuCJVonwL8OE8mQTVEwjD8lDg==" }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@selderee/plugin-htmlparser2": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@selderee/plugin-htmlparser2/-/plugin-htmlparser2-0.11.0.tgz", @@ -9524,6 +9876,17 @@ "@types/node": "*" } }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, "node_modules/@types/connect": { "version": "3.4.38", "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", @@ -9613,10 +9976,17 @@ "@types/ms": "*" } }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", "license": "MIT" }, "node_modules/@types/estree-jsx": { @@ -9928,6 +10298,151 @@ } } }, + "node_modules/@vitest/expect": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.7.tgz", + "integrity": "sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.7.tgz", + "integrity": "sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.7", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/@vitest/mocker/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/@vitest/pretty-format": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.7.tgz", + "integrity": "sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.7.tgz", + "integrity": "sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.7", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.7.tgz", + "integrity": "sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.7.tgz", + "integrity": "sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.7.tgz", + "integrity": "sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.7", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@yornaath/batshit": { "version": "0.14.0", "resolved": "https://registry.npmjs.org/@yornaath/batshit/-/batshit-0.14.0.tgz", @@ -10097,6 +10612,16 @@ "printable-characters": "^1.0.42" } }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/astring": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/astring/-/astring-1.9.0.tgz", @@ -10326,6 +10851,16 @@ "node": ">= 0.8" } }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", @@ -10446,6 +10981,23 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -10498,6 +11050,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, "node_modules/chokidar": { "version": "3.6.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", @@ -11068,6 +11630,16 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -11957,6 +12529,13 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, "node_modules/es-object-atoms": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", @@ -12309,6 +12888,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/express": { "version": "4.21.2", "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", @@ -12751,6 +13340,13 @@ "node": ">=10" } }, + "node_modules/globrex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", + "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==", + "dev": true, + "license": "MIT" + }, "node_modules/google-logging-utils": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", @@ -14038,6 +14634,13 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, "node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", @@ -16274,6 +16877,16 @@ "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", "license": "MIT" }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, "node_modules/peberminta": { "version": "0.9.0", "resolved": "https://registry.npmjs.org/peberminta/-/peberminta-0.9.0.tgz", @@ -18553,6 +19166,51 @@ "node": ">=10" } }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, "node_modules/rollup-plugin-inject": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rollup-plugin-inject/-/rollup-plugin-inject-3.0.2.tgz", @@ -18892,6 +19550,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", @@ -19046,6 +19711,13 @@ "node": ">= 10.x" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, "node_modules/stacktracey": { "version": "2.1.8", "resolved": "https://registry.npmjs.org/stacktracey/-/stacktracey-2.1.8.tgz", @@ -19071,6 +19743,13 @@ "node": ">= 0.8" } }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, "node_modules/stdin-discarder": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", @@ -19155,6 +19834,26 @@ "node": ">=4" } }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, "node_modules/stripe": { "version": "20.4.0", "resolved": "https://registry.npmjs.org/stripe/-/stripe-20.4.0.tgz", @@ -19369,6 +20068,13 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.1.tgz", @@ -19426,6 +20132,36 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/tlds": { "version": "1.256.0", "resolved": "https://registry.npmjs.org/tlds/-/tlds-1.256.0.tgz", @@ -19515,6 +20251,28 @@ "code-block-writer": "^13.0.3" } }, + "node_modules/tsconfck": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz", + "integrity": "sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w==", + "deprecated": "unmaintained", + "dev": true, + "license": "MIT", + "bin": { + "tsconfck": "bin/tsconfck.js" + }, + "engines": { + "node": "^18 || >=20" + }, + "peerDependencies": { + "typescript": "^5.0.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -19922,33 +20680,769 @@ "d3-timer": "^3.0.1" } }, - "node_modules/w3c-keyname": { - "version": "2.2.8", - "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", - "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==" - }, - "node_modules/web-namespaces": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", - "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/web-streams-polyfill": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", - "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "node_modules/vite": { + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.6.tgz", + "integrity": "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0 || ^0.28.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, "engines": { - "node": ">= 8" - } - }, - "node_modules/webdriver-bidi-protocol": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.2.tgz", - "integrity": "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA==", - "license": "Apache-2.0" + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.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 + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vite-tsconfig-paths": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/vite-tsconfig-paths/-/vite-tsconfig-paths-5.1.4.tgz", + "integrity": "sha512-cYj0LRuLV2c2sMqhqhGpaO3LretdtMn/BVX4cPLanIZuwwrkVl+lK84E/miEXkCHWXuq65rhNN4rXsBcOB3S4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^4.1.1", + "globrex": "^0.1.2", + "tsconfck": "^3.0.3" + }, + "peerDependencies": { + "vite": "*" + }, + "peerDependenciesMeta": { + "vite": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.7.tgz", + "integrity": "sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.7", + "@vitest/mocker": "3.2.7", + "@vitest/pretty-format": "^3.2.7", + "@vitest/runner": "3.2.7", + "@vitest/snapshot": "3.2.7", + "@vitest/spy": "3.2.7", + "@vitest/utils": "3.2.7", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.7", + "@vitest/ui": "3.2.7", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/vitest/node_modules/picomatch": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", + "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/vitest/node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==" + }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "engines": { + "node": ">= 8" + } + }, + "node_modules/webdriver-bidi-protocol": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.2.tgz", + "integrity": "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA==", + "license": "Apache-2.0" }, "node_modules/webidl-conversions": { "version": "7.0.0", @@ -20035,6 +21529,23 @@ "integrity": "sha512-VGkKJ564kzt6Ms1dbgPP/yuIoQCrsFAnRbptpC5wOEsDaNsbCB2bnfnaA8i/vRs5tjUSEOtIuvl9/MyVsvQZCg==", "license": "MIT" }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/wordwrap": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", diff --git a/package.json b/package.json index d6873f3c..9991908d 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,8 @@ "main": "index.js", "scripts": { "dev": "TZ=UTC next dev --turbo -H 127.0.0.1", + "test": "vitest run", + "test:watch": "vitest", "publish-lexicons": "tsx lexicons/publish.ts", "generate-db-types": "supabase gen types --local > supabase/database.types.ts && drizzle-kit introspect && rm -rf ./drizzle/*.sql ./drizzle/meta", "lexgen": "tsx ./lexicons/build.ts && lex gen-api ./lexicons/api ./lexicons/pub/leaflet/document.json ./lexicons/pub/leaflet/comment.json ./lexicons/pub/leaflet/publication.json ./lexicons/pub/leaflet/publicationPage.json ./lexicons/pub/leaflet/content.json ./lexicons/pub/leaflet/*/* ./lexicons/com/atproto/*/* ./lexicons/app/bsky/*/* ./lexicons/site/*/* ./lexicons/site/*/*/* ./lexicons/parts/*/* ./lexicons/parts/*/*/* --yes && tsx ./lexicons/fix-extensions.ts ./lexicons/api", @@ -133,6 +135,8 @@ "tailwindcss": "^4.1.13", "tsx": "^4.19.3", "typescript": "^5.8.3", + "vite-tsconfig-paths": "^5.1.4", + "vitest": "^3.2.7", "wrangler": "^3.56.0" }, "overrides": { diff --git a/src/replicache/cvr.test.ts b/src/replicache/cvr.test.ts new file mode 100644 index 00000000..388f5d64 --- /dev/null +++ b/src/replicache/cvr.test.ts @@ -0,0 +1,559 @@ +import { describe, expect, test } from "vitest"; +import type { PatchOperation, ReadonlyJSONValue } from "replicache"; +import { + buildExtras, + buildPullResponse, + nextCookieOrder, + parseCVRCookie, + stableHash, + type PullFact, +} from "./cvr"; +import { makeCVRStore, type CVRStore, type RedisLike } from "./cvrStore"; +import { FactWithIndexes } from "./utils"; +import type { Fact } from "src/replicache"; +import type { Attribute } from "src/replicache/attributes"; + +// --------------------------------------------------------------------------- +// Simulation helpers: a fake server state, a fake Redis, and a fake client KV +// store, used to check that applying CVR patches always converges to exactly +// the client view the legacy reset strategy would have produced. +// --------------------------------------------------------------------------- + +type FactRow = { fact: PullFact; row_version: number }; + +type ServerState = { + // fact id -> row (row_version emulates xmin: set to the txn counter on + // every insert/update) + facts: Map; + publication: { + description: string; + title: string; + tags: string[] | null; + preferences: ReadonlyJSONValue | null; + } | null; + draft_contributors: string[]; + clients: Record; + txn: number; +}; + +function newServerState(): ServerState { + return { + facts: new Map(), + publication: null, + draft_contributors: [], + clients: {}, + txn: 1, + }; +} + +function pullInputs(s: ServerState, baseF: Record | null) { + return { + // mirrors the pull query: full rows are hydrated only for facts that are + // new or changed relative to the base CVR + facts: [...s.facts.values()].map(({ fact, row_version }) => ({ + id: fact.id, + row_version, + fact: baseF && baseF[fact.id] === row_version ? null : fact, + })), + extras: buildExtras( + s.publication ? [s.publication] : null, + s.draft_contributors, + ), + clients: { ...s.clients }, + }; +} + +// What the legacy reset strategy leaves in the client KV: every fact keyed by +// id (with indexes), plus the extra keys. +function naiveClientView(s: ServerState): Map { + let kv = new Map(); + let extras = buildExtras( + s.publication ? [s.publication] : null, + s.draft_contributors, + ); + for (let [key, value] of Object.entries(extras)) kv.set(key, value); + for (let { fact } of s.facts.values()) + kv.set(fact.id, FactWithIndexes(fact as unknown as Fact)); + return kv; +} + +function applyPatch( + kv: Map, + patch: readonly PatchOperation[], +) { + for (let op of patch) { + if (op.op === "clear") kv.clear(); + else if (op.op === "put") kv.set(op.key, op.value); + else if (op.op === "del") kv.delete(op.key); + } +} + +function makeFakeRedis() { + let map = new Map(); + let client: RedisLike = { + async get(key) { + return map.get(key) ?? null; + }, + async setex(key, _seconds, value) { + map.set(key, value); + }, + }; + return { map, client }; +} + +let cvrCounter = 0; +// Mirrors computePull's composition: resolve base from the store, build, and +// store the advanced CVR (the real composition is covered by the integration +// tests). +async function serverPull( + store: CVRStore, + storeKey: string, + prevCookie: unknown, + s: ServerState, + now: number, +) { + let cookie = parseCVRCookie(prevCookie); + let baseCVR = cookie ? await store.get(storeKey, cookie.cvrID) : null; + let nextCVRID = `cvr-${++cvrCounter}`; + let { response, nextCVR } = buildPullResponse({ + prevCookie, + baseCVR, + nextCVRID, + ...pullInputs(s, baseCVR?.f ?? null), + now, + }); + if (nextCVR) await store.set(storeKey, { id: nextCVRID, ...nextCVR }); + return response; +} + +let factCounter = 0; +function makeFact( + s: ServerState, + data?: { type: string; value: unknown }, +): FactRow { + factCounter++; + let fact = { + id: `fact-${factCounter}`, + entity: `entity-${factCounter % 7}`, + attribute: `attribute/${factCounter % 5}`, + data: data ?? { type: "text", value: `value ${factCounter}` }, + author_did: null, + created_at: "2026-01-01T00:00:00", + updated_at: null, + version: 0, + }; + return { fact, row_version: s.txn }; +} + +function mulberry32(seed: number) { + return () => { + seed |= 0; + seed = (seed + 0x6d2b79f5) | 0; + let t = Math.imul(seed ^ (seed >>> 15), 1 | seed); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +// --------------------------------------------------------------------------- + +describe("parseCVRCookie", () => { + test("rejects legacy and malformed cookies", () => { + expect(parseCVRCookie(null)).toBeNull(); + expect(parseCVRCookie(Date.now())).toBeNull(); + expect(parseCVRCookie("some-string")).toBeNull(); + expect(parseCVRCookie({ order: 5 })).toBeNull(); + expect(parseCVRCookie({ order: 5, cvrID: 7 })).toBeNull(); + expect(parseCVRCookie({ order: "5", cvrID: "a" })).toBeNull(); + }); + + test("accepts a valid CVR cookie (JSON round-trip safe)", () => { + let cookie = { order: 12, cvrID: "abc-123" }; + expect(parseCVRCookie(JSON.parse(JSON.stringify(cookie)))).toEqual(cookie); + }); +}); + +describe("nextCookieOrder", () => { + test("stays above legacy Date.now() cookies", () => { + let legacy = 1_752_000_000_000; + expect(nextCookieOrder(legacy, 1000)).toBe(legacy + 1); + }); + test("uses wall clock when ahead of the previous order", () => { + expect(nextCookieOrder(null, 5000)).toBe(5000); + expect(nextCookieOrder({ order: 10, cvrID: "a" }, 5000)).toBe(5000); + }); + test("strictly increases even with a stalled clock", () => { + let cookie = { order: 9000, cvrID: "a" }; + expect(nextCookieOrder(cookie, 9000)).toBe(9001); + expect(nextCookieOrder(cookie, 100)).toBe(9001); + }); +}); + +describe("stableHash", () => { + test("is insensitive to object key order, sensitive to values", () => { + expect(stableHash({ a: 1, b: [2, { c: 3 }] })).toBe( + stableHash({ b: [2, { c: 3 }], a: 1 }), + ); + expect(stableHash({ a: 1 })).not.toBe(stableHash({ a: 2 })); + expect(stableHash([])).not.toBe(stableHash(null)); + expect(stableHash("")).not.toBe(stableHash(null)); + }); +}); + +describe("buildPullResponse: reset mode", () => { + test("matches the legacy reset response shape exactly", () => { + let s = newServerState(); + s.facts.set("a", { + fact: { + id: "a", + entity: "e1", + attribute: "attr", + data: { type: "reference", value: "e2" }, + }, + row_version: 1, + }); + s.publication = { + description: "desc", + title: "title", + tags: null, + preferences: null, + }; + s.clients = { client1: 3 }; + let { response, nextCVR } = buildPullResponse({ + prevCookie: null, + baseCVR: null, + nextCVRID: "new-cvr", + ...pullInputs(s, null), + now: 999, + }); + expect(response.lastMutationIDChanges).toEqual({ client1: 3 }); + expect(response.patch).toEqual([ + { op: "clear" }, + { op: "put", key: "initialized", value: true }, + { + op: "put", + key: "a", + value: { + id: "a", + entity: "e1", + attribute: "attr", + data: { type: "reference", value: "e2" }, + indexes: { eav: "e1-attr-a", vae: "e2-attr" }, + }, + }, + { op: "put", key: "publication_description", value: "desc" }, + { op: "put", key: "publication_title", value: "title" }, + // legacy behavior: falsy tags/preferences coalesce to []/null + { op: "put", key: "publication_tags", value: [] }, + { op: "put", key: "post_preferences", value: null }, + { op: "put", key: "draft_contributors", value: [] }, + ]); + expect(response.cookie).toEqual({ order: 999, cvrID: "new-cvr" }); + expect(nextCVR).not.toBeNull(); + expect(nextCVR!.f).toEqual({ a: 1 }); + expect(nextCVR!.c).toEqual({ client1: 3 }); + }); + + test("a parseable cookie whose CVR is gone still gets a full snapshot", () => { + let s = newServerState(); + let row = makeFact(s); + s.facts.set(row.fact.id, row); + let { response } = buildPullResponse({ + prevCookie: { order: 500, cvrID: "evicted" }, + baseCVR: null, + nextCVRID: "new-cvr", + ...pullInputs(s, null), + now: 100, + }); + expect(response.patch[0]).toEqual({ op: "clear" }); + expect(response.cookie).toEqual({ order: 501, cvrID: "new-cvr" }); + }); + + test("legacy numeric cookies get a reset with a larger order", () => { + let s = newServerState(); + let legacyCookie = 1_752_000_000_000; + let { response } = buildPullResponse({ + prevCookie: legacyCookie, + baseCVR: null, + nextCVRID: "new-cvr", + ...pullInputs(s, null), + now: 999, + }); + expect(response.patch[0]).toEqual({ op: "clear" }); + expect((response.cookie as { order: number }).order).toBeGreaterThan( + legacyCookie, + ); + }); +}); + +describe("buildPullResponse: diff mode via the store", () => { + test("no changes → empty patch, cookie echoed, store untouched", async () => { + let s = newServerState(); + let row = makeFact(s); + s.facts.set(row.fact.id, row); + s.clients = { client1: 1 }; + let { map, client } = makeFakeRedis(); + let store = makeCVRStore(client); + + let first = await serverPull(store, "k", null, s, 1000); + let storedAfterFirst = map.get("replicache-cvr:k"); + let second = await serverPull( + store, + "k", + JSON.parse(JSON.stringify(first.cookie)), + s, + 2000, + ); + expect(second.patch).toEqual([]); + expect(second.lastMutationIDChanges).toEqual({}); + expect(second.cookie).toEqual(JSON.parse(JSON.stringify(first.cookie))); + expect(map.get("replicache-cvr:k")).toBe(storedAfterFirst); + }); + + test("updated fact → single put; deleted fact → single del", async () => { + let s = newServerState(); + let a = makeFact(s); + let b = makeFact(s); + s.facts.set(a.fact.id, a); + s.facts.set(b.fact.id, b); + let store = makeCVRStore(makeFakeRedis().client); + let first = await serverPull(store, "k", null, s, 1000); + + s.txn++; + s.facts.set(a.fact.id, { + fact: { ...a.fact, data: { type: "text", value: "updated" } }, + row_version: s.txn, + }); + s.facts.delete(b.fact.id); + + let second = await serverPull(store, "k", first.cookie, s, 2000); + expect(second.patch).toEqual([ + { + op: "put", + key: a.fact.id, + value: FactWithIndexes({ + ...a.fact, + data: { type: "text", value: "updated" }, + } as unknown as Fact), + }, + { op: "del", key: b.fact.id }, + ]); + }); + + test("publication appearing and disappearing", async () => { + let s = newServerState(); + let store = makeCVRStore(makeFakeRedis().client); + let first = await serverPull(store, "k", null, s, 1000); + + s.publication = { + description: "d", + title: "t", + tags: ["x"], + preferences: { theme: "dark" }, + }; + let second = await serverPull(store, "k", first.cookie, s, 2000); + expect(second.patch).toEqual([ + { op: "put", key: "publication_description", value: "d" }, + { op: "put", key: "publication_title", value: "t" }, + { op: "put", key: "publication_tags", value: ["x"] }, + { op: "put", key: "post_preferences", value: { theme: "dark" } }, + ]); + + s.publication = null; + let third = await serverPull(store, "k", second.cookie, s, 3000); + expect(third.patch).toEqual( + expect.arrayContaining([ + { op: "del", key: "publication_description" }, + { op: "del", key: "publication_title" }, + { op: "del", key: "publication_tags" }, + { op: "del", key: "post_preferences" }, + ]), + ); + expect(third.patch).toHaveLength(4); + }); + + test("lastMutationIDChanges only includes changed clients", async () => { + let s = newServerState(); + s.clients = { client1: 1, client2: 5 }; + let store = makeCVRStore(makeFakeRedis().client); + let first = await serverPull(store, "k", null, s, 1000); + expect(first.lastMutationIDChanges).toEqual({ client1: 1, client2: 5 }); + + s.clients = { client1: 4, client2: 5, client3: 1 }; + let second = await serverPull(store, "k", first.cookie, s, 2000); + expect(second.lastMutationIDChanges).toEqual({ client1: 4, client3: 1 }); + // a mutation-only change still advances the cookie + expect((second.cookie as { order: number }).order).toBeGreaterThan( + (first.cookie as { order: number }).order, + ); + }); + + test("a lost pull response forces a clean full resync on the next pull", async () => { + let s = newServerState(); + let a = makeFact(s); + s.facts.set(a.fact.id, a); + let store = makeCVRStore(makeFakeRedis().client); + let first = await serverPull(store, "k", null, s, 1000); + let kv = new Map(); + applyPatch(kv, first.patch); + + // second pull advances the store slot, but the response never reaches the + // client + s.txn++; + let b = makeFact(s); + s.facts.set(b.fact.id, b); + await serverPull(store, "k", first.cookie, s, 2000); + + // the client retries with its old cookie: slot id no longer matches, so + // it gets a full snapshot and still converges + let retry = await serverPull(store, "k", first.cookie, s, 3000); + expect(retry.patch[0]).toEqual({ op: "clear" }); + applyPatch(kv, retry.patch); + expect(Object.fromEntries(kv)).toEqual( + Object.fromEntries(naiveClientView(s)), + ); + }); + + test("a changed fact arriving unhydrated is surfaced, not dropped", () => { + let s = newServerState(); + let a = makeFact(s); + s.facts.set(a.fact.id, a); + expect(() => + buildPullResponse({ + prevCookie: { order: 1, cvrID: "x" }, + baseCVR: { f: { [a.fact.id]: a.row_version - 1 }, x: {}, c: {} }, + nextCVRID: "y", + // hydrated against a *different* base that claims the fact is current + ...pullInputs(s, { [a.fact.id]: a.row_version }), + now: 2000, + }), + ).toThrow(/not hydrated/); + }); + + test("editing churn never grows the store: one slot per client group", async () => { + let s = newServerState(); + let { map, client } = makeFakeRedis(); + let store = makeCVRStore(client); + let cookie: unknown = null; + for (let i = 0; i < 20; i++) { + s.txn++; + let row = makeFact(s); + s.facts.set(row.fact.id, row); + cookie = (await serverPull(store, "k", cookie, s, 1000 + i)).cookie; + } + expect(map.size).toBe(1); + }); +}); + +describe("randomized equivalence with the naive reset strategy", () => { + // Several simulated clients pull at random cadences — sometimes losing + // their cookie, sometimes losing a pull response in flight, sometimes + // having their stored CVR evicted — while the server state mutates; after + // every applied pull the client's KV must exactly equal the naive full + // snapshot. + test.each([1, 2, 3, 4, 5, 6, 7, 8])("seed %i", async (seed) => { + let rand = mulberry32(seed * 7919); + let s = newServerState(); + let now = 10_000; + let { map, client } = makeFakeRedis(); + let store = makeCVRStore(client); + let clients = [0, 1, 2].map((i) => ({ + storeKey: `group-${i}`, + cookie: null as unknown, + kv: new Map(), + lmids: {} as Record, + initialized: false, + })); + + for (let step = 0; step < 400; step++) { + // mutate the server in a fresh "transaction" + s.txn++; + let action = rand(); + if (action < 0.35) { + let row = makeFact( + s, + rand() < 0.3 + ? { type: "reference", value: `entity-${Math.floor(rand() * 7)}` } + : undefined, + ); + s.facts.set(row.fact.id, row); + } else if (action < 0.6 && s.facts.size > 0) { + let ids = [...s.facts.keys()]; + let id = ids[Math.floor(rand() * ids.length)]; + let existing = s.facts.get(id)!; + s.facts.set(id, { + fact: { + ...existing.fact, + data: { type: "text", value: `updated ${s.txn}` }, + }, + row_version: s.txn, + }); + } else if (action < 0.75 && s.facts.size > 0) { + let ids = [...s.facts.keys()]; + s.facts.delete(ids[Math.floor(rand() * ids.length)]); + } else if (action < 0.85) { + s.publication = + rand() < 0.3 + ? null + : { + description: `desc ${s.txn}`, + title: `title ${s.txn}`, + tags: rand() < 0.5 ? [`tag-${s.txn}`] : null, + preferences: rand() < 0.5 ? { p: s.txn } : null, + }; + } else if (action < 0.92) { + s.draft_contributors = + rand() < 0.4 ? [] : [`did:plc:${Math.floor(rand() * 3)}`]; + } else { + let clientID = `client-${Math.floor(rand() * 4)}`; + s.clients[clientID] = (s.clients[clientID] ?? 0) + 1; + } + + // some clients pull + for (let c of clients) { + if (rand() > 0.3) continue; + if (rand() < 0.05) { + // simulate a fresh browser: cookie + kv lost + c.cookie = null; + c.kv.clear(); + c.lmids = {}; + c.initialized = false; + } + if (rand() < 0.03) map.delete(`replicache-cvr:${c.storeKey}`); // eviction + now += Math.floor(rand() * 50); + let res = await serverPull(store, c.storeKey, c.cookie, s, now); + + // simulate a response lost in flight: the server may have advanced + // its slot but the client saw nothing + if (rand() < 0.05) continue; + + // cookie order must never decrease + let prevOrder = + typeof c.cookie === "number" + ? c.cookie + : ((c.cookie as { order?: number })?.order ?? 0); + let newOrder = (res.cookie as { order: number }).order; + expect(newOrder).toBeGreaterThanOrEqual(prevOrder); + if (res.patch.length > 0 && c.initialized) + expect(newOrder).toBeGreaterThan(prevOrder); + + applyPatch(c.kv, res.patch); + Object.assign(c.lmids, res.lastMutationIDChanges); + // round-trip the cookie through JSON like the network would + c.cookie = JSON.parse(JSON.stringify(res.cookie)); + c.initialized = true; + + // the applied view must equal the naive full snapshot + expect(Object.fromEntries(c.kv)).toEqual( + Object.fromEntries(naiveClientView(s)), + ); + // confirmed mutation ids must match the server exactly for every + // client the simulated client has ever heard about + for (let [clientID, lmid] of Object.entries(c.lmids)) + expect(lmid).toBe(s.clients[clientID]); + } + } + // single slot per client group, no accumulation + expect(map.size).toBeLessThanOrEqual(clients.length); + }); +}); diff --git a/src/replicache/cvr.ts b/src/replicache/cvr.ts new file mode 100644 index 00000000..92959d50 --- /dev/null +++ b/src/replicache/cvr.ts @@ -0,0 +1,240 @@ +import { z } from "zod"; +import type { + Cookie, + PatchOperation, + PullResponseOKV1, + ReadonlyJSONValue, +} from "replicache"; +import type { Fact } from "src/replicache"; +import type { Attribute } from "src/replicache/attributes"; +import { FactWithIndexes } from "src/replicache/utils"; + +// Row-version (CVR) pull strategy, per +// https://doc.replicache.dev/strategies/row-version. The cookie carries only +// {order, cvrID}; the CVR itself (the previous client view to diff against) +// lives in Redis (src/replicache/cvrStore.ts). Facts are versioned by +// Postgres's xmin system column (only equality is ever compared, so xid +// wraparound is not a concern in practice). + +export type PullFact = { + id: string; + entity: string; + attribute: string; + data: any; + [key: string]: unknown; +}; + +export type PullFactVersion = { + id: string; + row_version: number; + // The full row, hydrated by the pull query only for facts that are new or + // changed relative to the base CVR it was given; null means unchanged. + fact: PullFact | null; +}; + +export type CVR = { + // fact id -> row version (xmin) + f: Record; + // extra top-level keys (initialized, publication_*, draft_contributors...) + // -> content hash + x: Record; + // clientID -> lastMutationID + c: Record; +}; + +const cvrCookieSchema = z.object({ + order: z.number(), + cvrID: z.string(), +}); + +export type CVRCookie = z.infer; + +// Old clients hold a Date.now() number cookie (or null on first pull); any +// cookie we can't parse gets the legacy full-snapshot response. +export function parseCVRCookie(cookie: unknown): CVRCookie | null { + let parsed = cvrCookieSchema.safeParse(cookie); + return parsed.success ? parsed.data : null; +} + +// Replicache requires the cookie order to be monotonically increasing within +// a client group. Legacy cookies were Date.now(), so new orders must stay at +// or above wall-clock time to remain larger than any cookie already out there. +export function nextCookieOrder(prevCookie: unknown, now: number): number { + let prevOrder = 0; + if (typeof prevCookie === "number") prevOrder = prevCookie; + else if ( + typeof prevCookie === "object" && + prevCookie !== null && + typeof (prevCookie as { order?: unknown }).order === "number" + ) + prevOrder = (prevCookie as { order: number }).order; + return Math.max(prevOrder + 1, now); +} + +function fnv1a(str: string, seed: number): number { + let h = seed >>> 0; + for (let i = 0; i < str.length; i++) { + h ^= str.charCodeAt(i); + h = Math.imul(h, 0x01000193); + } + return h >>> 0; +} + +function stableStringify(value: unknown): string { + if (value === undefined) return "null"; + if (value === null || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) + return "[" + value.map(stableStringify).join(",") + "]"; + return ( + "{" + + Object.keys(value) + .sort() + .map( + (key) => + JSON.stringify(key) + + ":" + + stableStringify((value as Record)[key]), + ) + .join(",") + + "}" + ); +} + +// Extras have no row in the facts table to take a version from, so they're +// versioned by content hash instead. +export function stableHash(value: unknown): string { + let s = stableStringify(value); + return fnv1a(s, 0x811c9dc5).toString(36) + fnv1a(s, 0x9dc5811c).toString(36); +} + +// The pull query's hydration condition must mirror the diff below exactly; a +// changed fact arriving unhydrated means the query was given a different base +// CVR than this diff, which is a caller bug — surface it rather than silently +// dropping the update. +function hydrated(row: PullFactVersion): PullFact { + if (!row.fact) + throw new Error( + `fact ${row.id} changed but was not hydrated; the pull query and diff must share the same base CVR`, + ); + return row.fact; +} + +export function buildPullResponse(args: { + prevCookie: unknown; + // The stored CVR the cookie's cvrID resolved to; null (legacy cookie, first + // pull, evicted or lost slot) always yields the full-snapshot response. + // Must be the same base the pull query hydrated `facts` against. + baseCVR: CVR | null; + nextCVRID: string; + facts: PullFactVersion[]; + // Ordered: `initialized` must be first; later entries keep legacy patch order. + extras: Record; + clients: Record; + now: number; +}): { + response: PullResponseOKV1; + // The CVR to store under nextCVRID; null when the pull was a no-op and the + // previous cookie (and stored CVR) remain current. + nextCVR: CVR | null; +} { + let { prevCookie, baseCVR: base, nextCVRID, facts, extras, clients, now } = + args; + + let nextF: Record = {}; + for (let { id, row_version } of facts) nextF[id] = row_version; + let nextX: Record = {}; + for (let [key, value] of Object.entries(extras)) + nextX[key] = stableHash(value); + let nextCVR: CVR = { f: nextF, x: nextX, c: { ...clients } }; + let nextCookie: CVRCookie = { + order: nextCookieOrder(prevCookie, now), + cvrID: nextCVRID, + }; + + if (!base) { + // Legacy full snapshot: identical to the old reset-strategy response, + // except the cookie now carries the CVR so the next pull can diff. + let patch: PatchOperation[] = [{ op: "clear" }]; + let extraEntries = Object.entries(extras); + for (let [key, value] of extraEntries) + if (key === "initialized") patch.push({ op: "put", key, value }); + for (let row of facts) + patch.push({ + op: "put", + key: row.id, + value: FactWithIndexes(hydrated(row) as unknown as Fact), + }); + for (let [key, value] of extraEntries) + if (key !== "initialized") patch.push({ op: "put", key, value }); + return { + response: { + cookie: nextCookie as Cookie, + lastMutationIDChanges: clients, + patch, + }, + nextCVR, + }; + } + + let patch: PatchOperation[] = []; + for (let row of facts) + if (base.f[row.id] !== row.row_version) + patch.push({ + op: "put", + key: row.id, + value: FactWithIndexes(hydrated(row) as unknown as Fact), + }); + for (let id of Object.keys(base.f)) + if (!(id in nextF)) patch.push({ op: "del", key: id }); + for (let [key, value] of Object.entries(extras)) + if (base.x[key] !== nextX[key]) patch.push({ op: "put", key, value }); + for (let key of Object.keys(base.x)) + if (!(key in nextX)) patch.push({ op: "del", key }); + + let lastMutationIDChanges: Record = {}; + for (let [clientID, lmid] of Object.entries(clients)) + if (base.c[clientID] !== lmid) lastMutationIDChanges[clientID] = lmid; + + // Nothing changed: echo the cookie back unchanged so the client commits + // nothing. This is the common poll case and now costs ~zero bandwidth. + if (patch.length === 0 && Object.keys(lastMutationIDChanges).length === 0) + return { + response: { + cookie: prevCookie as Cookie, + lastMutationIDChanges: {}, + patch: [], + }, + nextCVR: null, + }; + + return { + response: { cookie: nextCookie as Cookie, lastMutationIDChanges, patch }, + nextCVR, + }; +} + +// Builds the non-fact keys of the client view. Mirrors the legacy pull +// handler's handling of pull_data's publications + draft_contributors +// payloads, including its falsy-coalescing of tags/preferences. +export function buildExtras( + publication_data: + | { + description: string; + title: string; + tags: string[] | null; + preferences: ReadonlyJSONValue | null; + }[] + | null, + draft_contributors: string[] | null, +): Record { + let extras: Record = { initialized: true }; + let pub = publication_data?.[0]; + if (pub) { + extras.publication_description = pub.description; + extras.publication_title = pub.title; + extras.publication_tags = pub.tags || []; + extras.post_preferences = pub.preferences || null; + } + extras.draft_contributors = draft_contributors ?? []; + return extras; +} diff --git a/src/replicache/cvrStore.test.ts b/src/replicache/cvrStore.test.ts new file mode 100644 index 00000000..5bebdbc7 --- /dev/null +++ b/src/replicache/cvrStore.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from "vitest"; +import { makeCVRStore, type RedisLike } from "./cvrStore"; +import type { CVR } from "./cvr"; + +let cvr: CVR = { + f: { "fact-1": 4 }, + x: { initialized: "abc" }, + c: { client1: 7 }, +}; + +function mapRedis() { + let map = new Map(); + let ttls = new Map(); + let client: RedisLike = { + async get(key) { + return map.get(key) ?? null; + }, + async setex(key, seconds, value) { + map.set(key, value); + ttls.set(key, seconds); + }, + }; + return { map, ttls, client }; +} + +describe("makeCVRStore", () => { + test("round-trips a CVR when the cvrID matches", async () => { + let { ttls, client } = mapRedis(); + let store = makeCVRStore(client); + await store.set("k", { id: "cvr-1", ...cvr }); + expect(await store.get("k", "cvr-1")).toEqual({ id: "cvr-1", ...cvr }); + // TTL is the GC backstop for abandoned client groups + expect(ttls.get("replicache-cvr:k")).toBe(60 * 60 * 24 * 30); + }); + + test("misses on unknown key, superseded cvrID, and corrupt data", async () => { + let { map, client } = mapRedis(); + let store = makeCVRStore(client); + expect(await store.get("k", "cvr-1")).toBeNull(); + + await store.set("k", { id: "cvr-2", ...cvr }); + // a client holding the pre-overwrite cookie gets a miss → full snapshot + expect(await store.get("k", "cvr-1")).toBeNull(); + + map.set("replicache-cvr:k", "{not json"); + expect(await store.get("k", "cvr-2")).toBeNull(); + }); + + test("swallows redis errors on both get and set", async () => { + let store = makeCVRStore({ + async get() { + throw new Error("redis down"); + }, + async setex() { + throw new Error("redis down"); + }, + }); + expect(await store.get("k", "cvr-1")).toBeNull(); + await expect(store.set("k", { id: "cvr-1", ...cvr })).resolves.toBeUndefined(); + }); + + test("no redis client → always a miss, set is a no-op", async () => { + let store = makeCVRStore(null); + await store.set("k", { id: "cvr-1", ...cvr }); + expect(await store.get("k", "cvr-1")).toBeNull(); + }); +}); diff --git a/src/replicache/cvrStore.ts b/src/replicache/cvrStore.ts new file mode 100644 index 00000000..0e1c24fc --- /dev/null +++ b/src/replicache/cvrStore.ts @@ -0,0 +1,50 @@ +import type { CVR } from "./cvr"; + +// One CVR slot per token+clientGroup, overwritten on every advancing pull, so +// normal editing churn never accumulates entries. A cvrID match guards the +// slot: a client whose cookie no longer matches (lost pull response, slot +// evicted) just gets a full snapshot and re-converges. The TTL only garbage +// collects abandoned client groups — replicache's own cookie monotonicity +// makes stale slots unreachable, not incorrect. +const KEY_PREFIX = "replicache-cvr:"; +const TTL_SECONDS = 60 * 60 * 24 * 30; + +export type StoredCVR = CVR & { id: string }; + +// The subset of ioredis used, so tests can substitute a Map-backed fake. +export type RedisLike = { + get(key: string): Promise; + setex(key: string, seconds: number, value: string): Promise; +}; + +export type CVRStore = { + get(key: string, cvrID: string): Promise; + set(key: string, cvr: StoredCVR): Promise; +}; + +// A missing/erroring store must never fail a pull — every degraded path +// returns null, which buildPullResponse answers with a full snapshot. +export function makeCVRStore(redis: RedisLike | null): CVRStore { + return { + async get(key, cvrID) { + if (!redis) return null; + try { + let raw = await redis.get(KEY_PREFIX + key); + if (!raw) return null; + let stored = JSON.parse(raw) as StoredCVR; + return stored.id === cvrID ? stored : null; + } catch (e) { + console.error("[cvrStore] get failed:", e); + return null; + } + }, + async set(key, cvr) { + if (!redis) return; + try { + await redis.setex(KEY_PREFIX + key, TTL_SECONDS, JSON.stringify(cvr)); + } catch (e) { + console.error("[cvrStore] set failed:", e); + } + }, + }; +} diff --git a/src/replicache/serverPullData.integration.test.ts b/src/replicache/serverPullData.integration.test.ts new file mode 100644 index 00000000..0e42ed42 --- /dev/null +++ b/src/replicache/serverPullData.integration.test.ts @@ -0,0 +1,557 @@ +import { afterAll, describe, expect, test } from "vitest"; +import { Pool } from "pg"; +import { createClient } from "@supabase/supabase-js"; +import type { Database } from "supabase/database.types"; +import { randomUUID } from "node:crypto"; +import type { PatchOperation, ReadonlyJSONValue } from "replicache"; +import { fetchPullData, computePull } from "./serverPullData"; +import { parseCVRCookie } from "./cvr"; +import { makeCVRStore, type RedisLike } from "./cvrStore"; + +// computePull is exercised with the real store logic over a Map-backed fake +// redis; only the ioredis transport itself is out of scope here. +let redisMap = new Map(); +let fakeRedis: RedisLike = { + async get(key) { + return redisMap.get(key) ?? null; + }, + async setex(key, _seconds, value) { + redisMap.set(key, value); + }, +}; +let store = makeCVRStore(fakeRedis); + +// These tests run against the local supabase stack (which must be migrated +// and running: `supabase start`): PostgREST for the pull function like +// production, plus a direct pg connection for seeding and parity queries. +// They never read .env.local so they can't accidentally point at production. +const TEST_DB_URL = + process.env.TEST_DB_URL ?? + "postgresql://postgres:postgres@127.0.0.1:54322/postgres"; +// The standard supabase-CLI local demo credentials (`supabase status`). +const TEST_SUPABASE_URL = + process.env.TEST_SUPABASE_URL ?? "http://127.0.0.1:54321"; +const TEST_SUPABASE_SERVICE_KEY = + process.env.TEST_SUPABASE_SERVICE_KEY ?? + "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU"; + +let pool = new Pool({ connectionString: TEST_DB_URL, max: 5 }); +let supabase = createClient( + TEST_SUPABASE_URL, + TEST_SUPABASE_SERVICE_KEY, +); + +let dbAvailable = await pool + .query( + "select 1 from pg_proc where proname = 'pull_data_cvr' and exists (select 1 from pg_proc where proname = 'pull_data')", + ) + .then((r) => r.rowCount === 1) + .catch(() => false); +if (!dbAvailable) { + // Skipping locally is a convenience; in CI it would silently pass a broken + // stack, so fail instead. + if (process.env.CI) + throw new Error( + `Pull integration tests require a migrated database at ${TEST_DB_URL}`, + ); + console.warn( + `Skipping pull integration tests: no migrated database at ${TEST_DB_URL}`, + ); +} + +// --------------------------------------------------------------------------- +// Seeding helpers. Cleanup relies on ON DELETE CASCADE from entity_sets for +// everything except documents and replicache_clients, which we track. +// --------------------------------------------------------------------------- + +let createdEntitySets: string[] = []; +let createdDocuments: string[] = []; +let createdClients: string[] = []; + +async function createDoc() { + let set = randomUUID(); + let root = randomUUID(); + let token = randomUUID(); + createdEntitySets.push(set); + await pool.query(`insert into entity_sets (id) values ($1)`, [set]); + await pool.query(`insert into entities (id, set) values ($1, $2)`, [ + root, + set, + ]); + await pool.query( + `insert into permission_tokens (id, root_entity) values ($1, $2)`, + [token, root], + ); + return { set, root, token }; +} + +async function addEntity(set: string) { + let id = randomUUID(); + await pool.query(`insert into entities (id, set) values ($1, $2)`, [id, set]); + return id; +} + +async function addFact(entity: string, attribute: string, data: unknown) { + let id = randomUUID(); + await pool.query( + `insert into facts (id, entity, attribute, data) values ($1, $2, $3, $4)`, + [id, entity, attribute, JSON.stringify(data)], + ); + return id; +} + +async function setClient( + client_id: string, + client_group: string, + last_mutation: number, +) { + createdClients.push(client_id); + await pool.query( + `insert into replicache_clients (client_id, client_group, last_mutation) + values ($1, $2, $3) + on conflict (client_id) do update set last_mutation = excluded.last_mutation`, + [client_id, client_group, last_mutation], + ); +} + +async function addPublicationFor(token: string, title: string) { + let did = `did:plc:test-${randomUUID().slice(0, 13)}`; + let uri = `at://${did}/pub.leaflet.publication/test`; + await pool.query( + `insert into identities (id, home_page, atp_did) values ($1, $2, $3)`, + [randomUUID(), token, did], + ); + await pool.query( + `insert into publications (uri, name, identity_did) values ($1, $2, $3)`, + [uri, "Test Publication", did], + ); + await pool.query( + `insert into leaflets_in_publications (publication, leaflet, doc, title, description, tags, preferences) + values ($1, $2, null, $3, 'a description', $4, $5)`, + [uri, token, title, ["tag1", "tag2"], JSON.stringify({ theme: "dark" })], + ); + return { did, uri }; +} + +function sortById(rows: T[]) { + return [...rows].sort((a, b) => a.id.localeCompare(b.id)); +} + +async function naivePullData(token: string, clientGroup: string) { + let { rows } = await pool.query( + `select * from pull_data($1::uuid, $2)`, + [token, clientGroup], + ); + return rows[0] as { + client_groups: + | { client_id: string; client_group: string; last_mutation: number }[] + | null; + facts: { id: string; [k: string]: unknown }[] | null; + publications: Record[] | null; + draft_contributors: string[] | null; + }; +} + +function applyPatch( + kv: Map, + patch: readonly PatchOperation[], +) { + for (let op of patch) { + if (op.op === "clear") kv.clear(); + else if (op.op === "put") kv.set(op.key, op.value); + else if (op.op === "del") kv.delete(op.key); + } +} + +afterAll(async () => { + if (dbAvailable) { + if (createdEntitySets.length) + await pool.query(`delete from entity_sets where id = any($1)`, [ + createdEntitySets, + ]); + if (createdDocuments.length) + await pool.query(`delete from documents where uri = any($1)`, [ + createdDocuments, + ]); + if (createdClients.length) + await pool.query(`delete from replicache_clients where client_id = any($1)`, [ + createdClients, + ]); + } + await pool.end(); +}); + +// --------------------------------------------------------------------------- + +describe.runIf(dbAvailable)("fetchPullData parity with pull_data RPC", () => { + test("fact closure, clients, publication and contributors all match", async () => { + let { set, root, token } = await createDoc(); + let child = await addEntity(set); + let grandchild = await addEntity(set); + let orphan = await addEntity(set); + + await addFact(root, "block/text", { type: "text", value: "hello world" }); + await addFact(root, "card/block", { + type: "ordered-reference", + value: child, + position: "a0", + }); + await addFact(child, "card/block", { + type: "reference", + value: grandchild, + }); + await addFact(grandchild, "canvas/block", { + type: "spatial-reference", + value: root, // cycle back to the root + position: { x: 0, y: 0 }, + }); + // orphan is not reachable from root and must not appear + await addFact(orphan, "block/text", { type: "text", value: "orphan" }); + + let clientGroup = `cg-${randomUUID()}`; + await setClient(`c1-${randomUUID()}`, clientGroup, 4); + await setClient(`c2-${randomUUID()}`, clientGroup, 9); + await addPublicationFor(token, "Pub Title"); + await pool.query( + `insert into leaflet_contributors (leaflet, contributor_did) + select $1, atp_did from identities where home_page = $1`, + [token], + ); + + let naive = await naivePullData(token, clientGroup); + let mine = await fetchPullData(supabase, token, clientGroup, null); + + // facts: same rows, same JSON shapes + expect(sortById(mine.facts.map((f) => f.fact!))).toEqual( + sortById(naive.facts ?? []), + ); + expect(mine.facts).toHaveLength(4); + for (let f of mine.facts) { + expect(typeof f.row_version).toBe("number"); + expect(f.row_version).toBeGreaterThan(0); + } + + // clients + let naiveClients: Record = {}; + for (let c of naive.client_groups ?? []) + naiveClients[c.client_id] = c.last_mutation; + expect(mine.clients).toEqual(naiveClients); + + // publications + contributors + expect(mine.publications).toEqual(naive.publications); + expect(mine.publications?.[0]?.title).toBe("Pub Title"); + expect(mine.draft_contributors).toEqual(naive.draft_contributors); + expect(mine.draft_contributors).toHaveLength(1); + }); + + test("empty/nonexistent cases match", async () => { + // token that exists but has no facts beyond nothing on the root + let { token } = await createDoc(); + let naive = await naivePullData(token, "no-such-group"); + let mine = await fetchPullData(supabase, token, "no-such-group", null); + expect(mine.facts.map((f) => f.fact!)).toEqual(naive.facts ?? []); + expect(mine.clients).toEqual({}); + expect(mine.publications).toEqual(naive.publications); + expect(mine.draft_contributors).toEqual(naive.draft_contributors); + + // valid-uuid but nonexistent token + let ghost = randomUUID(); + let naiveGhost = await naivePullData(ghost, "no-such-group"); + let mineGhost = await fetchPullData(supabase, ghost, "no-such-group", null); + expect(mineGhost.facts).toEqual([]); + expect(naiveGhost.facts).toBeNull(); + expect(mineGhost.publications).toBeNull(); + expect(mineGhost.draft_contributors).toBeNull(); + + // malformed token id errors in both + await expect(naivePullData("not-a-uuid", "g")).rejects.toThrow(); + await expect(fetchPullData(supabase, "not-a-uuid", "g", null)).rejects.toThrow(); + }); + + test("hydrates full rows only for facts that differ from the passed base", async () => { + let { root, token } = await createDoc(); + let factA = await addFact(root, "block/text", { type: "text", value: "a" }); + let factB = await addFact(root, "block/text", { type: "text", value: "b" }); + + let full = await fetchPullData(supabase, token, "g", null); + expect(full.facts.every((f) => f.fact !== null)).toBe(true); + let base = Object.fromEntries(full.facts.map((f) => [f.id, f.row_version])); + + // base matches current state exactly: versions only, no rows hydrated + let unchanged = await fetchPullData(supabase, token, "g", base); + expect(unchanged.facts).toHaveLength(2); + expect(unchanged.facts.every((f) => f.fact === null)).toBe(true); + expect( + Object.fromEntries(unchanged.facts.map((f) => [f.id, f.row_version])), + ).toEqual(base); + + // one stale version + one unknown id: only those hydrate + await pool.query(`update facts set data = $2 where id = $1`, [ + factA, + JSON.stringify({ type: "text", value: "a2" }), + ]); + let partial = await fetchPullData(supabase, token, "g", base); + let partialA = partial.facts.find((f) => f.id === factA)!; + let partialB = partial.facts.find((f) => f.id === factB)!; + expect(partialA.fact!.data).toEqual({ type: "text", value: "a2" }); + expect(partialB.fact).toBeNull(); + + let factC = await addFact(root, "block/text", { type: "text", value: "c" }); + let withNew = await fetchPullData(supabase, token, "g", base); + expect(withNew.facts.find((f) => f.id === factC)!.fact).not.toBeNull(); + }); + + test("row_version tracks updates via xmin", async () => { + let { root, token } = await createDoc(); + let factA = await addFact(root, "block/text", { + type: "text", + value: "a", + }); + let factB = await addFact(root, "block/text", { + type: "text", + value: "b", + }); + + let before = await fetchPullData(supabase, token, "g", null); + let beforeA = before.facts.find((f) => f.id === factA)!; + let beforeB = before.facts.find((f) => f.id === factB)!; + + await pool.query(`update facts set data = $2 where id = $1`, [ + factA, + JSON.stringify({ type: "text", value: "a2" }), + ]); + + let after = await fetchPullData(supabase, token, "g", null); + let afterA = after.facts.find((f) => f.id === factA)!; + let afterB = after.facts.find((f) => f.id === factB)!; + + expect(afterA.row_version).not.toBe(beforeA.row_version); + expect(afterA.fact!.data).toEqual({ type: "text", value: "a2" }); + expect(afterB.row_version).toBe(beforeB.row_version); + }); +}); + +describe.runIf(dbAvailable)("computePull end-to-end", () => { + test("first pull resets, later pulls are incremental, no-op pulls are free", async () => { + let { set, root, token } = await createDoc(); + let child = await addEntity(set); + let factRoot = await addFact(root, "card/block", { + type: "reference", + value: child, + }); + let factChild = await addFact(child, "block/text", { + type: "text", + value: "original", + }); + let clientGroup = `cg-${randomUUID()}`; + let clientID = `c-${randomUUID()}`; + await setClient(clientID, clientGroup, 1); + + // --- first pull: legacy-style full snapshot + let kv = new Map(); + let first = await computePull( + supabase, + store, + { cookie: null, clientGroupID: clientGroup }, + token, + Date.now(), + ); + if ("error" in first) throw new Error("unexpected pull error"); + expect(first.patch[0]).toEqual({ op: "clear" }); + expect(first.lastMutationIDChanges).toEqual({ [clientID]: 1 }); + applyPatch(kv, first.patch); + expect(kv.get("initialized")).toBe(true); + expect(kv.has(factRoot)).toBe(true); + expect(kv.has(factChild)).toBe(true); + expect(parseCVRCookie(first.cookie)).not.toBeNull(); + let slotKey = `replicache-cvr:${token}:${clientGroup}`; + expect(JSON.parse(redisMap.get(slotKey)!).id).toBe( + parseCVRCookie(first.cookie)!.cvrID, + ); + + // --- no-op pull: nothing changed + let noop = await computePull( + supabase, + store, + { cookie: JSON.parse(JSON.stringify(first.cookie)), clientGroupID: clientGroup }, + token, + Date.now(), + ); + if ("error" in noop) throw new Error("unexpected pull error"); + expect(noop.patch).toEqual([]); + expect(noop.lastMutationIDChanges).toEqual({}); + expect(noop.cookie).toEqual(JSON.parse(JSON.stringify(first.cookie))); + // a no-op pull writes nothing: the slot still holds the first CVR + expect(JSON.parse(redisMap.get(slotKey)!).id).toBe( + parseCVRCookie(first.cookie)!.cvrID, + ); + + // --- mutate: update one fact, add one, delete none, confirm a mutation + await pool.query(`update facts set data = $2 where id = $1`, [ + factChild, + JSON.stringify({ type: "text", value: "edited" }), + ]); + let factNew = await addFact(child, "block/heading", { + type: "number", + value: 2, + }); + await setClient(clientID, clientGroup, 2); + + let second = await computePull( + supabase, + store, + { cookie: noop.cookie, clientGroupID: clientGroup }, + token, + Date.now(), + ); + if ("error" in second) throw new Error("unexpected pull error"); + // incremental: no clear, and only the two touched facts + expect(second.patch.some((op) => op.op === "clear")).toBe(false); + expect( + second.patch.map((op) => ({ op: op.op, key: "key" in op ? op.key : "" })), + ).toEqual( + expect.arrayContaining([ + { op: "put", key: factChild }, + { op: "put", key: factNew }, + ]), + ); + expect(second.patch).toHaveLength(2); + expect(second.lastMutationIDChanges).toEqual({ [clientID]: 2 }); + applyPatch(kv, second.patch); + + // --- delete a fact (which unlinks the child closure too) + await pool.query(`delete from facts where id = $1`, [factRoot]); + let third = await computePull( + supabase, + store, + { cookie: second.cookie, clientGroupID: clientGroup }, + token, + Date.now(), + ); + if ("error" in third) throw new Error("unexpected pull error"); + // deleting the reference fact removes the whole child subtree from the view + expect(new Set(third.patch.map((op) => op.op))).toEqual(new Set(["del"])); + expect(new Set(third.patch.map((op) => ("key" in op ? op.key : "")))).toEqual( + new Set([factRoot, factChild, factNew]), + ); + applyPatch(kv, third.patch); + + // final client view must equal a from-scratch snapshot + let fresh = await computePull( + supabase, + store, + { cookie: null, clientGroupID: clientGroup }, + token, + Date.now(), + ); + if ("error" in fresh) throw new Error("unexpected pull error"); + let freshKv = new Map(); + applyPatch(freshKv, fresh.patch); + expect(Object.fromEntries(kv)).toEqual(Object.fromEntries(freshKv)); + }); + + test("publication metadata changes flow through incrementally", async () => { + let { token } = await createDoc(); + let first = await computePull( + supabase, + store, + { cookie: null, clientGroupID: "g" }, + token, + Date.now(), + ); + if ("error" in first) throw new Error("unexpected pull error"); + let kv = new Map(); + applyPatch(kv, first.patch); + expect(kv.has("publication_title")).toBe(false); + + await addPublicationFor(token, "First Title"); + let second = await computePull( + supabase, + store, + { cookie: first.cookie, clientGroupID: "g" }, + token, + Date.now(), + ); + if ("error" in second) throw new Error("unexpected pull error"); + applyPatch(kv, second.patch); + expect(kv.get("publication_title")).toBe("First Title"); + expect(kv.get("publication_tags")).toEqual(["tag1", "tag2"]); + expect(kv.get("post_preferences")).toEqual({ theme: "dark" }); + expect(kv.get("draft_contributors")).toEqual([]); + + await pool.query( + `update leaflets_in_publications set title = 'Renamed' where leaflet = $1`, + [token], + ); + let third = await computePull( + supabase, + store, + { cookie: second.cookie, clientGroupID: "g" }, + token, + Date.now(), + ); + if ("error" in third) throw new Error("unexpected pull error"); + expect(third.patch).toEqual([ + { op: "put", key: "publication_title", value: "Renamed" }, + ]); + }); + + test("legacy numeric cookie falls back to a full snapshot with a larger order", async () => { + let { root, token } = await createDoc(); + await addFact(root, "block/text", { type: "text", value: "hi" }); + let legacyCookie = Date.now() - 1000; + let res = await computePull( + supabase, + store, + { cookie: legacyCookie, clientGroupID: "g" }, + token, + Date.now(), + ); + if ("error" in res) throw new Error("unexpected pull error"); + expect(res.patch[0]).toEqual({ op: "clear" }); + expect((res.cookie as { order: number }).order).toBeGreaterThan( + legacyCookie, + ); + expect(parseCVRCookie(res.cookie)).not.toBeNull(); + }); + + test("an evicted CVR slot falls back to a full snapshot and re-converges", async () => { + let { root, token } = await createDoc(); + await addFact(root, "block/text", { type: "text", value: "hi" }); + let first = await computePull( + supabase, + store, + { cookie: null, clientGroupID: "g" }, + token, + Date.now(), + ); + if ("error" in first) throw new Error("unexpected pull error"); + let slotKey = `replicache-cvr:${token}:g`; + redisMap.delete(slotKey); + + let res = await computePull( + supabase, + store, + { cookie: first.cookie, clientGroupID: "g" }, + token, + Date.now(), + ); + if ("error" in res) throw new Error("unexpected pull error"); + expect(res.patch[0]).toEqual({ op: "clear" }); + // a fresh slot was written for the new cookie + expect(JSON.parse(redisMap.get(slotKey)!).id).toBe( + parseCVRCookie(res.cookie)!.cvrID, + ); + }); + + test("malformed token id returns ClientStateNotFound like the legacy handler", async () => { + let res = await computePull( + supabase, + store, + { cookie: null, clientGroupID: "g" }, + "not-a-uuid", + Date.now(), + ); + expect(res).toEqual({ error: "ClientStateNotFound" }); + }); +}); diff --git a/src/replicache/serverPullData.ts b/src/replicache/serverPullData.ts new file mode 100644 index 00000000..aa1941f4 --- /dev/null +++ b/src/replicache/serverPullData.ts @@ -0,0 +1,121 @@ +import { randomUUID } from "node:crypto"; +import type { SupabaseClient } from "@supabase/supabase-js"; +import type { + ClientStateNotFoundResponse, + PullResponseOKV1, + ReadonlyJSONValue, +} from "replicache"; +import type { Database } from "supabase/database.types"; +import { + buildExtras, + buildPullResponse, + parseCVRCookie, + type PullFactVersion, +} from "./cvr"; +import type { CVRStore } from "./cvrStore"; + +export type PullData = { + clients: Record; + facts: PullFactVersion[]; + publications: + | { + description: string; + title: string; + tags: string[] | null; + cover_image: string | null; + preferences: ReadonlyJSONValue | null; + }[] + | null; + draft_contributors: string[] | null; +}; + +// The whole client view comes from the pull_data_cvr stored function (see its +// migration for the query): one PostgREST round trip, no pool connection, and +// — because the function is a single SQL statement — one snapshot for every +// read, so a mutation's lastMutationID is never visible without its fact +// writes. The caller's base CVR fact versions are passed in so full fact rows +// only leave the database when new or changed against that base; null +// hydrates everything (the full-snapshot path). +export async function fetchPullData( + supabase: SupabaseClient, + token_id: string, + client_group_id: string, + baseFactVersions: Record | null, +): Promise { + let { data, error } = await supabase.rpc("pull_data_cvr", { + token_id, + client_group_id, + base_cvr: baseFactVersions ?? {}, + }); + if (error || data == null) + throw error ?? new Error("pull_data_cvr returned no data"); + let result = data as unknown as { + client_groups: { client_id: string; last_mutation: number }[] | null; + facts: + | { id: string; row_version: number; fact: PullFactVersion["fact"] }[] + | null; + publications: PullData["publications"]; + draft_contributors: string[] | null; + }; + let clients: Record = {}; + for (let row of result.client_groups ?? []) + clients[row.client_id] = Number(row.last_mutation); + return { + clients, + facts: (result.facts ?? []).map((row) => ({ + id: row.id, + row_version: Number(row.row_version), + fact: row.fact ?? null, + })), + publications: result.publications, + draft_contributors: result.draft_contributors, + }; +} + +// The full CVR pull: resolve the base CVR the cookie points at, fetch the +// client view, diff, and store the advanced CVR. Cookies we can't parse +// (legacy Date.now() numbers, null first pulls) and cookies whose CVR is +// gone from the store both get the legacy full-snapshot response. +export async function computePull( + supabase: SupabaseClient, + store: CVRStore, + pullRequest: { cookie: unknown; clientGroupID: string }, + token_id: string, + now: number, +): Promise { + let cookie = parseCVRCookie(pullRequest.cookie); + let storeKey = `${token_id}:${pullRequest.clientGroupID}`; + try { + // The base CVR must resolve before the pull query runs: the query takes + // the base fact versions as an argument and only hydrates rows that + // differ from them. + let baseCVR = cookie ? await store.get(storeKey, cookie.cvrID) : null; + let data = await fetchPullData( + supabase, + token_id, + pullRequest.clientGroupID, + baseCVR?.f ?? null, + ); + let nextCVRID = randomUUID(); + let { response, nextCVR } = buildPullResponse({ + prevCookie: pullRequest.cookie, + baseCVR, + nextCVRID, + facts: data.facts, + extras: buildExtras(data.publications, data.draft_contributors), + clients: data.clients, + now, + }); + if (nextCVR) await store.set(storeKey, { id: nextCVRID, ...nextCVR }); + return response; + } catch (e) { + // The legacy handler returned ClientStateNotFound whenever the pull_data + // RPC errored (e.g. a malformed token id); Replicache then resets the + // client, which also cleanly recovers the (structurally impossible) + // unhydrated-changed-fact error from buildPullResponse. Preserve that + // rather than surfacing a 500 the puller would hand to Replicache as a + // garbage response. + console.log(e); + return { error: "ClientStateNotFound" }; + } +} diff --git a/supabase/database.types.ts b/supabase/database.types.ts index 646e8631..f2f83914 100644 --- a/supabase/database.types.ts +++ b/supabase/database.types.ts @@ -2080,6 +2080,14 @@ export type Database = { } Returns: Database["public"]["CompositeTypes"]["pull_result"] } + pull_data_cvr: { + Args: { + token_id: string + client_group_id: string + base_cvr: Json + } + Returns: Json + } search_tags: { Args: { search_query: string diff --git a/supabase/migrations/20250827115826_updated_reference_index.sql b/supabase/migrations/20250827115826_updated_reference_index.sql index 32e0a822..10bb8053 100644 --- a/supabase/migrations/20250827115826_updated_reference_index.sql +++ b/supabase/migrations/20250827115826_updated_reference_index.sql @@ -1,2 +1,2 @@ -CREATE INDEX CONCURRENTLY facts_reference_idx2 ON public.facts USING btree (((data ->> 'value'::text))) WHERE (((data ->> 'type'::text) = 'reference'::text) OR ((data ->> 'type'::text) = 'ordered-reference'::text) OR ((data ->> 'type'::text) = 'spatial-reference'::text)); +CREATE INDEX facts_reference_idx2 ON public.facts USING btree (((data ->> 'value'::text))) WHERE (((data ->> 'type'::text) = 'reference'::text) OR ((data ->> 'type'::text) = 'ordered-reference'::text) OR ((data ->> 'type'::text) = 'spatial-reference'::text)); drop index facts_reference_idx; diff --git a/supabase/migrations/20260305100000_fix_profile_posts_performance.sql b/supabase/migrations/20260305100000_fix_profile_posts_performance.sql index ddcd251c..f586e57f 100644 --- a/supabase/migrations/20260305100000_fix_profile_posts_performance.sql +++ b/supabase/migrations/20260305100000_fix_profile_posts_performance.sql @@ -1,16 +1,16 @@ -- Add missing indexes on document foreign keys used by get_profile_posts -CREATE INDEX CONCURRENTLY IF NOT EXISTS comments_on_documents_document_idx +CREATE INDEX IF NOT EXISTS comments_on_documents_document_idx ON public.comments_on_documents (document); -CREATE INDEX CONCURRENTLY IF NOT EXISTS document_mentions_in_bsky_document_idx +CREATE INDEX IF NOT EXISTS document_mentions_in_bsky_document_idx ON public.document_mentions_in_bsky (document); -CREATE INDEX CONCURRENTLY IF NOT EXISTS documents_in_publications_document_idx +CREATE INDEX IF NOT EXISTS documents_in_publications_document_idx ON public.documents_in_publications (document); -- Expression index to look up documents by DID without adding a column -- at://did:plc:xxx/collection/rkey -> split_part gives did:plc:xxx -CREATE INDEX CONCURRENTLY IF NOT EXISTS documents_identity_did_sort_idx +CREATE INDEX IF NOT EXISTS documents_identity_did_sort_idx ON public.documents (split_part(uri, '/', 3), sort_date DESC, uri DESC); -- Rewrite get_profile_posts to use the expression index diff --git a/supabase/migrations/20260721000000_add_pull_data_cvr.sql b/supabase/migrations/20260721000000_add_pull_data_cvr.sql new file mode 100644 index 00000000..1d9d941d --- /dev/null +++ b/supabase/migrations/20260721000000_add_pull_data_cvr.sql @@ -0,0 +1,65 @@ +-- CVR pull query: replicates pull_data (and its get_facts closure walk) with +-- two changes for the row-version strategy. Each fact carries a row_version +-- taken from xmin, which bumps on every insert/update; and the caller's base +-- CVR fact versions come in as base_cvr, so the full fact row is only +-- serialized for facts that are new or changed against that base — everything +-- else returns as bare (id, row_version) pairs, which the caller needs to +-- build the next CVR anyway. Pass '{}' to hydrate everything (full snapshot). +-- A single SQL statement, so all reads share one snapshot: a mutation's +-- lastMutationID is never visible without its fact writes. +CREATE OR REPLACE FUNCTION public.pull_data_cvr(token_id uuid, client_group_id text, base_cvr jsonb) + RETURNS jsonb + LANGUAGE sql + STABLE +AS $function$ +SELECT jsonb_build_object( + 'client_groups', ( + SELECT jsonb_agg(jsonb_build_object( + 'client_id', rc.client_id, + 'last_mutation', rc.last_mutation)) + FROM replicache_clients rc + WHERE rc.client_group = client_group_id + ), + 'facts', ( + WITH RECURSIVE all_facts AS ( + SELECT f.*, f.xmin::text::bigint AS row_version + FROM facts f + JOIN permission_tokens pt ON f.entity = pt.root_entity + WHERE pt.id = token_id + UNION + SELECT f.*, f.xmin::text::bigint AS row_version + FROM facts f + INNER JOIN all_facts f1 ON (uuid(f1.data ->> 'value') = f.entity) + WHERE f1.data ->> 'type' = 'reference' + OR f1.data ->> 'type' = 'ordered-reference' + OR f1.data ->> 'type' = 'spatial-reference' + ), + base AS ( + SELECT key AS id, value::bigint AS row_version + FROM jsonb_each_text(coalesce(base_cvr, '{}'::jsonb)) + ) + SELECT jsonb_agg(jsonb_build_object( + 'id', af.id, + 'row_version', af.row_version, + 'fact', CASE WHEN b.row_version IS NULL OR b.row_version <> af.row_version + THEN row_to_json(af)::jsonb - 'row_version' + END)) + FROM all_facts af + LEFT JOIN base b ON b.id = af.id::text + ), + 'publications', coalesce( + (SELECT jsonb_agg(row_to_json(lip)::jsonb) + FROM leaflets_in_publications lip + WHERE lip.leaflet = token_id), + (SELECT jsonb_agg(row_to_json(ltd)::jsonb) + FROM leaflets_to_documents ltd + WHERE ltd.leaflet = token_id) + ), + 'draft_contributors', ( + SELECT jsonb_agg(lc.contributor_did) + FROM leaflet_contributors lc + WHERE lc.leaflet = token_id + ) +); +$function$ +; diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..c79c5e7b --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "vitest/config"; +import tsconfigPaths from "vite-tsconfig-paths"; + +export default defineConfig({ + plugins: [tsconfigPaths()], + test: { + environment: "node", + include: ["**/*.test.ts"], + exclude: ["**/node_modules/**"], + }, +});