From 03de8b6cd63e14c1a0d0d6e8a61b0ff961f6cd01 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Sat, 18 Jul 2026 21:27:31 -0400 Subject: [PATCH 1/7] Replace naive reset pull with CVR/row-version strategy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pulls previously ignored the cookie and shipped a clear op plus every fact on every pull. The pull handler now diffs against a Client View Record per https://doc.replicache.dev/strategies/row-version: the cookie carries {order, cvrID}, the CVR itself lives in a single Redis slot per token+clientGroup (SETEX 30d, overwritten in place so editing churn never accumulates keys), and facts are versioned by Postgres xmin so no schema changes are needed. Data is fetched with direct SQL replicating the pull_data RPC (plus xmin) in one repeatable-read snapshot, so a mutation's lastMutationID is never visible without its writes. Every degraded path — legacy Date.now() cookies, null first pulls, Redis down/evicted/superseded slots — falls back to the legacy full-snapshot response, and cookie orders stay above wall clock so rollback to the reset-strategy server cannot strand upgraded clients. Without REDIS_URL (dev) every pull is a full snapshot. Adds the repo's first test setup (vitest): randomized equivalence simulation against the naive strategy including lost-response and eviction recovery, and integration tests against local supabase with row-for-row parity checks between fetchPullData and the pull_data RPC. Co-Authored-By: Claude Fable 5 --- app/api/rpc/[command]/pull.ts | 117 +- package-lock.json | 1569 ++++++++++++++++- package.json | 4 + src/replicache/cvr.test.ts | 532 ++++++ src/replicache/cvr.ts | 221 +++ src/replicache/cvrStore.test.ts | 67 + src/replicache/cvrStore.ts | 50 + .../serverPullData.integration.test.ts | 504 ++++++ src/replicache/serverPullData.ts | 153 ++ vitest.config.ts | 11 + 10 files changed, 3102 insertions(+), 126 deletions(-) create mode 100644 src/replicache/cvr.test.ts create mode 100644 src/replicache/cvr.ts create mode 100644 src/replicache/cvrStore.test.ts create mode 100644 src/replicache/cvrStore.ts create mode 100644 src/replicache/serverPullData.integration.test.ts create mode 100644 src/replicache/serverPullData.ts create mode 100644 vitest.config.ts diff --git a/app/api/rpc/[command]/pull.ts b/app/api/rpc/[command]/pull.ts index 17bc6aaf..9cb60163 100644 --- a/app/api/rpc/[command]/pull.ts +++ b/app/api/rpc/[command]/pull.ts @@ -1,15 +1,12 @@ 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 { drizzle } from "drizzle-orm/node-postgres"; +import Client from "ioredis"; +import { pool } from "supabase/pool"; +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 +18,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 +43,22 @@ const pullRequestV1 = z.object({ // Combined PullRequest type const PullRequestSchema = z.union([pullRequestV0, pullRequestV1]); +const db = drizzle(pool); +// 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) => { + handler: async ({ pullRequest, token_id }, _env: 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(db, 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..127abbcb --- /dev/null +++ b/src/replicache/cvr.test.ts @@ -0,0 +1,532 @@ +import { describe, expect, test } from "vitest"; +import type { PatchOperation, ReadonlyJSONValue } from "replicache"; +import { + buildExtras, + buildPullResponse, + nextCookieOrder, + parseCVRCookie, + stableHash, + type PullFactRow, +} 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 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) { + return { + facts: [...s.facts.values()], + 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 { facts, extras } = pullInputs(s); + for (let [key, value] of Object.entries(extras)) kv.set(key, value); + for (let { fact } of facts) + 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), + now, + }); + if (nextCVR) await store.set(storeKey, { id: nextCVRID, ...nextCVR }); + return response; +} + +let factCounter = 0; +function makeFact( + s: ServerState, + data?: { type: string; value: unknown }, +): PullFactRow { + 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), + 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), + 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), + 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("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..65ff0b30 --- /dev/null +++ b/src/replicache/cvr.ts @@ -0,0 +1,221 @@ +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 PullFactRow = { fact: PullFact; row_version: number }; + +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); +} + +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. + baseCVR: CVR | null; + nextCVRID: string; + facts: PullFactRow[]; + // 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 { fact, row_version } of facts) nextF[fact.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 { fact } of facts) + patch.push({ + op: "put", + key: fact.id, + value: FactWithIndexes(fact 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 { fact, row_version } of facts) + if (base.f[fact.id] !== row_version) + patch.push({ + op: "put", + key: fact.id, + value: FactWithIndexes(fact 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..67f4da2b --- /dev/null +++ b/src/replicache/serverPullData.integration.test.ts @@ -0,0 +1,504 @@ +import { afterAll, describe, expect, test } from "vitest"; +import { Pool } from "pg"; +import { drizzle } from "drizzle-orm/node-postgres"; +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 database (which must be migrated +// and running: `supabase start`). 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"; + +let pool = new Pool({ connectionString: TEST_DB_URL, max: 5 }); +let db = drizzle(pool); + +let dbAvailable = await pool + .query("select 1 from pg_proc where proname = 'pull_data'") + .then((r) => r.rowCount === 1) + .catch(() => false); +if (!dbAvailable) + 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(db, token, clientGroup); + + // 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(db, token, "no-such-group"); + 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(db, ghost, "no-such-group"); + 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(db, "not-a-uuid", "g")).rejects.toThrow(); + }); + + 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(db, token, "g"); + let beforeA = before.facts.find((f) => f.fact.id === factA)!; + let beforeB = before.facts.find((f) => f.fact.id === factB)!; + + await pool.query(`update facts set data = $2 where id = $1`, [ + factA, + JSON.stringify({ type: "text", value: "a2" }), + ]); + + let after = await fetchPullData(db, token, "g"); + let afterA = after.facts.find((f) => f.fact.id === factA)!; + let afterB = after.facts.find((f) => f.fact.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( + db, + 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( + db, + 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( + db, + 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( + db, + 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( + db, + 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( + db, + 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( + db, + 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( + db, + 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( + db, + 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( + db, + 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( + db, + 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( + db, + 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..30f0a51d --- /dev/null +++ b/src/replicache/serverPullData.ts @@ -0,0 +1,153 @@ +import { sql } from "drizzle-orm"; +import type { NodePgDatabase } from "drizzle-orm/node-postgres"; +import type { + ClientStateNotFoundResponse, + PullResponseOKV1, + ReadonlyJSONValue, +} from "replicache"; +import { randomUUID } from "node:crypto"; +import { + buildExtras, + buildPullResponse, + parseCVRCookie, + type PullFactRow, +} from "./cvr"; +import type { CVRStore } from "./cvrStore"; + +export type PullData = { + clients: Record; + facts: PullFactRow[]; + publications: + | { + description: string; + title: string; + tags: string[] | null; + cover_image: string | null; + preferences: ReadonlyJSONValue | null; + }[] + | null; + draft_contributors: string[] | null; +}; + +// Replicates the pull_data Postgres function (and its get_facts closure walk) +// with one addition: each fact carries a row_version taken from xmin, which +// bumps on every insert/update and so lets the CVR pull diff without any +// schema changes. Runs as a single repeatable-read snapshot to match the +// consistency of the original single-statement RPC — in particular a +// mutation's lastMutationID is never visible without its fact writes. +export async function fetchPullData( + db: NodePgDatabase, + token_id: string, + client_group_id: string, +): Promise { + return await db.transaction( + async (tx) => { + let clientRows = ( + await tx.execute( + sql`SELECT client_id, last_mutation FROM replicache_clients WHERE client_group = ${client_group_id}`, + ) + ).rows as { client_id: string; last_mutation: number | string }[]; + let clients: Record = {}; + for (let row of clientRows) + clients[row.client_id] = Number(row.last_mutation); + + let factRows = ( + await tx.execute( + sql`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}::uuid + 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' + ) + SELECT row_to_json(af)::jsonb - 'row_version' AS fact, af.row_version + FROM all_facts af`, + ) + ).rows as { fact: PullFactRow["fact"]; row_version: number | string }[]; + let facts: PullFactRow[] = factRows.map((row) => ({ + fact: row.fact, + row_version: Number(row.row_version), + })); + + let publications = ( + await tx.execute( + sql`SELECT row_to_json(lip) AS pub FROM leaflets_in_publications lip WHERE lip.leaflet = ${token_id}::uuid`, + ) + ).rows as { pub: NonNullable[number] }[]; + if (publications.length === 0) + publications = ( + await tx.execute( + sql`SELECT row_to_json(ltd) AS pub FROM leaflets_to_documents ltd WHERE ltd.leaflet = ${token_id}::uuid`, + ) + ).rows as { pub: NonNullable[number] }[]; + + let contributorRows = ( + await tx.execute( + sql`SELECT contributor_did FROM leaflet_contributors WHERE leaflet = ${token_id}::uuid`, + ) + ).rows as { contributor_did: string }[]; + + return { + clients, + facts, + publications: + publications.length > 0 ? publications.map((row) => row.pub) : null, + draft_contributors: + contributorRows.length > 0 + ? contributorRows.map((row) => row.contributor_did) + : null, + }; + }, + { isolationLevel: "repeatable read", accessMode: "read only" }, + ); +} + +// 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( + db: NodePgDatabase, + store: CVRStore, + pullRequest: { cookie: unknown; clientGroupID: string }, + token_id: string, + now: number, +): Promise { + let cookie = parseCVRCookie(pullRequest.cookie); + let storeKey = `${token_id}:${pullRequest.clientGroupID}`; + let baseCVR; + let data: PullData; + try { + // Stored CVRs are immutable once written (keyed by cvrID), so reading one + // concurrently with the snapshot query is race-free. + [baseCVR, data] = await Promise.all([ + cookie ? store.get(storeKey, cookie.cvrID) : null, + fetchPullData(db, token_id, pullRequest.clientGroupID), + ]); + } catch (e) { + // The legacy handler returned ClientStateNotFound whenever the pull_data + // RPC errored (e.g. a malformed token id); Replicache then resets the + // client. Preserve that rather than surfacing a 500 the puller would + // hand to Replicache as a garbage response. + console.log(e); + return { error: "ClientStateNotFound" }; + } + 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; +} 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/**"], + }, +}); From fc13859e79b063757733daab3ea78ad621a59ee7 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Sun, 19 Jul 2026 20:38:39 -0400 Subject: [PATCH 2/7] Hydrate only changed facts by passing the base CVR into the pull query MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pull query previously shipped every fact's full row out of Postgres on every pull and diffed in app code. The base CVR's fact versions are now passed in as a jsonb argument; the closure walk still runs in full, but row_to_json only leaves the database for facts whose xmin is new or changed against that base — everything else returns as (id, row_version) pairs, which the next CVR needs anyway. This is the row-version docs' "CVR as query argument" variation: two-phase savings (DB egress and app-side parse/memory drop from O(document) to O(changes) per pull) with no extra round trip. A null base hydrates everything, so full snapshots and all fallback paths are unchanged. The app-side diff remains authoritative for the patch; a changed fact arriving unhydrated (a base-CVR mismatch between query and diff, which the shared computePull plumbing makes structurally impossible) throws and degrades to ClientStateNotFound rather than silently dropping the update. Co-Authored-By: Claude Fable 5 --- src/replicache/cvr.test.ts | 49 ++++++++--- src/replicache/cvr.ts | 39 ++++++--- .../serverPullData.integration.test.ts | 59 ++++++++++--- src/replicache/serverPullData.ts | 83 ++++++++++++------- 4 files changed, 165 insertions(+), 65 deletions(-) diff --git a/src/replicache/cvr.test.ts b/src/replicache/cvr.test.ts index 127abbcb..388f5d64 100644 --- a/src/replicache/cvr.test.ts +++ b/src/replicache/cvr.test.ts @@ -6,7 +6,7 @@ import { nextCookieOrder, parseCVRCookie, stableHash, - type PullFactRow, + type PullFact, } from "./cvr"; import { makeCVRStore, type CVRStore, type RedisLike } from "./cvrStore"; import { FactWithIndexes } from "./utils"; @@ -19,10 +19,12 @@ import type { Attribute } from "src/replicache/attributes"; // 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; + facts: Map; publication: { description: string; title: string; @@ -44,9 +46,15 @@ function newServerState(): ServerState { }; } -function pullInputs(s: ServerState) { +function pullInputs(s: ServerState, baseF: Record | null) { return { - facts: [...s.facts.values()], + // 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, @@ -59,9 +67,12 @@ function pullInputs(s: ServerState) { // id (with indexes), plus the extra keys. function naiveClientView(s: ServerState): Map { let kv = new Map(); - let { facts, extras } = pullInputs(s); + 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 facts) + for (let { fact } of s.facts.values()) kv.set(fact.id, FactWithIndexes(fact as unknown as Fact)); return kv; } @@ -108,7 +119,7 @@ async function serverPull( prevCookie, baseCVR, nextCVRID, - ...pullInputs(s), + ...pullInputs(s, baseCVR?.f ?? null), now, }); if (nextCVR) await store.set(storeKey, { id: nextCVRID, ...nextCVR }); @@ -119,7 +130,7 @@ let factCounter = 0; function makeFact( s: ServerState, data?: { type: string; value: unknown }, -): PullFactRow { +): FactRow { factCounter++; let fact = { id: `fact-${factCounter}`, @@ -212,7 +223,7 @@ describe("buildPullResponse: reset mode", () => { prevCookie: null, baseCVR: null, nextCVRID: "new-cvr", - ...pullInputs(s), + ...pullInputs(s, null), now: 999, }); expect(response.lastMutationIDChanges).toEqual({ client1: 3 }); @@ -251,7 +262,7 @@ describe("buildPullResponse: reset mode", () => { prevCookie: { order: 500, cvrID: "evicted" }, baseCVR: null, nextCVRID: "new-cvr", - ...pullInputs(s), + ...pullInputs(s, null), now: 100, }); expect(response.patch[0]).toEqual({ op: "clear" }); @@ -265,7 +276,7 @@ describe("buildPullResponse: reset mode", () => { prevCookie: legacyCookie, baseCVR: null, nextCVRID: "new-cvr", - ...pullInputs(s), + ...pullInputs(s, null), now: 999, }); expect(response.patch[0]).toEqual({ op: "clear" }); @@ -403,6 +414,22 @@ describe("buildPullResponse: diff mode via the store", () => { ); }); + 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(); diff --git a/src/replicache/cvr.ts b/src/replicache/cvr.ts index 65ff0b30..92959d50 100644 --- a/src/replicache/cvr.ts +++ b/src/replicache/cvr.ts @@ -24,7 +24,13 @@ export type PullFact = { [key: string]: unknown; }; -export type PullFactRow = { fact: PullFact; row_version: number }; +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) @@ -101,13 +107,26 @@ export function stableHash(value: unknown): string { 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: PullFactRow[]; + facts: PullFactVersion[]; // Ordered: `initialized` must be first; later entries keep legacy patch order. extras: Record; clients: Record; @@ -122,7 +141,7 @@ export function buildPullResponse(args: { args; let nextF: Record = {}; - for (let { fact, row_version } of facts) nextF[fact.id] = row_version; + 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); @@ -139,11 +158,11 @@ export function buildPullResponse(args: { let extraEntries = Object.entries(extras); for (let [key, value] of extraEntries) if (key === "initialized") patch.push({ op: "put", key, value }); - for (let { fact } of facts) + for (let row of facts) patch.push({ op: "put", - key: fact.id, - value: FactWithIndexes(fact as unknown as Fact), + 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 }); @@ -158,12 +177,12 @@ export function buildPullResponse(args: { } let patch: PatchOperation[] = []; - for (let { fact, row_version } of facts) - if (base.f[fact.id] !== row_version) + for (let row of facts) + if (base.f[row.id] !== row.row_version) patch.push({ op: "put", - key: fact.id, - value: FactWithIndexes(fact as unknown as Fact), + 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 }); diff --git a/src/replicache/serverPullData.integration.test.ts b/src/replicache/serverPullData.integration.test.ts index 67f4da2b..5b6444f3 100644 --- a/src/replicache/serverPullData.integration.test.ts +++ b/src/replicache/serverPullData.integration.test.ts @@ -199,10 +199,10 @@ describe.runIf(dbAvailable)("fetchPullData parity with pull_data RPC", () => { ); let naive = await naivePullData(token, clientGroup); - let mine = await fetchPullData(db, token, clientGroup); + let mine = await fetchPullData(db, token, clientGroup, null); // facts: same rows, same JSON shapes - expect(sortById(mine.facts.map((f) => f.fact))).toEqual( + expect(sortById(mine.facts.map((f) => f.fact!))).toEqual( sortById(naive.facts ?? []), ); expect(mine.facts).toHaveLength(4); @@ -228,8 +228,8 @@ describe.runIf(dbAvailable)("fetchPullData parity with pull_data RPC", () => { // 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(db, token, "no-such-group"); - expect(mine.facts.map((f) => f.fact)).toEqual(naive.facts ?? []); + let mine = await fetchPullData(db, 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); @@ -237,7 +237,7 @@ describe.runIf(dbAvailable)("fetchPullData parity with pull_data RPC", () => { // valid-uuid but nonexistent token let ghost = randomUUID(); let naiveGhost = await naivePullData(ghost, "no-such-group"); - let mineGhost = await fetchPullData(db, ghost, "no-such-group"); + let mineGhost = await fetchPullData(db, ghost, "no-such-group", null); expect(mineGhost.facts).toEqual([]); expect(naiveGhost.facts).toBeNull(); expect(mineGhost.publications).toBeNull(); @@ -245,7 +245,40 @@ describe.runIf(dbAvailable)("fetchPullData parity with pull_data RPC", () => { // malformed token id errors in both await expect(naivePullData("not-a-uuid", "g")).rejects.toThrow(); - await expect(fetchPullData(db, "not-a-uuid", "g")).rejects.toThrow(); + await expect(fetchPullData(db, "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(db, 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(db, 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(db, 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(db, token, "g", base); + expect(withNew.facts.find((f) => f.id === factC)!.fact).not.toBeNull(); }); test("row_version tracks updates via xmin", async () => { @@ -259,21 +292,21 @@ describe.runIf(dbAvailable)("fetchPullData parity with pull_data RPC", () => { value: "b", }); - let before = await fetchPullData(db, token, "g"); - let beforeA = before.facts.find((f) => f.fact.id === factA)!; - let beforeB = before.facts.find((f) => f.fact.id === factB)!; + let before = await fetchPullData(db, 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(db, token, "g"); - let afterA = after.facts.find((f) => f.fact.id === factA)!; - let afterB = after.facts.find((f) => f.fact.id === factB)!; + let after = await fetchPullData(db, 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(afterA.fact!.data).toEqual({ type: "text", value: "a2" }); expect(afterB.row_version).toBe(beforeB.row_version); }); }); diff --git a/src/replicache/serverPullData.ts b/src/replicache/serverPullData.ts index 30f0a51d..360f0e7c 100644 --- a/src/replicache/serverPullData.ts +++ b/src/replicache/serverPullData.ts @@ -10,13 +10,13 @@ import { buildExtras, buildPullResponse, parseCVRCookie, - type PullFactRow, + type PullFactVersion, } from "./cvr"; import type { CVRStore } from "./cvrStore"; export type PullData = { clients: Record; - facts: PullFactRow[]; + facts: PullFactVersion[]; publications: | { description: string; @@ -30,15 +30,19 @@ export type PullData = { }; // Replicates the pull_data Postgres function (and its get_facts closure walk) -// with one addition: each fact carries a row_version taken from xmin, which +// with two additions: each fact carries a row_version taken from xmin, which // bumps on every insert/update and so lets the CVR pull diff without any -// schema changes. Runs as a single repeatable-read snapshot to match the +// schema changes; and the caller's base CVR fact versions are passed into the +// query, so full rows only leave the database for facts that are new or +// changed against that base (pass null to hydrate everything, e.g. for a full +// snapshot). Runs as a single repeatable-read snapshot to match the // consistency of the original single-statement RPC — in particular a // mutation's lastMutationID is never visible without its fact writes. export async function fetchPullData( db: NodePgDatabase, token_id: string, client_group_id: string, + baseFactVersions: Record | null, ): Promise { return await db.transaction( async (tx) => { @@ -65,14 +69,27 @@ export async function fetchPullData( 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(${JSON.stringify(baseFactVersions ?? {})}::jsonb) ) - SELECT row_to_json(af)::jsonb - 'row_version' AS fact, af.row_version - FROM all_facts af`, + SELECT af.id, af.row_version, + CASE WHEN b.row_version IS NULL OR b.row_version <> af.row_version + THEN row_to_json(af)::jsonb - 'row_version' + END AS fact + FROM all_facts af + LEFT JOIN base b ON b.id = af.id::text`, ) - ).rows as { fact: PullFactRow["fact"]; row_version: number | string }[]; - let facts: PullFactRow[] = factRows.map((row) => ({ - fact: row.fact, + ).rows as { + id: string; + row_version: number | string; + fact: NonNullable | null; + }[]; + let facts: PullFactVersion[] = factRows.map((row) => ({ + id: row.id, row_version: Number(row.row_version), + fact: row.fact, })); let publications = ( @@ -121,33 +138,37 @@ export async function computePull( ): Promise { let cookie = parseCVRCookie(pullRequest.cookie); let storeKey = `${token_id}:${pullRequest.clientGroupID}`; - let baseCVR; - let data: PullData; try { - // Stored CVRs are immutable once written (keyed by cvrID), so reading one - // concurrently with the snapshot query is race-free. - [baseCVR, data] = await Promise.all([ - cookie ? store.get(storeKey, cookie.cvrID) : null, - fetchPullData(db, token_id, pullRequest.clientGroupID), - ]); + // The base CVR must resolve before the snapshot 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( + db, + 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. Preserve that rather than surfacing a 500 the puller would - // hand to Replicache as a garbage response. + // 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" }; } - 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; } From 27ba000e0933b04c65f7add16e8e28bc456f91d0 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Sun, 19 Jul 2026 22:11:21 -0400 Subject: [PATCH 3/7] Move the CVR pull query into a stored function called via PostgREST MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fetchPullData ran inline SQL over the shared pg pool, which put pull — the highest-QPS RPC — on direct database connections and needed an explicit repeatable-read transaction to keep its reads on one snapshot. The new pull_data_cvr(token_id, client_group_id, base_cvr) function is a single SQL statement, so every read shares a snapshot for free, and the handler reaches it through the existing supabase client: one stateless PostgREST round trip and no pool connections held by pulls. The base-CVR hydration contract is unchanged — full fact rows only leave the database for facts new or changed against base_cvr. The pull_data_cvr entry in database.types.ts is hand-added because the local database is behind the committed migrations; a future generate-db-types run against a migrated database produces the same. Integration tests now exercise the real PostgREST path with the local supabase demo credentials, keeping the direct pg connection only for seeding and for row-for-row parity checks against the legacy pull_data function. Co-Authored-By: Claude Fable 5 --- app/api/rpc/[command]/pull.ts | 7 +- .../serverPullData.integration.test.ts | 69 +++++---- src/replicache/serverPullData.ts | 141 ++++++------------ supabase/database.types.ts | 8 + .../20260719000000_add_pull_data_cvr.sql | 65 ++++++++ 5 files changed, 160 insertions(+), 130 deletions(-) create mode 100644 supabase/migrations/20260719000000_add_pull_data_cvr.sql diff --git a/app/api/rpc/[command]/pull.ts b/app/api/rpc/[command]/pull.ts index 9cb60163..6b284e8b 100644 --- a/app/api/rpc/[command]/pull.ts +++ b/app/api/rpc/[command]/pull.ts @@ -1,8 +1,6 @@ import { z } from "zod"; import { VersionNotSupportedResponse } from "replicache"; -import { drizzle } from "drizzle-orm/node-postgres"; import Client from "ioredis"; -import { pool } from "supabase/pool"; import { computePull } from "src/replicache/serverPullData"; import { makeCVRStore } from "src/replicache/cvrStore"; import { makeRoute } from "../lib"; @@ -43,7 +41,6 @@ const pullRequestV1 = z.object({ // Combined PullRequest type const PullRequestSchema = z.union([pullRequestV0, pullRequestV1]); -const db = drizzle(pool); // Without Redis (dev) every CVR lookup misses and pulls degrade to full // snapshots — the pre-CVR behavior. const cvrStore = makeCVRStore( @@ -55,10 +52,10 @@ const cvrStore = makeCVRStore( export const pull = makeRoute({ route: "pull", input: z.object({ pullRequest: PullRequestSchema, token_id: z.string() }), - handler: async ({ pullRequest, token_id }, _env: Env) => { + handler: async ({ pullRequest, token_id }, { supabase }: Env) => { let body = pullRequest; if (body.pullVersion === 0) return versionNotSupported; - return await computePull(db, cvrStore, body, token_id, Date.now()); + return await computePull(supabase, cvrStore, body, token_id, Date.now()); }, }); diff --git a/src/replicache/serverPullData.integration.test.ts b/src/replicache/serverPullData.integration.test.ts index 5b6444f3..ed066d01 100644 --- a/src/replicache/serverPullData.integration.test.ts +++ b/src/replicache/serverPullData.integration.test.ts @@ -1,6 +1,7 @@ import { afterAll, describe, expect, test } from "vitest"; import { Pool } from "pg"; -import { drizzle } from "drizzle-orm/node-postgres"; +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"; @@ -20,18 +21,30 @@ let fakeRedis: RedisLike = { }; let store = makeCVRStore(fakeRedis); -// These tests run against the local supabase database (which must be migrated -// and running: `supabase start`). They never read .env.local so they can't -// accidentally point at production. +// 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 db = drizzle(pool); +let supabase = createClient( + TEST_SUPABASE_URL, + TEST_SUPABASE_SERVICE_KEY, +); let dbAvailable = await pool - .query("select 1 from pg_proc where proname = 'pull_data'") + .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) @@ -199,7 +212,7 @@ describe.runIf(dbAvailable)("fetchPullData parity with pull_data RPC", () => { ); let naive = await naivePullData(token, clientGroup); - let mine = await fetchPullData(db, token, clientGroup, null); + let mine = await fetchPullData(supabase, token, clientGroup, null); // facts: same rows, same JSON shapes expect(sortById(mine.facts.map((f) => f.fact!))).toEqual( @@ -228,7 +241,7 @@ describe.runIf(dbAvailable)("fetchPullData parity with pull_data RPC", () => { // 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(db, token, "no-such-group", null); + 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); @@ -237,7 +250,7 @@ describe.runIf(dbAvailable)("fetchPullData parity with pull_data RPC", () => { // valid-uuid but nonexistent token let ghost = randomUUID(); let naiveGhost = await naivePullData(ghost, "no-such-group"); - let mineGhost = await fetchPullData(db, ghost, "no-such-group", null); + let mineGhost = await fetchPullData(supabase, ghost, "no-such-group", null); expect(mineGhost.facts).toEqual([]); expect(naiveGhost.facts).toBeNull(); expect(mineGhost.publications).toBeNull(); @@ -245,7 +258,7 @@ describe.runIf(dbAvailable)("fetchPullData parity with pull_data RPC", () => { // malformed token id errors in both await expect(naivePullData("not-a-uuid", "g")).rejects.toThrow(); - await expect(fetchPullData(db, "not-a-uuid", "g", null)).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 () => { @@ -253,12 +266,12 @@ describe.runIf(dbAvailable)("fetchPullData parity with pull_data RPC", () => { 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(db, token, "g", null); + 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(db, token, "g", base); + let unchanged = await fetchPullData(supabase, token, "g", base); expect(unchanged.facts).toHaveLength(2); expect(unchanged.facts.every((f) => f.fact === null)).toBe(true); expect( @@ -270,14 +283,14 @@ describe.runIf(dbAvailable)("fetchPullData parity with pull_data RPC", () => { factA, JSON.stringify({ type: "text", value: "a2" }), ]); - let partial = await fetchPullData(db, token, "g", base); + 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(db, token, "g", base); + let withNew = await fetchPullData(supabase, token, "g", base); expect(withNew.facts.find((f) => f.id === factC)!.fact).not.toBeNull(); }); @@ -292,7 +305,7 @@ describe.runIf(dbAvailable)("fetchPullData parity with pull_data RPC", () => { value: "b", }); - let before = await fetchPullData(db, token, "g", null); + 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)!; @@ -301,7 +314,7 @@ describe.runIf(dbAvailable)("fetchPullData parity with pull_data RPC", () => { JSON.stringify({ type: "text", value: "a2" }), ]); - let after = await fetchPullData(db, token, "g", null); + 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)!; @@ -330,7 +343,7 @@ describe.runIf(dbAvailable)("computePull end-to-end", () => { // --- first pull: legacy-style full snapshot let kv = new Map(); let first = await computePull( - db, + supabase, store, { cookie: null, clientGroupID: clientGroup }, token, @@ -351,7 +364,7 @@ describe.runIf(dbAvailable)("computePull end-to-end", () => { // --- no-op pull: nothing changed let noop = await computePull( - db, + supabase, store, { cookie: JSON.parse(JSON.stringify(first.cookie)), clientGroupID: clientGroup }, token, @@ -378,7 +391,7 @@ describe.runIf(dbAvailable)("computePull end-to-end", () => { await setClient(clientID, clientGroup, 2); let second = await computePull( - db, + supabase, store, { cookie: noop.cookie, clientGroupID: clientGroup }, token, @@ -402,7 +415,7 @@ describe.runIf(dbAvailable)("computePull end-to-end", () => { // --- delete a fact (which unlinks the child closure too) await pool.query(`delete from facts where id = $1`, [factRoot]); let third = await computePull( - db, + supabase, store, { cookie: second.cookie, clientGroupID: clientGroup }, token, @@ -418,7 +431,7 @@ describe.runIf(dbAvailable)("computePull end-to-end", () => { // final client view must equal a from-scratch snapshot let fresh = await computePull( - db, + supabase, store, { cookie: null, clientGroupID: clientGroup }, token, @@ -433,7 +446,7 @@ describe.runIf(dbAvailable)("computePull end-to-end", () => { test("publication metadata changes flow through incrementally", async () => { let { token } = await createDoc(); let first = await computePull( - db, + supabase, store, { cookie: null, clientGroupID: "g" }, token, @@ -446,7 +459,7 @@ describe.runIf(dbAvailable)("computePull end-to-end", () => { await addPublicationFor(token, "First Title"); let second = await computePull( - db, + supabase, store, { cookie: first.cookie, clientGroupID: "g" }, token, @@ -464,7 +477,7 @@ describe.runIf(dbAvailable)("computePull end-to-end", () => { [token], ); let third = await computePull( - db, + supabase, store, { cookie: second.cookie, clientGroupID: "g" }, token, @@ -481,7 +494,7 @@ describe.runIf(dbAvailable)("computePull end-to-end", () => { await addFact(root, "block/text", { type: "text", value: "hi" }); let legacyCookie = Date.now() - 1000; let res = await computePull( - db, + supabase, store, { cookie: legacyCookie, clientGroupID: "g" }, token, @@ -499,7 +512,7 @@ describe.runIf(dbAvailable)("computePull end-to-end", () => { let { root, token } = await createDoc(); await addFact(root, "block/text", { type: "text", value: "hi" }); let first = await computePull( - db, + supabase, store, { cookie: null, clientGroupID: "g" }, token, @@ -510,7 +523,7 @@ describe.runIf(dbAvailable)("computePull end-to-end", () => { redisMap.delete(slotKey); let res = await computePull( - db, + supabase, store, { cookie: first.cookie, clientGroupID: "g" }, token, @@ -526,7 +539,7 @@ describe.runIf(dbAvailable)("computePull end-to-end", () => { test("malformed token id returns ClientStateNotFound like the legacy handler", async () => { let res = await computePull( - db, + supabase, store, { cookie: null, clientGroupID: "g" }, "not-a-uuid", diff --git a/src/replicache/serverPullData.ts b/src/replicache/serverPullData.ts index 360f0e7c..aa1941f4 100644 --- a/src/replicache/serverPullData.ts +++ b/src/replicache/serverPullData.ts @@ -1,11 +1,11 @@ -import { sql } from "drizzle-orm"; -import type { NodePgDatabase } from "drizzle-orm/node-postgres"; +import { randomUUID } from "node:crypto"; +import type { SupabaseClient } from "@supabase/supabase-js"; import type { ClientStateNotFoundResponse, PullResponseOKV1, ReadonlyJSONValue, } from "replicache"; -import { randomUUID } from "node:crypto"; +import type { Database } from "supabase/database.types"; import { buildExtras, buildPullResponse, @@ -29,100 +29,47 @@ export type PullData = { draft_contributors: string[] | null; }; -// Replicates the pull_data Postgres function (and its get_facts closure walk) -// with two additions: each fact carries a row_version taken from xmin, which -// bumps on every insert/update and so lets the CVR pull diff without any -// schema changes; and the caller's base CVR fact versions are passed into the -// query, so full rows only leave the database for facts that are new or -// changed against that base (pass null to hydrate everything, e.g. for a full -// snapshot). Runs as a single repeatable-read snapshot to match the -// consistency of the original single-statement RPC — in particular a -// mutation's lastMutationID is never visible without its fact writes. +// 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( - db: NodePgDatabase, + supabase: SupabaseClient, token_id: string, client_group_id: string, baseFactVersions: Record | null, ): Promise { - return await db.transaction( - async (tx) => { - let clientRows = ( - await tx.execute( - sql`SELECT client_id, last_mutation FROM replicache_clients WHERE client_group = ${client_group_id}`, - ) - ).rows as { client_id: string; last_mutation: number | string }[]; - let clients: Record = {}; - for (let row of clientRows) - clients[row.client_id] = Number(row.last_mutation); - - let factRows = ( - await tx.execute( - sql`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}::uuid - 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(${JSON.stringify(baseFactVersions ?? {})}::jsonb) - ) - SELECT af.id, af.row_version, - CASE WHEN b.row_version IS NULL OR b.row_version <> af.row_version - THEN row_to_json(af)::jsonb - 'row_version' - END AS fact - FROM all_facts af - LEFT JOIN base b ON b.id = af.id::text`, - ) - ).rows as { - id: string; - row_version: number | string; - fact: NonNullable | null; - }[]; - let facts: PullFactVersion[] = factRows.map((row) => ({ - id: row.id, - row_version: Number(row.row_version), - fact: row.fact, - })); - - let publications = ( - await tx.execute( - sql`SELECT row_to_json(lip) AS pub FROM leaflets_in_publications lip WHERE lip.leaflet = ${token_id}::uuid`, - ) - ).rows as { pub: NonNullable[number] }[]; - if (publications.length === 0) - publications = ( - await tx.execute( - sql`SELECT row_to_json(ltd) AS pub FROM leaflets_to_documents ltd WHERE ltd.leaflet = ${token_id}::uuid`, - ) - ).rows as { pub: NonNullable[number] }[]; - - let contributorRows = ( - await tx.execute( - sql`SELECT contributor_did FROM leaflet_contributors WHERE leaflet = ${token_id}::uuid`, - ) - ).rows as { contributor_did: string }[]; - - return { - clients, - facts, - publications: - publications.length > 0 ? publications.map((row) => row.pub) : null, - draft_contributors: - contributorRows.length > 0 - ? contributorRows.map((row) => row.contributor_did) - : null, - }; - }, - { isolationLevel: "repeatable read", accessMode: "read only" }, - ); + 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 @@ -130,7 +77,7 @@ export async function fetchPullData( // (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( - db: NodePgDatabase, + supabase: SupabaseClient, store: CVRStore, pullRequest: { cookie: unknown; clientGroupID: string }, token_id: string, @@ -139,12 +86,12 @@ export async function computePull( let cookie = parseCVRCookie(pullRequest.cookie); let storeKey = `${token_id}:${pullRequest.clientGroupID}`; try { - // The base CVR must resolve before the snapshot query runs: the query - // takes the base fact versions as an argument and only hydrates rows - // that differ from them. + // 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( - db, + supabase, token_id, pullRequest.clientGroupID, baseCVR?.f ?? null, 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/20260719000000_add_pull_data_cvr.sql b/supabase/migrations/20260719000000_add_pull_data_cvr.sql new file mode 100644 index 00000000..1d9d941d --- /dev/null +++ b/supabase/migrations/20260719000000_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$ +; From 2fd573d47cf34cbb56f32e7095eb04e1c54268bf Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Tue, 21 Jul 2026 15:48:13 -0400 Subject: [PATCH 4/7] add pull tests to ci --- .github/workflows/main.yml | 18 +++++++++- .github/workflows/test.yml | 36 +++++++++++++++++++ .../serverPullData.integration.test.ts | 9 ++++- 3 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 .github/workflows/test.yml diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 4b15be38..1ec638e0 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -18,6 +18,22 @@ jobs: - run: "npm i" - run: "npx tsc" + test: + runs-on: ubuntu-latest + name: vitest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: "npm" + - run: "npm i" + - uses: supabase/setup-cli@v1 + # The integration tests need the migrated db + PostgREST behind kong; + # everything else is dead weight in CI. + - run: supabase start -x studio,imgproxy,realtime,storage-api,edge-runtime,logflare,vector + - run: "npm test" + lexicons: runs-on: ubuntu-latest name: lexicon @@ -33,7 +49,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..78989dc2 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,36 @@ +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" + +concurrency: + group: test-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + runs-on: ubuntu-latest + name: vitest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: "npm" + - run: "npm i" + - uses: supabase/setup-cli@v1 + # The integration tests need the migrated db + PostgREST behind kong; + # everything else is dead weight in CI. + - run: supabase start -x studio,imgproxy,realtime,storage-api,edge-runtime,logflare,vector + - run: "npm test" diff --git a/src/replicache/serverPullData.integration.test.ts b/src/replicache/serverPullData.integration.test.ts index ed066d01..0e42ed42 100644 --- a/src/replicache/serverPullData.integration.test.ts +++ b/src/replicache/serverPullData.integration.test.ts @@ -47,10 +47,17 @@ let dbAvailable = await pool ) .then((r) => r.rowCount === 1) .catch(() => false); -if (!dbAvailable) +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 From 1b7de7b19cefa21a92f8aab19b13f6b406bcd38a Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Tue, 21 Jul 2026 15:56:17 -0400 Subject: [PATCH 5/7] fix migrations that can't run in ci --- .../migrations/20250827115826_updated_reference_index.sql | 2 +- .../20260305100000_fix_profile_posts_performance.sql | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) 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 From 44a1ead20eb5702ce546f95751dfcace6fe20534 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Tue, 21 Jul 2026 16:06:02 -0400 Subject: [PATCH 6/7] bump migration version --- ...add_pull_data_cvr.sql => 20260721000000_add_pull_data_cvr.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename supabase/migrations/{20260719000000_add_pull_data_cvr.sql => 20260721000000_add_pull_data_cvr.sql} (100%) diff --git a/supabase/migrations/20260719000000_add_pull_data_cvr.sql b/supabase/migrations/20260721000000_add_pull_data_cvr.sql similarity index 100% rename from supabase/migrations/20260719000000_add_pull_data_cvr.sql rename to supabase/migrations/20260721000000_add_pull_data_cvr.sql From 5de479864f34ba67667d78090dee4cbd5effc032 Mon Sep 17 00:00:00 2001 From: Jared Pereira Date: Tue, 21 Jul 2026 16:13:05 -0400 Subject: [PATCH 7/7] cache supabase installs in ci --- .github/actions/run-tests/action.yml | 47 ++++++++++++++++++++++++++++ .github/workflows/main.yml | 11 +------ .github/workflows/test.yml | 12 ++----- 3 files changed, 50 insertions(+), 20 deletions(-) create mode 100644 .github/actions/run-tests/action.yml 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 1ec638e0..62a1fff7 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -23,16 +23,7 @@ jobs: name: vitest steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 24 - cache: "npm" - - run: "npm i" - - uses: supabase/setup-cli@v1 - # The integration tests need the migrated db + PostgREST behind kong; - # everything else is dead weight in CI. - - run: supabase start -x studio,imgproxy,realtime,storage-api,edge-runtime,logflare,vector - - run: "npm test" + - uses: ./.github/actions/run-tests lexicons: runs-on: ubuntu-latest diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 78989dc2..6ba2b10c 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -13,6 +13,7 @@ on: - "package.json" - "package-lock.json" - ".github/workflows/test.yml" + - ".github/actions/run-tests/**" concurrency: group: test-${{ github.ref }} @@ -24,13 +25,4 @@ jobs: name: vitest steps: - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: 24 - cache: "npm" - - run: "npm i" - - uses: supabase/setup-cli@v1 - # The integration tests need the migrated db + PostgREST behind kong; - # everything else is dead weight in CI. - - run: supabase start -x studio,imgproxy,realtime,storage-api,edge-runtime,logflare,vector - - run: "npm test" + - uses: ./.github/actions/run-tests