diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 7d56669b..00000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,27 +0,0 @@ -# To get started with Dependabot version updates, you'll need to specify which -# package ecosystems to update and where the package manifests are located. -# Please see the documentation for all configuration options: -# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file - -version: 2 -updates: - - package-ecosystem: "npm" - directories: ["/frontend", "/docs"] - schedule: - interval: "daily" - - - package-ecosystem: "docker" - directories: ["**"] - schedule: - interval: "daily" - - - package-ecosystem: "cargo" - directories: ["**"] - schedule: - interval: "daily" - - # Disable this for security reasons - # - package-ecosystem: "github-actions" - # directories: ["**"] - # schedule: - # interval: "daily" diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml deleted file mode 100644 index fc1b1c99..00000000 --- a/.github/workflows/check.yml +++ /dev/null @@ -1,36 +0,0 @@ -name: Check - -on: - push: - branches: ["main"] - pull_request: - branches: ["main"] - workflow_dispatch: - -env: - CARGO_TERM_COLOR: always - RUSTFLAGS: "-Dwarnings" - -jobs: - build: - runs-on: self-hosted - - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Setup Node.js environment - uses: actions/setup-node@v4.2.0 - with: - node-version: "25.x" - check-latest: true - - - name: Setup Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - toolchain: "1.92.0" - components: clippy, rustfmt - - - name: Lint & test - run: scripts/check.sh diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml deleted file mode 100644 index bb25e463..00000000 --- a/.github/workflows/deploy-docs.yml +++ /dev/null @@ -1,58 +0,0 @@ -name: Deploy Documentation - -on: - push: - branches: - - main - paths: - - "docs/**" - - ".github/workflows/deploy-docs.yml" - workflow_dispatch: - -permissions: - contents: read - pages: write - id-token: write - -concurrency: - group: pages - cancel-in-progress: false - -jobs: - build: - runs-on: self-hosted - - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Setup Node.js environment - uses: actions/setup-node@v4.2.0 - with: - node-version: "25.x" - check-latest: true - - - name: Setup Pages - uses: actions/configure-pages@v4 - - - name: Build docs - run: scripts/build-docs.sh - - - name: Upload artifact - uses: actions/upload-pages-artifact@v3 - with: - path: docs/.vitepress/dist - - deploy: - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} - needs: build - runs-on: self-hosted - name: Deploy - steps: - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v4 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml deleted file mode 100644 index 98dbfc1f..00000000 --- a/.github/workflows/e2e.yml +++ /dev/null @@ -1,72 +0,0 @@ -name: E2E tests - -on: - push: - branches: ["main"] - pull_request: - branches: ["main"] - schedule: - - cron: "0 * * * *" - workflow_dispatch: - -concurrency: - group: e2e-tests - cancel-in-progress: false - -env: - RUSTFLAGS: "-Dwarnings" - -jobs: - build: - runs-on: self-hosted - - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Setup Node.js environment - uses: actions/setup-node@v4.2.0 - with: - node-version: "25.x" - check-latest: true - - - name: Setup Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - toolchain: "1.92.0" - components: clippy, rustfmt - - - name: Setup rust - run: | - which sqlx || cargo install sqlx-cli - cd sync-server - sqlx database create --database-url sqlite://db.sqlite3 - sqlx migrate run --source src/app_state/database/migrations --database-url sqlite://db.sqlite3 - - - name: E2E tests - run: | - cd sync-server - cargo run config-e2e.yml --color never & - SERVER_PID=$! - cd .. - - scripts/e2e.sh 8 - EXIT_CODE=$? - - kill $SERVER_PID 2>/dev/null || true - wait $SERVER_PID 2>/dev/null || true - - exit $EXIT_CODE - - - name: Upload e2e logs - if: always() - uses: actions/upload-artifact@v4 - with: - name: e2e-logs - path: logs/ - retention-days: 30 - - - name: Cleanup - if: always() - run: scripts/clean-up.sh diff --git a/.github/workflows/publish-cli-docker.yml b/.github/workflows/publish-cli-docker.yml deleted file mode 100644 index 10a7e8ba..00000000 --- a/.github/workflows/publish-cli-docker.yml +++ /dev/null @@ -1,67 +0,0 @@ -name: Publish CLI - -on: - push: - branches: ["main"] - tags: ["*"] - pull_request: - branches: ["main"] - -env: - REGISTRY: ghcr.io - IMAGE_NAME: ${{ github.repository }}-cli - -jobs: - publish-docker: - runs-on: self-hosted - - permissions: - contents: read - packages: write - id-token: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Install cosign - uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 #v3.5.0 - with: - cosign-release: "v2.2.4" - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0 - - - name: Log into registry ${{ env.REGISTRY }} - uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract Docker metadata - id: meta - uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - - - name: Build and push Docker image - id: build-and-push - uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0 - with: - context: frontend - file: frontend/local-client-cli/Dockerfile - platforms: linux/amd64 - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - - - name: Sign the published Docker image - env: - TAGS: ${{ steps.meta.outputs.tags }} - DIGEST: ${{ steps.build-and-push.outputs.digest }} - run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST} diff --git a/.github/workflows/publish-plugin.yml b/.github/workflows/publish-plugin.yml deleted file mode 100644 index 452bc601..00000000 --- a/.github/workflows/publish-plugin.yml +++ /dev/null @@ -1,59 +0,0 @@ -name: Publish Obsidian plugin - -on: - push: - tags: ["*"] - -env: - CARGO_TERM_COLOR: always - -jobs: - publish-plugin: - runs-on: self-hosted - - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Setup Node.js environment - uses: actions/setup-node@v4.2.0 - with: - node-version: "25.x" - check-latest: true - - - name: Build plugin - run: | - cd frontend - npm ci - npm run build - - - name: Setup Rust toolchain - uses: dtolnay/rust-toolchain@stable - with: - toolchain: "1.92.0" - components: clippy, rustfmt - - - name: Install cross-compilation tools - run: | - apt update - apt install -y gcc-aarch64-linux-gnu musl-tools gcc-mingw-w64-x86-64 - - - name: Build Linux and Windows binaries - run: ./scripts/build-sync-server-binaries.sh - - - name: Create release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - tag="${GITHUB_REF#refs/tags/}" - - mkdir -p release - cp frontend/obsidian-plugin/dist/* release/ - cp sync-server/artifacts/sync-server-* release/ - cd release - - gh release create "$tag" \ - --title="$tag" \ - --draft \ - * diff --git a/.github/workflows/publish-server-docker.yml b/.github/workflows/publish-server-docker.yml deleted file mode 100644 index 4a97a9e6..00000000 --- a/.github/workflows/publish-server-docker.yml +++ /dev/null @@ -1,92 +0,0 @@ -name: Publish server Docker image - -on: - push: - branches: ["main"] - tags: ["*"] - pull_request: - branches: ["main"] - -env: - # Use docker.io for Docker Hub if empty - REGISTRY: ghcr.io - # github.repository as / - IMAGE_NAME: ${{ github.repository }} - -jobs: - publish-docker: - runs-on: self-hosted - - permissions: - contents: read - packages: write - # This is used to complete the identity challenge - # with sigstore/fulcio. - id-token: write - - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - # Install the cosign tool - # https://github.com/sigstore/cosign-installer - - name: Install cosign - if: github.ref_type == 'tag' - uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 #v3.5.0 - with: - cosign-release: "v2.2.4" - - # Set up BuildKit Docker container builder to be able to build - # multi-platform images and export cache - # https://github.com/docker/setup-buildx-action - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0 - - # Login against a Docker registry - # https://github.com/docker/login-action - - name: Log into registry ${{ env.REGISTRY }} - if: github.ref_type == 'tag' - uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - # Extract metadata (tags, labels) for Docker - # https://github.com/docker/metadata-action - - name: Extract Docker metadata - id: meta - uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - - # Build and push Docker image with Buildx - # https://github.com/docker/build-push-action - - name: Build and push Docker image - id: build-and-push - uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0 - with: - context: sync-server - platforms: linux/amd64,linux/arm64 - push: ${{ github.ref_type == 'tag' }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - cache-from: type=gha - cache-to: type=gha,mode=max - - # Sign the resulting Docker image digest. - # This will only write to the public Rekor transparency log when the Docker - # repository is public to avoid leaking data. If you would like to publish - # transparency data even for private images, pass --force to cosign below. - # https://github.com/sigstore/cosign - - name: Sign the published Docker image - if: ${{ github.ref_type == 'tag' }} - env: - # https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable - TAGS: ${{ steps.meta.outputs.tags }} - DIGEST: ${{ steps.build-and-push.outputs.digest }} - # This step uses the identity token to provision an ephemeral certificate - # against the sigstore community Fulcio instance. - run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST} diff --git a/frontend/deterministic-tests/README.md b/frontend/deterministic-tests/README.md index c422406d..6fa2848c 100644 --- a/frontend/deterministic-tests/README.md +++ b/frontend/deterministic-tests/README.md @@ -10,7 +10,7 @@ Each test is a `TestDefinition`: a client count and an ordered list of steps. Th Tests that don't pause the server share a single server process (vault-name isolation). Tests that use `pause-server`/`resume-server` (SIGSTOP/SIGCONT) each get a dedicated server, since SIGSTOP freezes the entire process. -All tests run in parallel up to a concurrency limit. +The runner executes two sequential phases: regular tests on the shared server, then pause-server tests on dedicated servers. Within each phase tests run in parallel up to a concurrency limit. ## Step types @@ -19,12 +19,15 @@ Clients always start with syncing disabled. **File operations** (per-client, fire-and-forget — sync is enqueued but not awaited): - `create`, `update`, `rename`, `delete` +- `rename-next-write` — arm a deferred rename that fires the next time the given path is written. Lets a test race a user-rename against an in-flight remote create that's about to land at the same path. **Sync control:** - `sync` — wait for a specific client or all clients to finish pending operations - `barrier` — retry until all clients converge to identical file state (60s timeout) - `enable-sync` / `disable-sync` — simulate going online/offline +- `reset` — reset a client's tracked sync state (keeps disk files); equivalent to a forced re-handshake on next enable +- `sleep` — wall-clock pause; use sparingly, prefer `barrier` / `sync` **WebSocket control** (per-client): @@ -33,6 +36,12 @@ Clients always start with syncing disabled. **Server control:** - `pause-server` / `resume-server` — SIGSTOP/SIGCONT the server process +- `resume-server-until-history-then-pause` — resume the server, wait until a specific client observes a matching history entry (`CREATE`/`UPDATE`/`DELETE` for a path), then re-pause. Used to land exactly one operation across the wire. + +**Fault injection** (per-client): + +- `drop-next-create-response` — arm a one-shot interceptor that lets the next `POST /documents` reach the server (commit happens) but throws `SyncResetError` before the client sees the response, simulating connection loss after server commit. +- `wait-for-dropped-create-response` — wait until the armed drop has fired. **Assertions:** @@ -72,7 +81,9 @@ export const myScenarioTest: TestDefinition = { { type: "barrier" }, { type: "assert-consistent", - verify: (s) => s.assertFileCount(1).assertContent("A.md", "hello") + verify: (s) => { + s.assertFileCount(1).assertContent("A.md", "hello"); + } } ] }; @@ -81,14 +92,18 @@ export const myScenarioTest: TestDefinition = { The `verify` callback receives an `AssertableState` object with chainable assertion methods: ```typescript -s.assertFileCount(n) // exact file count -s.assertFileExists("path") // file must exist -s.assertFileNotExists("path") // file must not exist -s.assertContent("path", "expected") // exact content match -s.assertContains("path", "a", "b") // all substrings present -s.assertAnyFileContains("text") // substring in any file -s.assertContentInAtMostOneFile("text") // no duplicate content -s.ifFileExists("path", (s) => ...) // conditional assertion +s.assertFileCount(n); // exact file count +s.assertFileExists("path"); // file must exist +s.assertFileNotExists("path"); // file must not exist +s.assertContent("path", "expected"); // exact content match +s.assertContains("path", "a", "b"); // all substrings present in file +s.assertContainsAny("path", "a", "b"); // at least one substring present +s.assertAnyFileContains("text"); // substring present in some file +s.assertNoFileContains("text"); // substring absent from every file +s.assertSubstringCount("path", "x", 3); // substring appears exactly N times +s.assertContentInAtMostOneFile("text"); // no duplicate content +s.ifFileExists("path", (s) => { /* … */ }); // conditional block +s.getContent("path"); // raw content (or "" if missing) ``` 2. Register it in `src/test-registry.ts`: diff --git a/frontend/deterministic-tests/package.json b/frontend/deterministic-tests/package.json index e1c1b276..4bd82c74 100644 --- a/frontend/deterministic-tests/package.json +++ b/frontend/deterministic-tests/package.json @@ -11,6 +11,7 @@ "test": "npm run build && node dist/cli.js" }, "devDependencies": { + "commander": "^14.0.2", "@types/node": "^25.0.2", "sync-client": "file:../sync-client", "ts-loader": "^9.5.4", diff --git a/frontend/deterministic-tests/src/cli.ts b/frontend/deterministic-tests/src/cli.ts index 6e0e764f..6e15cac0 100644 --- a/frontend/deterministic-tests/src/cli.ts +++ b/frontend/deterministic-tests/src/cli.ts @@ -4,7 +4,7 @@ import { ServerManager } from "./server-manager"; import { PrefixedLogger } from "./prefixed-logger"; import { TESTS } from "./test-registry"; import type { TestDefinition, TestResult } from "./test-definition"; -import { parseConcurrency } from "./parse-concurrency"; +import { parseArgs } from "./parse-args"; import { runWithConcurrency } from "./run-with-concurrency"; import { TOKEN, SERVER_BINARY_PATH, CONFIG_PATH } from "./consts"; import * as path from "node:path"; @@ -29,7 +29,31 @@ serverManager.installSignalHandlers(); function testUsesPauseServer(test: TestDefinition): boolean { return test.steps.some( - (step) => step.type === "pause-server" || step.type === "resume-server" + (step) => + step.type === "pause-server" || + step.type === "resume-server" || + step.type === "resume-server-until-history-then-pause" + ); +} + +/** + * Walk up from the CLI binary's location until we find a directory + * containing `sync-server/` and `frontend/`. + */ +function findProjectRoot(): string { + let dir = path.dirname(__filename); + const root = path.parse(dir).root; + while (dir !== root) { + if ( + fs.existsSync(path.join(dir, "sync-server")) && + fs.existsSync(path.join(dir, "frontend")) + ) { + return dir; + } + dir = path.dirname(dir); + } + throw new Error( + `Could not locate project root (no ancestor of ${__filename} contains both 'sync-server' and 'frontend')` ); } @@ -100,15 +124,7 @@ async function runDedicatedServerTest( } async function main(): Promise { - const cwd = process.cwd(); - let projectRoot = cwd; - - if (cwd.endsWith("frontend/deterministic-tests")) { - projectRoot = path.resolve(cwd, "../.."); - } else if (cwd.endsWith("frontend")) { - projectRoot = path.resolve(cwd, ".."); - } - + const projectRoot = findProjectRoot(); const serverPath = path.join(projectRoot, SERVER_BINARY_PATH); if (!fs.existsSync(serverPath)) { logger.error(`Server binary not found at: ${serverPath}`); @@ -121,8 +137,7 @@ async function main(): Promise { process.exit(1); } - const filterArg = process.argv.find((a) => a.startsWith("--filter=")); - const filter = filterArg?.slice("--filter=".length); + const { filter, concurrency } = parseArgs(process.argv); const testsToRun: [string, TestDefinition][] = []; for (const [key, test] of Object.entries(TESTS)) { @@ -147,7 +162,6 @@ async function main(): Promise { process.exit(1); } - const concurrency = parseConcurrency(); const regularTests = testsToRun.filter(([, t]) => !testUsesPauseServer(t)); const pauseTests = testsToRun.filter(([, t]) => testUsesPauseServer(t)); diff --git a/frontend/deterministic-tests/src/consts.ts b/frontend/deterministic-tests/src/consts.ts index a04c9b61..d9a2498f 100644 --- a/frontend/deterministic-tests/src/consts.ts +++ b/frontend/deterministic-tests/src/consts.ts @@ -11,3 +11,7 @@ export const IS_SYNC_ENABLED_BY_DEFAULT = false; export const WAIT_TIMEOUT_MS = 60_000; export const WEBSOCKET_CONNECT_TIMEOUT_MS = 10_000; export const WEBSOCKET_POLL_INTERVAL_MS = 50; + +export const SERVER_READY_POLL_INTERVAL_MS = 100; +export const SERVER_READY_MAX_ATTEMPTS = 50; +export const SERVER_START_MAX_ATTEMPTS = 5; diff --git a/frontend/deterministic-tests/src/deterministic-agent.ts b/frontend/deterministic-tests/src/deterministic-agent.ts index 74ec2b8d..b32b01c2 100644 --- a/frontend/deterministic-tests/src/deterministic-agent.ts +++ b/frontend/deterministic-tests/src/deterministic-agent.ts @@ -37,15 +37,15 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem { private readonly wsFactory = new ManagedWebSocketFactory(); private nextWriteRename: | { - oldPath: RelativePath; - newPath: RelativePath; - } + oldPath: RelativePath; + newPath: RelativePath; + } | undefined; private nextCreateResponseDrop: | { - dropped: Promise; - resolveDropped: () => void; - } + dropped: Promise; + resolveDropped: () => void; + } | undefined; public constructor( @@ -82,10 +82,10 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem { this.logger(`${prefix} WARN: ${line.message}`); break; case LogLevel.INFO: - this.logger(`${prefix} ${line.message}`); + this.logger(`${prefix} INFO: ${line.message}`); break; case LogLevel.DEBUG: - // Skip debug logs to reduce noise + this.logger(`${prefix} DEBUG: ${line.message}`); break; } }); @@ -271,8 +271,18 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem { this.log(`Cleanup waitUntilFinished failed: ${error}`); } } + // Surface any background sync errors that arrived after the last + // waitForSync (e.g. between the final assert-consistent and here). + // Without this, regressions that fault the engine during the very + // last step of a test would be silently swallowed. + const pendingErrors = this.syncErrors.splice(0); await this.client.destroy(); this.log("Cleanup complete"); + if (pendingErrors.length > 0) { + throw new Error( + `Client ${this.clientId} had ${pendingErrors.length} background sync error(s) during cleanup:\n${pendingErrors.map((e) => e.message).join("\n")}` + ); + } } public override async read(path: RelativePath): Promise { @@ -312,6 +322,10 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem { }); }); } + // The rename consumed `path`. Skip the post-update enqueue below + // — it would send a syncLocallyUpdatedFile for a path that no + // longer exists. + return; } if (!this.isSyncEnabled) { @@ -435,6 +449,11 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem { DeterministicAgent.isCreateDocumentRequest(input, init) ) { this.nextCreateResponseDrop = undefined; + try { + await response.body?.cancel(); + } catch { + // Best-effort — body may already be consumed/closed. + } drop.resolveDropped(); throw new SyncResetError(); } diff --git a/frontend/deterministic-tests/src/managed-websocket.ts b/frontend/deterministic-tests/src/managed-websocket.ts index 421561fd..c759891b 100644 --- a/frontend/deterministic-tests/src/managed-websocket.ts +++ b/frontend/deterministic-tests/src/managed-websocket.ts @@ -139,11 +139,21 @@ class ManagedWebSocket implements WebSocket { } public resume(): void { - this.paused = false; - const messages = this.bufferedMessages.splice(0); - for (const msg of messages) { - this.externalOnMessage?.(msg); + // Drain buffered messages BEFORE flipping `paused` to false. + // If `externalOnMessage` is async (its return type is `unknown`), + // dispatch yields control between buffered messages, and a fresh + // live `ws.onmessage` event firing during that yield would jump + // ahead of unprocessed buffered messages — silently reordering + // events relative to the wire. Keeping `paused = true` during the + // drain forces the live handler to keep buffering, so we splice + // those late arrivals onto the tail and dispatch them in order. + while (this.bufferedMessages.length > 0) { + const messages = this.bufferedMessages.splice(0); + for (const msg of messages) { + this.externalOnMessage?.(msg); + } } + this.paused = false; } public send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void { @@ -157,6 +167,17 @@ class ManagedWebSocket implements WebSocket { public addEventListener( ...args: Parameters ): void { + // Only the `.onmessage` setter routes through the pause buffer. + // If sync-client ever attaches "message" listeners via + // addEventListener instead, those messages would bypass pause/resume + // and deterministic tests would silently lose their fault injection. + if (args[0] === "message") { + throw new Error( + "ManagedWebSocket: addEventListener('message') bypasses the " + + "pause buffer. Use the .onmessage setter instead, or " + + "extend ManagedWebSocket to route message listeners." + ); + } this.ws.addEventListener(...args); } @@ -176,6 +197,11 @@ class ManagedWebSocket implements WebSocket { * for pause/resume control from the test harness */ export class ManagedWebSocketFactory { + // Append-only: closed sockets stay tracked. Bounded per test (one + // factory per agent, each test discards its agents on cleanup), so + // not a real leak — but iterating over closed instances on + // pause/resume is a deliberate no-op since their `.onmessage` is + // already detached. private readonly instances: ManagedWebSocket[] = []; // Sticky pause state: applied to current instances on `pause()` AND // to any new instance created later (e.g. WS reconnect after a diff --git a/frontend/deterministic-tests/src/parse-args.ts b/frontend/deterministic-tests/src/parse-args.ts new file mode 100644 index 00000000..11c56f19 --- /dev/null +++ b/frontend/deterministic-tests/src/parse-args.ts @@ -0,0 +1,43 @@ +import * as os from "node:os"; +import { Command, InvalidArgumentError } from "commander"; + +export interface CliArgs { + filter: string | undefined; + concurrency: number; +} + +function parsePositiveInt(value: string): number { + const n = parseInt(value, 10); + if (isNaN(n) || n <= 0) { + throw new InvalidArgumentError("must be a positive integer"); + } + return n; +} + +export function parseArgs(argv: string[]): CliArgs { + const program = new Command(); + + program + .name("deterministic-tests") + .description("Scripted multi-client sync tests against a real server") + .option( + "-f, --filter ", + "Run only tests whose name contains this substring" + ) + .option( + "-j, --concurrency ", + "Number of tests to run in parallel", + parsePositiveInt, + os.cpus().length + ); + + program.parse(argv); + + /* eslint-disable @typescript-eslint/no-unsafe-type-assertion */ + const opts = program.opts(); + const filter = opts.filter as string | undefined; + const concurrency = opts.concurrency as number; + /* eslint-enable @typescript-eslint/no-unsafe-type-assertion */ + + return { filter, concurrency }; +} diff --git a/frontend/deterministic-tests/src/parse-concurrency.ts b/frontend/deterministic-tests/src/parse-concurrency.ts deleted file mode 100644 index f926d1fa..00000000 --- a/frontend/deterministic-tests/src/parse-concurrency.ts +++ /dev/null @@ -1,17 +0,0 @@ -import * as os from "node:os"; - -export function parseConcurrency(): number { - const args = process.argv.slice(2); - for (let i = 0; i < args.length; i++) { - if ( - (args[i] === "--concurrency" || args[i] === "-j") && - i + 1 < args.length - ) { - const n = parseInt(args[i + 1], 10); - if (!isNaN(n) && n > 0) { - return n; - } - } - } - return os.cpus().length; -} diff --git a/frontend/deterministic-tests/src/server-control.ts b/frontend/deterministic-tests/src/server-control.ts index f903cc4c..9cb4cde0 100644 --- a/frontend/deterministic-tests/src/server-control.ts +++ b/frontend/deterministic-tests/src/server-control.ts @@ -5,7 +5,12 @@ import * as path from "node:path"; import { sleep } from "./utils/sleep"; import { findFreePort } from "./utils/find-free-port"; import type { Logger } from "sync-client"; -import { STOP_TIMEOUT_MS } from "./consts"; +import { + STOP_TIMEOUT_MS, + SERVER_READY_POLL_INTERVAL_MS, + SERVER_READY_MAX_ATTEMPTS, + SERVER_START_MAX_ATTEMPTS +} from "./consts"; export class ServerControl { private process: ChildProcess | null = null; @@ -38,10 +43,32 @@ export class ServerControl { throw new Error("Server is already running"); } + // Retry on bind failure: findFreePort closes its probe before we + // spawn, so under heavy parallelism another process can grab the + // same port. Each attempt picks a fresh port. + let lastError: unknown; + for (let attempt = 1; attempt <= SERVER_START_MAX_ATTEMPTS; attempt++) { + try { + await this.startOnce(); + return; + } catch (error) { + lastError = error; + this.logger.warn( + `Server start attempt ${attempt}/${SERVER_START_MAX_ATTEMPTS} failed: ${error instanceof Error ? error.message : String(error)}` + ); + // startOnce already cleaned up its child + tempdir on failure. + } + } + throw new Error( + `Server failed to start after ${SERVER_START_MAX_ATTEMPTS} attempts: ${lastError instanceof Error ? lastError.message : String(lastError)}`, + { cause: lastError instanceof Error ? lastError : undefined } + ); + } + + private async startOnce(): Promise { const reservation = await findFreePort(); this._port = reservation.port; - // Prefer tmpfs (/host/tmp) over disk-backed /tmp for faster SQLite I/O - const tmpBase = fs.existsSync("/host/tmp") ? "/host/tmp" : os.tmpdir(); + const tmpBase = os.tmpdir(); this.tempDir = fs.mkdtempSync(path.join(tmpBase, "vault-link-test-")); const tempConfigPath = path.join(this.tempDir, "config.yml"); const dbDir = path.join(this.tempDir, "databases"); @@ -101,7 +128,9 @@ export class ServerControl { } } - public async waitForReady(maxAttempts = 50): Promise { + public async waitForReady( + maxAttempts: number = SERVER_READY_MAX_ATTEMPTS + ): Promise { const pingUrl = `${this.remoteUri}/vaults/test/ping`; for (let i = 0; i < maxAttempts; i++) { if (this.process?.exitCode !== null) { @@ -118,7 +147,7 @@ export class ServerControl { } catch { // Server not ready yet, continue polling } - await sleep(100); + await sleep(SERVER_READY_POLL_INTERVAL_MS); } throw new Error("Server failed to start within timeout"); } @@ -208,10 +237,42 @@ export class ServerControl { } public isRunning(): boolean { - return this.process?.pid !== undefined; + const proc = this.process; + return ( + proc !== null && + proc.pid !== undefined && + proc.exitCode === null && + proc.signalCode === null + ); + } + + /** + * Synchronously SIGCONT-then-SIGKILL the child process. Safe to call + * from a `process.on("exit", ...)` handler, where async work cannot + * run. Used as a last-resort cleanup so a SIGSTOP'd server doesn't + * outlive the test runner and wedge the next CI invocation. + */ + public forceKillSync(): void { + const proc = this.process; + if (proc?.pid === undefined) { + return; + } + try { + process.kill(proc.pid, "SIGCONT"); + } catch { + // Process may already be gone or never paused. + } + try { + process.kill(proc.pid, "SIGKILL"); + } catch { + // Process already gone. + } } private writeConfigFile(destPath: string, dbDir: string): void { + // Assumes config-e2e.yml has exactly one 2-space-indented `port:` and + // one `databases_directory_path:` (under `server:` and `database:` + // respectively) const baseConfig = fs.readFileSync(this.baseConfigPath, "utf-8"); const config = baseConfig .replace(/^\s*port:\s*\d+/m, ` port: ${this._port}`) diff --git a/frontend/deterministic-tests/src/server-manager.ts b/frontend/deterministic-tests/src/server-manager.ts index a9697eb0..76c624f7 100644 --- a/frontend/deterministic-tests/src/server-manager.ts +++ b/frontend/deterministic-tests/src/server-manager.ts @@ -55,5 +55,17 @@ export class ServerManager { }) .then(() => process.exit(143)); }); + + // Last-resort synchronous cleanup. Runs even when the process is + // exiting via process.exit() from unhandledRejection / + // uncaughtException — paths where async stopAll() cannot complete. + // SIGSTOP'd servers MUST receive SIGCONT before SIGKILL or the + // kernel keeps them as zombies holding the test's tmpdir, and the + // next CI run can't reuse the port. + process.on("exit", () => { + for (const server of this.activeServers) { + server.forceKillSync(); + } + }); } } diff --git a/frontend/deterministic-tests/src/test-registry.ts b/frontend/deterministic-tests/src/test-registry.ts index ed4fe026..1a07b411 100644 --- a/frontend/deterministic-tests/src/test-registry.ts +++ b/frontend/deterministic-tests/src/test-registry.ts @@ -33,10 +33,9 @@ import { offlineDeleteVsRemoteUpdateTest } from "./tests/offline-delete-vs-remot import { doubleOfflineCycleTest } from "./tests/double-offline-cycle.test"; import { serverPauseRenameEditResumeTest } from "./tests/server-pause-rename-edit-resume.test"; import { offlineUpdateBothThenDeleteOneTest } from "./tests/offline-update-both-then-delete-one.test"; -import { offlineCreateSamePathMergeableTest } from "./tests/offline-create-same-path-binary-conflict.test"; +import { offlineCreateSamePathMergeableTest } from "./tests/offline-create-same-path-mergeable.test"; import { deleteDuringPendingCreateTest } from "./tests/delete-during-pending-create.test"; import { threeClientRenameCreateDeleteTest } from "./tests/three-client-rename-create-delete.test"; -import { keyMigrationEventDropTest } from "./tests/key-migration-event-drop.test"; import { renameToPathOfUnconfirmedDeleteTest } from "./tests/rename-to-path-of-unconfirmed-delete.test"; import { offlineEditThenMoveSameContentTest } from "./tests/offline-edit-then-move-same-content.test"; import { rapidCreateUpdateDeleteCycleTest } from "./tests/rapid-create-update-delete-cycle.test"; @@ -47,10 +46,9 @@ import { offlineMoveThenRemoteDeleteTest } from "./tests/offline-move-then-remot import { resetClearsRecentlyDeletedResurrectionTest } from "./tests/reset-clears-recently-deleted-resurrection.test"; import { moveThenDeleteStalePathTest } from "./tests/move-then-delete-stale-path.test"; import { interruptedDeleteRetryTest } from "./tests/interrupted-delete-retry.test"; -import { updateDoesNotSurvivesRemoteDeleteTest } from "./tests/update-survives-remote-delete.test"; +import { updateDoesNotSurviveRemoteDeleteTest } from "./tests/update-does-not-survive-remote-delete.test"; import { movePreservesRemoteUpdateTest } from "./tests/move-preserves-remote-update.test"; import { recentlyDeletedClearedOnReconnectTest } from "./tests/recently-deleted-cleared-on-reconnect.test"; -import { migrateKeyPreservesExistingTest } from "./tests/migrate-key-preserves-existing.test"; import { watermarkAdvancesOnSkipTest } from "./tests/watermark-advances-on-skip.test"; import { watermarkGapRemoteUpdateNotRecordedTest } from "./tests/watermark-gap-remote-update-not-recorded.test"; import { queueResetLosesCoalescedLocalEditTest } from "./tests/queue-reset-loses-coalesced-local-edit.test"; @@ -62,25 +60,25 @@ import { createRenameResponseSkipsFileTest } from "./tests/create-rename-respons import { onlineCreateRenameConcurrentCreateOrphanTest } from "./tests/online-create-rename-concurrent-create-orphan.test"; import { concurrentRenameFirstWinsTest } from "./tests/concurrent-rename-first-wins.test"; import { binaryToTextTransitionTest } from "./tests/binary-to-text-transition.test"; -import { textPendingCreateNotDisplacedTest } from "./tests/1-text-pending-create-not-displaced.test"; -import { binaryPendingCreateNotDisplacedTest } from "./tests/2-binary-pending-create-not-displaced.test"; -import { coalesceUpdateRemoteUpdateDataLossTest } from "./tests/3-coalesce-update-remote-update-data-loss.test"; -import { coalescedRemoteUpdateWatermarkLossTest } from "./tests/4-coalesced-remote-update-watermark-loss.test"; -import { concurrentDeleteDuringRemoteUpdateTest } from "./tests/5-concurrent-delete-during-remote-update.test"; -import { concurrentEditExactSamePositionTest } from "./tests/6-concurrent-edit-exact-same-position.test"; -import { concurrentRenameAndCreateAtTargetTest as concurrentRenameAndCreateAtTargetRenameFirstTest } from "./tests/7-concurrent-rename-and-create-at-target.test"; -import { concurrentRenameAndCreateAtTargetTest as concurrentRenameAndCreateAtTargetCreateFirstTest } from "./tests/8-concurrent-rename-and-create-at-target.test"; -import { concurrentRenameSameTargetTest } from "./tests/9-concurrent-rename-same-target.test"; -import { concurrentUpdateDiffConsistencyTest } from "./tests/10-concurrent-update-diff-consistency.test"; -import { userParenthesizedFileNotDeletedTest } from "./tests/10-user-parenthesized-file-not-deleted.test"; -import { createDeleteNoopTest } from "./tests/11-create-delete-noop.test"; -import { createMergeDeleteTest } from "./tests/12-create-merge-delete.test"; -import { moveIdenticalContentAmbiguityTest } from "./tests/13-move-identical-content-ambiguity.test"; -import { createUpdateCoalesceServerPauseTest } from "./tests/15-create-update-coalesce-server-pause.test"; -import { createDuringReconciliationTest } from "./tests/16-create-during-reconciliation.test"; -import { createMergePreservesRenamedUpdateTest } from "./tests/17-create-merge-preserves-renamed-update.test"; -import { createRenameCreateSamePathTest } from "./tests/18-create-rename-create-same-path.test"; -import { moveChainThreeFilesTest } from "./tests/19-move-chain-three-files.test"; +import { textPendingCreateNotDisplacedTest } from "./tests/text-pending-create-not-displaced.test"; +import { binaryPendingCreateNotDisplacedTest } from "./tests/binary-pending-create-not-displaced.test"; +import { coalesceUpdateRemoteUpdateDataLossTest } from "./tests/coalesce-update-remote-update-data-loss.test"; +import { coalescedRemoteUpdateWatermarkLossTest } from "./tests/coalesced-remote-update-watermark-loss.test"; +import { concurrentDeleteDuringRemoteUpdateTest } from "./tests/concurrent-delete-during-remote-update.test"; +import { concurrentEditExactSamePositionTest } from "./tests/concurrent-edit-exact-same-position.test"; +import { concurrentRenameAndCreateAtTargetRenameFirstTest } from "./tests/concurrent-rename-and-create-at-target-rename-first.test"; +import { concurrentRenameAndCreateAtTargetCreateFirstTest } from "./tests/concurrent-rename-and-create-at-target-create-first.test"; +import { concurrentRenameSameTargetTest } from "./tests/concurrent-rename-same-target.test"; +import { concurrentUpdateDiffConsistencyTest } from "./tests/concurrent-update-diff-consistency.test"; +import { userParenthesizedFileNotDeletedTest } from "./tests/user-parenthesized-file-not-deleted.test"; +import { createDeleteNoopTest } from "./tests/create-delete-noop.test"; +import { createMergeDeleteTest } from "./tests/create-merge-delete.test"; +import { moveIdenticalContentAmbiguityTest } from "./tests/move-identical-content-ambiguity.test"; +import { createUpdateCoalesceServerPauseTest } from "./tests/create-update-coalesce-server-pause.test"; +import { createDuringReconciliationTest } from "./tests/create-during-reconciliation.test"; +import { createMergePreservesRenamedUpdateTest } from "./tests/create-merge-preserves-renamed-update.test"; +import { createRenameCreateSamePathTest } from "./tests/create-rename-create-same-path.test"; +import { moveChainThreeFilesTest } from "./tests/move-chain-three-files.test"; import { deleteByOtherClientThenRecreateTest } from "./tests/delete-by-other-client-then-recreate.test"; import { onlineDeleteRecreateRapidCycleTest } from "./tests/online-delete-recreate-rapid-cycle.test"; import { onlineEditVsDeleteConvergenceTest } from "./tests/online-edit-vs-delete-convergence.test"; @@ -147,7 +145,6 @@ export const TESTS: Partial> = { "offline-create-same-path-mergeable": offlineCreateSamePathMergeableTest, "delete-during-pending-create": deleteDuringPendingCreateTest, "three-client-rename-create-delete": threeClientRenameCreateDeleteTest, - "key-migration-event-drop": keyMigrationEventDropTest, "rename-to-path-of-unconfirmed-delete": renameToPathOfUnconfirmedDeleteTest, "offline-edit-then-move-same-content": offlineEditThenMoveSameContentTest, "rapid-create-update-delete-cycle": rapidCreateUpdateDeleteCycleTest, @@ -160,11 +157,10 @@ export const TESTS: Partial> = { "move-then-delete-stale-path": moveThenDeleteStalePathTest, "offline-delete-vs-remote-update": offlineDeleteVsRemoteUpdateTest, "interrupted-delete-retry": interruptedDeleteRetryTest, - "update-survives-remote-delete": updateDoesNotSurvivesRemoteDeleteTest, + "update-does-not-survive-remote-delete": updateDoesNotSurviveRemoteDeleteTest, "move-preserves-remote-update": movePreservesRemoteUpdateTest, "recently-deleted-cleared-on-reconnect": recentlyDeletedClearedOnReconnectTest, - "migrate-key-preserves-existing": migrateKeyPreservesExistingTest, "watermark-advances-on-skip": watermarkAdvancesOnSkipTest, "watermark-gap-remote-update-not-recorded": watermarkGapRemoteUpdateNotRecordedTest, diff --git a/frontend/deterministic-tests/src/test-runner.ts b/frontend/deterministic-tests/src/test-runner.ts index c8cbadd0..411e9b08 100644 --- a/frontend/deterministic-tests/src/test-runner.ts +++ b/frontend/deterministic-tests/src/test-runner.ts @@ -266,18 +266,10 @@ export class TestRunner { } } - // Final attempt — let the error propagate - await this.waitAllAgentsSettled(); - - try { - await this.assertConsistent(); - this.logger.info("Barrier complete: all clients converged"); - } catch (error) { - throw new Error( - `Convergence timed out after ${CONVERGENCE_TIMEOUT_MS}ms: ${error instanceof Error ? error.message : String(error)}`, - { cause: lastError } - ); - } + throw new Error( + `Convergence timed out after ${CONVERGENCE_TIMEOUT_MS}ms: ${lastError?.message ?? "no consistency check ran"}`, + { cause: lastError } + ); } /** diff --git a/frontend/deterministic-tests/src/tests/2-binary-pending-create-not-displaced.test.ts b/frontend/deterministic-tests/src/tests/binary-pending-create-not-displaced.test.ts similarity index 100% rename from frontend/deterministic-tests/src/tests/2-binary-pending-create-not-displaced.test.ts rename to frontend/deterministic-tests/src/tests/binary-pending-create-not-displaced.test.ts diff --git a/frontend/deterministic-tests/src/tests/3-coalesce-update-remote-update-data-loss.test.ts b/frontend/deterministic-tests/src/tests/coalesce-update-remote-update-data-loss.test.ts similarity index 70% rename from frontend/deterministic-tests/src/tests/3-coalesce-update-remote-update-data-loss.test.ts rename to frontend/deterministic-tests/src/tests/coalesce-update-remote-update-data-loss.test.ts index 69a5ff10..1972526a 100644 --- a/frontend/deterministic-tests/src/tests/3-coalesce-update-remote-update-data-loss.test.ts +++ b/frontend/deterministic-tests/src/tests/coalesce-update-remote-update-data-loss.test.ts @@ -3,8 +3,14 @@ import type { TestDefinition } from "../test-definition"; export const coalesceUpdateRemoteUpdateDataLossTest: TestDefinition = { description: - "Client 0 edits a file while client 1 is offline. Client 1 reconnects " + - "and immediately edits the same file. Both edits should be preserved.", + "Divergent offline edits with text-merge expectation. Client 0's " + + "remote update fully lands before Client 1 reconnects (`sync`-after " + + "the c0 update enforces this), so Client 1's offline edit merges " + + "against a server-known version, not a coalesced batch. Both " + + "additions must survive in the final merged content. (Filename's " + + "'coalesce' framing is aspirational — a true update-coalesce test " + + "would skip the c0 sync and queue overlapping local + remote " + + "updates against the same parent version.)", clients: 2, steps: [ { diff --git a/frontend/deterministic-tests/src/tests/4-coalesced-remote-update-watermark-loss.test.ts b/frontend/deterministic-tests/src/tests/coalesced-remote-update-watermark-loss.test.ts similarity index 100% rename from frontend/deterministic-tests/src/tests/4-coalesced-remote-update-watermark-loss.test.ts rename to frontend/deterministic-tests/src/tests/coalesced-remote-update-watermark-loss.test.ts diff --git a/frontend/deterministic-tests/src/tests/5-concurrent-delete-during-remote-update.test.ts b/frontend/deterministic-tests/src/tests/concurrent-delete-during-remote-update.test.ts similarity index 100% rename from frontend/deterministic-tests/src/tests/5-concurrent-delete-during-remote-update.test.ts rename to frontend/deterministic-tests/src/tests/concurrent-delete-during-remote-update.test.ts diff --git a/frontend/deterministic-tests/src/tests/6-concurrent-edit-exact-same-position.test.ts b/frontend/deterministic-tests/src/tests/concurrent-edit-exact-same-position.test.ts similarity index 100% rename from frontend/deterministic-tests/src/tests/6-concurrent-edit-exact-same-position.test.ts rename to frontend/deterministic-tests/src/tests/concurrent-edit-exact-same-position.test.ts diff --git a/frontend/deterministic-tests/src/tests/8-concurrent-rename-and-create-at-target.test.ts b/frontend/deterministic-tests/src/tests/concurrent-rename-and-create-at-target-create-first.test.ts similarity index 94% rename from frontend/deterministic-tests/src/tests/8-concurrent-rename-and-create-at-target.test.ts rename to frontend/deterministic-tests/src/tests/concurrent-rename-and-create-at-target-create-first.test.ts index a6f34102..cd8046ce 100644 --- a/frontend/deterministic-tests/src/tests/8-concurrent-rename-and-create-at-target.test.ts +++ b/frontend/deterministic-tests/src/tests/concurrent-rename-and-create-at-target-create-first.test.ts @@ -1,7 +1,7 @@ import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; -export const concurrentRenameAndCreateAtTargetTest: TestDefinition = { +export const concurrentRenameAndCreateAtTargetCreateFirstTest: TestDefinition = { description: "One client renames X to Y while another creates a new file at Y, " + "both offline. After syncing, Y should contain merged content from " + diff --git a/frontend/deterministic-tests/src/tests/7-concurrent-rename-and-create-at-target.test.ts b/frontend/deterministic-tests/src/tests/concurrent-rename-and-create-at-target-rename-first.test.ts similarity index 94% rename from frontend/deterministic-tests/src/tests/7-concurrent-rename-and-create-at-target.test.ts rename to frontend/deterministic-tests/src/tests/concurrent-rename-and-create-at-target-rename-first.test.ts index 63dee0db..0ac0b721 100644 --- a/frontend/deterministic-tests/src/tests/7-concurrent-rename-and-create-at-target.test.ts +++ b/frontend/deterministic-tests/src/tests/concurrent-rename-and-create-at-target-rename-first.test.ts @@ -1,7 +1,7 @@ import type { AssertableState } from "../utils/assertable-state"; import type { TestDefinition } from "../test-definition"; -export const concurrentRenameAndCreateAtTargetTest: TestDefinition = { +export const concurrentRenameAndCreateAtTargetRenameFirstTest: TestDefinition = { description: "One client renames X to Y while another creates a new file at Y, " + "both offline. We can't merge the create because it would result in a cycle", diff --git a/frontend/deterministic-tests/src/tests/9-concurrent-rename-same-target.test.ts b/frontend/deterministic-tests/src/tests/concurrent-rename-same-target.test.ts similarity index 100% rename from frontend/deterministic-tests/src/tests/9-concurrent-rename-same-target.test.ts rename to frontend/deterministic-tests/src/tests/concurrent-rename-same-target.test.ts diff --git a/frontend/deterministic-tests/src/tests/10-concurrent-update-diff-consistency.test.ts b/frontend/deterministic-tests/src/tests/concurrent-update-diff-consistency.test.ts similarity index 100% rename from frontend/deterministic-tests/src/tests/10-concurrent-update-diff-consistency.test.ts rename to frontend/deterministic-tests/src/tests/concurrent-update-diff-consistency.test.ts diff --git a/frontend/deterministic-tests/src/tests/11-create-delete-noop.test.ts b/frontend/deterministic-tests/src/tests/create-delete-noop.test.ts similarity index 100% rename from frontend/deterministic-tests/src/tests/11-create-delete-noop.test.ts rename to frontend/deterministic-tests/src/tests/create-delete-noop.test.ts diff --git a/frontend/deterministic-tests/src/tests/16-create-during-reconciliation.test.ts b/frontend/deterministic-tests/src/tests/create-during-reconciliation.test.ts similarity index 100% rename from frontend/deterministic-tests/src/tests/16-create-during-reconciliation.test.ts rename to frontend/deterministic-tests/src/tests/create-during-reconciliation.test.ts diff --git a/frontend/deterministic-tests/src/tests/12-create-merge-delete.test.ts b/frontend/deterministic-tests/src/tests/create-merge-delete.test.ts similarity index 100% rename from frontend/deterministic-tests/src/tests/12-create-merge-delete.test.ts rename to frontend/deterministic-tests/src/tests/create-merge-delete.test.ts diff --git a/frontend/deterministic-tests/src/tests/17-create-merge-preserves-renamed-update.test.ts b/frontend/deterministic-tests/src/tests/create-merge-preserves-renamed-update.test.ts similarity index 100% rename from frontend/deterministic-tests/src/tests/17-create-merge-preserves-renamed-update.test.ts rename to frontend/deterministic-tests/src/tests/create-merge-preserves-renamed-update.test.ts diff --git a/frontend/deterministic-tests/src/tests/18-create-rename-create-same-path.test.ts b/frontend/deterministic-tests/src/tests/create-rename-create-same-path.test.ts similarity index 100% rename from frontend/deterministic-tests/src/tests/18-create-rename-create-same-path.test.ts rename to frontend/deterministic-tests/src/tests/create-rename-create-same-path.test.ts diff --git a/frontend/deterministic-tests/src/tests/15-create-update-coalesce-server-pause.test.ts b/frontend/deterministic-tests/src/tests/create-update-coalesce-server-pause.test.ts similarity index 100% rename from frontend/deterministic-tests/src/tests/15-create-update-coalesce-server-pause.test.ts rename to frontend/deterministic-tests/src/tests/create-update-coalesce-server-pause.test.ts diff --git a/frontend/deterministic-tests/src/tests/key-migration-event-drop.test.ts b/frontend/deterministic-tests/src/tests/key-migration-event-drop.test.ts deleted file mode 100644 index cc40e6b0..00000000 --- a/frontend/deterministic-tests/src/tests/key-migration-event-drop.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { AssertableState } from "../utils/assertable-state"; -import type { TestDefinition } from "../test-definition"; - -export const keyMigrationEventDropTest: TestDefinition = { - description: - "Client 0 creates a file and immediately updates it while the server is paused. " + - "After resume, both clients should have the updated content.", - clients: 2, - steps: [ - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "barrier" }, - - { type: "pause-server" }, - - { - type: "create", - client: 0, - path: "A.md", - content: "initial content" - }, - { - type: "update", - client: 0, - path: "A.md", - content: "updated content" - }, - - { type: "resume-server" }, - { type: "barrier" }, - - { - type: "assert-consistent", - verify: (s: AssertableState): void => { - s.assertFileCount(1).assertContent("A.md", "updated content"); - } - } - ] -}; diff --git a/frontend/deterministic-tests/src/tests/migrate-key-preserves-existing.test.ts b/frontend/deterministic-tests/src/tests/migrate-key-preserves-existing.test.ts deleted file mode 100644 index bb669e45..00000000 --- a/frontend/deterministic-tests/src/tests/migrate-key-preserves-existing.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { AssertableState } from "../utils/assertable-state"; -import type { TestDefinition } from "../test-definition"; - -export const migrateKeyPreservesExistingTest: TestDefinition = { - description: - "Client 0 creates a file and immediately updates it while the server is paused. " + - "After resume, the update must not be lost.", - clients: 2, - steps: [ - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "barrier" }, - - { type: "pause-server" }, - - { type: "create", client: 0, path: "A.md", content: "initial" }, - { - type: "update", - client: 0, - path: "A.md", - content: "updated by client 0" - }, - - { type: "resume-server" }, - { type: "barrier" }, - - { - type: "assert-consistent", - verify: (s: AssertableState): void => { - s.assertFileCount(1).assertContains( - "A.md", - "updated by client 0" - ); - } - } - ] -}; diff --git a/frontend/deterministic-tests/src/tests/19-move-chain-three-files.test.ts b/frontend/deterministic-tests/src/tests/move-chain-three-files.test.ts similarity index 100% rename from frontend/deterministic-tests/src/tests/19-move-chain-three-files.test.ts rename to frontend/deterministic-tests/src/tests/move-chain-three-files.test.ts diff --git a/frontend/deterministic-tests/src/tests/13-move-identical-content-ambiguity.test.ts b/frontend/deterministic-tests/src/tests/move-identical-content-ambiguity.test.ts similarity index 100% rename from frontend/deterministic-tests/src/tests/13-move-identical-content-ambiguity.test.ts rename to frontend/deterministic-tests/src/tests/move-identical-content-ambiguity.test.ts diff --git a/frontend/deterministic-tests/src/tests/offline-create-same-path-binary-conflict.test.ts b/frontend/deterministic-tests/src/tests/offline-create-same-path-mergeable.test.ts similarity index 100% rename from frontend/deterministic-tests/src/tests/offline-create-same-path-binary-conflict.test.ts rename to frontend/deterministic-tests/src/tests/offline-create-same-path-mergeable.test.ts diff --git a/frontend/deterministic-tests/src/tests/1-text-pending-create-not-displaced.test.ts b/frontend/deterministic-tests/src/tests/text-pending-create-not-displaced.test.ts similarity index 100% rename from frontend/deterministic-tests/src/tests/1-text-pending-create-not-displaced.test.ts rename to frontend/deterministic-tests/src/tests/text-pending-create-not-displaced.test.ts diff --git a/frontend/deterministic-tests/src/tests/update-survives-remote-delete.test.ts b/frontend/deterministic-tests/src/tests/update-does-not-survive-remote-delete.test.ts similarity index 100% rename from frontend/deterministic-tests/src/tests/update-survives-remote-delete.test.ts rename to frontend/deterministic-tests/src/tests/update-does-not-survive-remote-delete.test.ts diff --git a/frontend/deterministic-tests/src/tests/10-user-parenthesized-file-not-deleted.test.ts b/frontend/deterministic-tests/src/tests/user-parenthesized-file-not-deleted.test.ts similarity index 100% rename from frontend/deterministic-tests/src/tests/10-user-parenthesized-file-not-deleted.test.ts rename to frontend/deterministic-tests/src/tests/user-parenthesized-file-not-deleted.test.ts diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index eed30760..1e33ac41 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -8,7 +8,7 @@ export default [ "sync-client/src/services/types.ts", "**/dist/", "**/*.mjs", - "**/*.js", + "**/*.js" ] }, ...tseslint.config({ @@ -17,9 +17,7 @@ export default [ }, extends: [eslint.configs.recommended, tseslint.configs.all], rules: { - "no-console": "error", "no-unused-vars": "off", - "curly": ["error", "all"], "@typescript-eslint/restrict-template-expressions": "off", "@typescript-eslint/no-unused-vars": "off", "@typescript-eslint/no-floating-promises": [ diff --git a/frontend/history-ui/index.html b/frontend/history-ui/index.html deleted file mode 100644 index cde20e90..00000000 --- a/frontend/history-ui/index.html +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - VaultLink2 - - - -
- - - diff --git a/frontend/history-ui/package.json b/frontend/history-ui/package.json deleted file mode 100644 index 000dbdb0..00000000 --- a/frontend/history-ui/package.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "name": "history-ui", - "version": "0.14.0", - "private": true, - "type": "module", - "scripts": { - "dev": "vite dev --host 0.0.0.0", - "build": "vite build", - "test": "echo 'no tests yet'" - }, - "devDependencies": { - "@sveltejs/vite-plugin-svelte": "^5.0.0", - "svelte": "^5.0.0", - "vite": "^6.0.0" - } -} diff --git a/frontend/history-ui/src/App.svelte b/frontend/history-ui/src/App.svelte deleted file mode 100644 index 37b4cd34..00000000 --- a/frontend/history-ui/src/App.svelte +++ /dev/null @@ -1,78 +0,0 @@ - - -{#if restoring} -
-
-
-{:else if !auth.token} - -{:else if !auth.isAuthenticated} - -{:else} - -{/if} - - - - diff --git a/frontend/history-ui/src/app.css b/frontend/history-ui/src/app.css deleted file mode 100644 index ff3e6a9c..00000000 --- a/frontend/history-ui/src/app.css +++ /dev/null @@ -1,101 +0,0 @@ -:root { - --bg: #0d1117; - --bg-secondary: #161b22; - --bg-tertiary: #21262d; - --bg-hover: #30363d; - --border: #30363d; - --border-light: #21262d; - --text: #e6edf3; - --text-muted: #8b949e; - --text-subtle: #6e7681; - --accent: #58a6ff; - --accent-hover: #79c0ff; - --green: #3fb950; - --green-bg: rgba(63, 185, 80, 0.15); - --red: #f85149; - --red-bg: rgba(248, 81, 73, 0.15); - --orange: #d29922; - --orange-bg: rgba(210, 153, 34, 0.15); - --purple: #bc8cff; - --purple-bg: rgba(188, 140, 255, 0.15); - --blue: #58a6ff; - --blue-bg: rgba(88, 166, 255, 0.15); - --mono: "SF Mono", "Fira Code", "Fira Mono", Menlo, Consolas, monospace; - --sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Noto Sans, Helvetica, Arial, sans-serif; - --radius: 6px; - --radius-sm: 4px; - --shadow: 0 1px 3px rgba(0, 0, 0, 0.3); -} - -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -html, body, #app { - height: 100%; - width: 100%; - overflow: hidden; -} - -body { - font-family: var(--sans); - font-size: 14px; - line-height: 1.5; - color: var(--text); - background: var(--bg); - -webkit-font-smoothing: antialiased; -} - -button { - font-family: inherit; - font-size: inherit; - cursor: pointer; - border: none; - background: none; - color: inherit; -} - -input { - font-family: inherit; - font-size: inherit; - color: inherit; - background: var(--bg); - border: 1px solid var(--border); - border-radius: var(--radius); - padding: 8px 12px; - outline: none; - transition: border-color 0.15s; -} - -input:focus { - border-color: var(--accent); -} - -a { - color: var(--accent); - text-decoration: none; -} - -a:hover { - text-decoration: underline; -} - -::-webkit-scrollbar { - width: 8px; - height: 8px; -} - -::-webkit-scrollbar-track { - background: transparent; -} - -::-webkit-scrollbar-thumb { - background: var(--bg-tertiary); - border-radius: 4px; -} - -::-webkit-scrollbar-thumb:hover { - background: var(--bg-hover); -} diff --git a/frontend/history-ui/src/components/ActivityFeed.svelte b/frontend/history-ui/src/components/ActivityFeed.svelte deleted file mode 100644 index b20991e2..00000000 --- a/frontend/history-ui/src/components/ActivityFeed.svelte +++ /dev/null @@ -1,346 +0,0 @@ - - -
- {#if loading && versions.length === 0} -
Loading activity...
- {:else if versions.length === 0} -
- No activity yet. Documents will appear here as sync clients - make changes. -
- {:else} - {#each grouped as group} -
-
{group.date}
-
- {#each group.items as event} -
- - -
- {/each} -
-
- {/each} - - {#if hasMore} -
- -
- {/if} - {/if} -
- - diff --git a/frontend/history-ui/src/components/ConfirmDialog.svelte b/frontend/history-ui/src/components/ConfirmDialog.svelte deleted file mode 100644 index e91f790a..00000000 --- a/frontend/history-ui/src/components/ConfirmDialog.svelte +++ /dev/null @@ -1,167 +0,0 @@ - - - - - - - - diff --git a/frontend/history-ui/src/components/Dashboard.svelte b/frontend/history-ui/src/components/Dashboard.svelte deleted file mode 100644 index 8cf89677..00000000 --- a/frontend/history-ui/src/components/Dashboard.svelte +++ /dev/null @@ -1,508 +0,0 @@ - - -
-
- -
- - - - -
- {#if maxUpdateId > 0} -
- { - timeSliderValue = v; - }} - /> -
- {/if} - - {#if selectedDocumentId} - nav.goHome()} - onRestore={handleRefresh} - /> - {:else} -
- - -
- - {#if activeTab === "activity"} - { - timeSliderValue = id >= maxUpdateId ? null : id; - }} - /> - {:else} -
- {#each latestDocuments - .filter((d) => showDeleted || !d.isDeleted) - .sort((a, b) => b.vaultUpdateId - a.vaultUpdateId) as doc} - - {/each} -
- {/if} - {/if} -
-
-
- - diff --git a/frontend/history-ui/src/components/DiffView.svelte b/frontend/history-ui/src/components/DiffView.svelte deleted file mode 100644 index be97952c..00000000 --- a/frontend/history-ui/src/components/DiffView.svelte +++ /dev/null @@ -1,288 +0,0 @@ - - -
-
- {oldLabel} - - {newLabel} - - +{stats.added} - -{stats.removed} - -
-
- {#each diffLines as line} -
- - {line.oldLineNo ?? ""} - - - {line.newLineNo ?? ""} - - - {#if line.type === "add"}+{:else if line.type === "remove"}-{:else} {/if} - - {line.content} -
- {/each} -
-
- - diff --git a/frontend/history-ui/src/components/DocumentDetail.svelte b/frontend/history-ui/src/components/DocumentDetail.svelte deleted file mode 100644 index e4de2de8..00000000 --- a/frontend/history-ui/src/components/DocumentDetail.svelte +++ /dev/null @@ -1,729 +0,0 @@ - - -
- -
- -
-
- - {currentPath} - - {#if isDeleted} - Deleted - {:else} - Active - {/if} -
-
- - {documentId.substring(0, 8)}... - - {#if latest} - · - {versions.length} version{versions.length !== 1 ? "s" : ""} - · - Last by {latest.userId} - {/if} -
-
-
- - {#if loading} -
Loading versions...
- {:else} - -
-
- {#if selectedVersion} -
- - -
- - Viewing v#{selectedVersion.vaultUpdateId} - · - {relativeTime(selectedVersion.updatedDate)} - -
- -
- {#if loadingContent} -
Loading content...
- {:else if activeTab === "diff" && diffOldContent !== null && diffNewContent !== null} - - {:else if activeTab === "preview"} - {#if isTextFile(selectedVersion.relativePath) || fileExtension(selectedVersion.relativePath) === ""} -
{loadedContent ?? ""}
- {:else if isImageFile(selectedVersion.relativePath) && loadedContentBytes} -
- {selectedVersion.relativePath} -
- {:else} -
-
📦
-
Binary file
-
- {formatBytes(selectedVersion.contentSize)} -
-
- {/if} - {/if} -
- {/if} -
- - -
-
Version History
-
- {#each [...versionEvents].reverse() as event, i} - {@const v = event.version} - {@const isSelected = selectedVersion?.vaultUpdateId === v.vaultUpdateId} -
- - {#if event.previousPath} -
- {event.previousPath} → {v.relativePath} -
- {/if} -
- {#if i < versionEvents.length - 1} - - {/if} - {#if v !== latest} - - {/if} -
-
- {/each} -
-
-
- {/if} -
- -{#if showRestoreDialog && restoreTarget} - { - showRestoreDialog = false; - restoreTarget = null; - }} - /> -{/if} - - diff --git a/frontend/history-ui/src/components/FileTree.svelte b/frontend/history-ui/src/components/FileTree.svelte deleted file mode 100644 index a1a99d4c..00000000 --- a/frontend/history-ui/src/components/FileTree.svelte +++ /dev/null @@ -1,124 +0,0 @@ - - -{#if node.isFolder && depth === 0} - {#each node.children as child} - - {/each} -{:else if node.isFolder} -
- - {#if isExpanded(node.path)} - {#each node.children as child} - - {/each} - {/if} -
-{:else} - -{/if} - - diff --git a/frontend/history-ui/src/components/Header.svelte b/frontend/history-ui/src/components/Header.svelte deleted file mode 100644 index 8e635224..00000000 --- a/frontend/history-ui/src/components/Header.svelte +++ /dev/null @@ -1,144 +0,0 @@ - - -
-
- - - - - - VaultLink - / - {vaultId} -
- -
- v{serverVersion} - - {#if auth.availableVaults.length > 1} - - {/if} - -
-
- - diff --git a/frontend/history-ui/src/components/Login.svelte b/frontend/history-ui/src/components/Login.svelte deleted file mode 100644 index 8d331966..00000000 --- a/frontend/history-ui/src/components/Login.svelte +++ /dev/null @@ -1,176 +0,0 @@ - - - - - diff --git a/frontend/history-ui/src/components/TimeSlider.svelte b/frontend/history-ui/src/components/TimeSlider.svelte deleted file mode 100644 index 0bdc3abf..00000000 --- a/frontend/history-ui/src/components/TimeSlider.svelte +++ /dev/null @@ -1,191 +0,0 @@ - - -
-
- - - - - Time Travel -
- -
- -
- -
- {#if isNow} - Now - {:else if currentVersion} - - #{value} - · - {relativeTime(currentVersion.updatedDate)} - - {:else} - #{value} - {/if} -
- - {#if !isNow} - - {/if} -
- - diff --git a/frontend/history-ui/src/components/ToastContainer.svelte b/frontend/history-ui/src/components/ToastContainer.svelte deleted file mode 100644 index 39ab1705..00000000 --- a/frontend/history-ui/src/components/ToastContainer.svelte +++ /dev/null @@ -1,80 +0,0 @@ - - -{#if toasts.items.length > 0} -
- {#each toasts.items as toast (toast.id)} -
- {toast.message} - -
- {/each} -
-{/if} - - diff --git a/frontend/history-ui/src/components/VaultPicker.svelte b/frontend/history-ui/src/components/VaultPicker.svelte deleted file mode 100644 index 8ca82737..00000000 --- a/frontend/history-ui/src/components/VaultPicker.svelte +++ /dev/null @@ -1,198 +0,0 @@ - - -
-
-
- -
- - {#if auth.availableVaults.length === 0} -
-

No vaults found

-

- Vaults are created when a sync client first connects. -

-
- {:else} -
    - {#each auth.availableVaults as vault} -
  • - -
  • - {/each} -
- {/if} -
-
- - diff --git a/frontend/history-ui/src/lib/api.ts b/frontend/history-ui/src/lib/api.ts deleted file mode 100644 index eefc594d..00000000 --- a/frontend/history-ui/src/lib/api.ts +++ /dev/null @@ -1,146 +0,0 @@ -import type { DocumentUpdateResponse } from "./types/DocumentUpdateResponse"; -import type { DocumentVersion } from "./types/DocumentVersion"; -import type { DocumentVersionWithoutContent } from "./types/DocumentVersionWithoutContent"; -import type { FetchLatestDocumentsResponse } from "./types/FetchLatestDocumentsResponse"; -import type { ListVaultsResponse } from "./types/ListVaultsResponse"; -import type { PingResponse } from "./types/PingResponse"; -import type { VaultHistoryResponse } from "./types/VaultHistoryResponse"; - -async function fetchJsonWithToken( - path: string, - token: string, - init?: RequestInit -): Promise { - const response = await fetch(path, { - ...init, - headers: { - Authorization: `Bearer ${token}`, - "device-id": "history-ui", - ...init?.headers - } - }); - if (!response.ok) { - const body = await response.text(); - throw new Error(`HTTP ${response.status}: ${body}`); - } - return response.json() as Promise; -} - -export async function listVaults(token: string): Promise { - return fetchJsonWithToken("/vaults", token); -} - -export class ApiClient { - constructor( - private vaultId: string, - private token: string - ) {} - - private get baseUrl(): string { - return `/vaults/${encodeURIComponent(this.vaultId)}`; - } - - private async fetchJson(path: string, init?: RequestInit): Promise { - return fetchJsonWithToken(path, this.token, init); - } - - async ping(): Promise { - return this.fetchJson(`${this.baseUrl}/ping`); - } - - async fetchLatestDocuments(): Promise { - return this.fetchJson(`${this.baseUrl}/documents`); - } - - async fetchDocumentVersions( - documentId: string - ): Promise { - return this.fetchJson( - `${this.baseUrl}/documents/${documentId}/versions` - ); - } - - async fetchDocumentVersion( - documentId: string, - vaultUpdateId: number - ): Promise { - return this.fetchJson( - `${this.baseUrl}/documents/${documentId}/versions/${vaultUpdateId}` - ); - } - - async fetchDocumentVersionContent( - documentId: string, - vaultUpdateId: number - ): Promise { - const response = await fetch( - `${this.baseUrl}/documents/${documentId}/versions/${vaultUpdateId}/content`, - { - headers: { - Authorization: `Bearer ${this.token}`, - "device-id": "history-ui" - } - } - ); - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); - } - return response.arrayBuffer(); - } - - async fetchVaultHistory( - limit?: number, - beforeUpdateId?: number - ): Promise { - const params = new URLSearchParams(); - if (limit !== undefined) params.set("limit", String(limit)); - if (beforeUpdateId !== undefined) - params.set("before_update_id", String(beforeUpdateId)); - const qs = params.toString(); - return this.fetchJson(`${this.baseUrl}/history${qs ? `?${qs}` : ""}`); - } - - /** - * Upload a new version of an existing (non-deleted) document. The - * server treats this like any other edit — server-side merging, - * path dedupe, and broadcast still apply. Used by the UI to restore - * an old version by re-submitting its bytes on top of the latest. - */ - async updateBinaryDocument( - documentId: string, - parentVersionId: number, - relativePath: string, - content: ArrayBuffer - ): Promise { - const form = new FormData(); - form.append("parent_version_id", String(parentVersionId)); - form.append("relative_path", relativePath); - form.append("content", new Blob([content])); - return this.fetchJson( - `${this.baseUrl}/documents/${documentId}/binary`, - { method: "PUT", body: form } - ); - } - - /** - * Create a new document. Used by the UI to restore a deleted - * document: `update_document` short-circuits on `is_deleted`, so - * resurrection has to go through `create_document` — which detects - * an existing doc at the same path, merges or dedupes as needed, - * and returns the resulting version. - */ - async createDocument( - lastSeenVaultUpdateId: number, - relativePath: string, - content: ArrayBuffer - ): Promise { - const form = new FormData(); - form.append("last_seen_vault_update_id", String(lastSeenVaultUpdateId)); - form.append("relative_path", relativePath); - form.append("content", new Blob([content])); - return this.fetchJson(`${this.baseUrl}/documents`, { - method: "POST", - body: form - }); - } -} diff --git a/frontend/history-ui/src/lib/stores.svelte.ts b/frontend/history-ui/src/lib/stores.svelte.ts deleted file mode 100644 index 16ee4a30..00000000 --- a/frontend/history-ui/src/lib/stores.svelte.ts +++ /dev/null @@ -1,290 +0,0 @@ -import { ApiClient } from "./api"; -import type { DocumentVersionWithoutContent } from "./types/DocumentVersionWithoutContent"; -import type { VaultInfo } from "./types/VaultInfo"; -import type { VersionEvent, ActionType, TreeNode } from "./view-types"; - -class AuthStore { - token = $state(""); - userName = $state(""); - vaultId = $state(""); - serverVersion = $state(""); - availableVaults = $state([]); - isAuthenticated = $state(false); - api = $state(null); - - authenticate(token: string, userName: string, vaults: VaultInfo[]) { - this.token = token; - this.userName = userName; - this.availableVaults = vaults; - sessionStorage.setItem("vaultlink_token", token); - } - - selectVault(vaultId: string) { - this.vaultId = vaultId; - this.isAuthenticated = true; - this.api = new ApiClient(vaultId, this.token); - sessionStorage.setItem("vaultlink_vault", vaultId); - } - - deselectVault() { - this.vaultId = ""; - this.isAuthenticated = false; - this.api = null; - sessionStorage.removeItem("vaultlink_vault"); - } - - logout() { - this.token = ""; - this.userName = ""; - this.vaultId = ""; - this.serverVersion = ""; - this.availableVaults = []; - this.isAuthenticated = false; - this.api = null; - sessionStorage.removeItem("vaultlink_token"); - sessionStorage.removeItem("vaultlink_vault"); - } - - tryRestore(): { token: string; vaultId?: string } | null { - const token = sessionStorage.getItem("vaultlink_token"); - if (!token) return null; - const vaultId = sessionStorage.getItem("vaultlink_vault") ?? undefined; - return { token, vaultId }; - } -} - -export const auth = new AuthStore(); - -// Navigation -export type View = - | { kind: "dashboard" } - | { kind: "document"; documentId: string }; - -class NavStore { - current = $state({ kind: "dashboard" }); - - goto(view: View) { - this.current = view; - } - - goHome() { - this.current = { kind: "dashboard" }; - } -} - -export const nav = new NavStore(); - -// Toasts -interface Toast { - id: number; - message: string; - type: "success" | "error" | "info"; -} - -class ToastStore { - items = $state([]); - private nextId = 0; - - add(message: string, type: Toast["type"] = "info") { - const id = this.nextId++; - this.items.push({ id, message, type }); - setTimeout(() => this.dismiss(id), 5000); - } - - dismiss(id: number) { - this.items = this.items.filter((t) => t.id !== id); - } -} - -export const toasts = new ToastStore(); - -// Utilities - -export function inferAction( - version: DocumentVersionWithoutContent, - previousVersion?: DocumentVersionWithoutContent -): ActionType { - if (version.isDeleted) return "deleted"; - if (!previousVersion) return "created"; - if (previousVersion.isDeleted && !version.isDeleted) return "restored"; - if (previousVersion.relativePath !== version.relativePath) return "renamed"; - return "updated"; -} - -export function enrichVersions( - versions: DocumentVersionWithoutContent[] -): VersionEvent[] { - // versions should be sorted by vaultUpdateId ascending - const sorted = [...versions].sort( - (a, b) => a.vaultUpdateId - b.vaultUpdateId - ); - const byDoc = new Map(); - for (const v of sorted) { - let arr = byDoc.get(v.documentId); - if (!arr) { - arr = []; - byDoc.set(v.documentId, arr); - } - arr.push(v); - } - - return sorted.map((v) => { - const docVersions = byDoc.get(v.documentId)!; - const idx = docVersions.indexOf(v); - const prev = idx > 0 ? docVersions[idx - 1] : undefined; - const action = inferAction(v, prev); - return { - ...v, - action, - previousPath: action === "renamed" ? prev?.relativePath : undefined - }; - }); -} - -export function buildTree( - documents: DocumentVersionWithoutContent[], - showDeleted: boolean -): TreeNode { - const root: TreeNode = { - name: "", - path: "", - isFolder: true, - children: [] - }; - - const filtered = showDeleted - ? documents - : documents.filter((d) => !d.isDeleted); - - for (const doc of filtered) { - const parts = doc.relativePath.split("/"); - let current = root; - - for (let i = 0; i < parts.length; i++) { - const part = parts[i]; - const isFile = i === parts.length - 1; - const path = parts.slice(0, i + 1).join("/"); - - if (isFile) { - current.children.push({ - name: part, - path, - isFolder: false, - children: [], - document: doc, - isDeleted: doc.isDeleted - }); - } else { - let folder = current.children.find( - (c) => c.isFolder && c.name === part - ); - if (!folder) { - folder = { - name: part, - path, - isFolder: true, - children: [] - }; - current.children.push(folder); - } - current = folder; - } - } - } - - sortTree(root); - return root; -} - -function sortTree(node: TreeNode) { - node.children.sort((a, b) => { - if (a.isFolder !== b.isFolder) return a.isFolder ? -1 : 1; - return a.name.localeCompare(b.name); - }); - for (const child of node.children) { - if (child.isFolder) sortTree(child); - } -} - -export function relativeTime(dateStr: string): string { - const date = new Date(dateStr); - const now = Date.now(); - const diff = now - date.getTime(); - const seconds = Math.floor(diff / 1000); - const minutes = Math.floor(seconds / 60); - const hours = Math.floor(minutes / 60); - const days = Math.floor(hours / 24); - - if (seconds < 60) return "just now"; - if (minutes < 60) return `${minutes}m ago`; - if (hours < 24) return `${hours}h ago`; - if (days < 7) return `${days}d ago`; - return date.toLocaleDateString("en-US", { - month: "short", - day: "numeric", - year: days > 365 ? "numeric" : undefined - }); -} - -export function absoluteTime(dateStr: string): string { - return new Date(dateStr).toLocaleString(); -} - -export function formatBytes(bytes: number): string { - if (bytes === 0) return "0 B"; - const k = 1024; - const sizes = ["B", "KB", "MB", "GB"]; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`; -} - -export function fileExtension(path: string): string { - const dot = path.lastIndexOf("."); - return dot > -1 ? path.substring(dot + 1).toLowerCase() : ""; -} - -export function isTextFile(path: string): boolean { - const textExts = new Set([ - "md", - "txt", - "json", - "yaml", - "yml", - "toml", - "xml", - "html", - "css", - "js", - "ts", - "svelte", - "rs", - "py", - "sh", - "bash", - "zsh", - "csv", - "svg", - "log", - "conf", - "cfg", - "ini", - "env", - "gitignore", - "editorconfig" - ]); - return textExts.has(fileExtension(path)); -} - -export function isImageFile(path: string): boolean { - const imageExts = new Set([ - "png", - "jpg", - "jpeg", - "gif", - "webp", - "svg", - "ico", - "bmp" - ]); - return imageExts.has(fileExtension(path)); -} diff --git a/frontend/history-ui/src/lib/types/ClientCursors.ts b/frontend/history-ui/src/lib/types/ClientCursors.ts deleted file mode 100644 index 14298431..00000000 --- a/frontend/history-ui/src/lib/types/ClientCursors.ts +++ /dev/null @@ -1,8 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { DocumentWithCursors } from "./DocumentWithCursors"; - -export type ClientCursors = { - userName: string; - deviceId: string; - documentsWithCursors: Array; -}; diff --git a/frontend/history-ui/src/lib/types/CursorPositionFromClient.ts b/frontend/history-ui/src/lib/types/CursorPositionFromClient.ts deleted file mode 100644 index 5846843e..00000000 --- a/frontend/history-ui/src/lib/types/CursorPositionFromClient.ts +++ /dev/null @@ -1,6 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { DocumentWithCursors } from "./DocumentWithCursors"; - -export type CursorPositionFromClient = { - documentsWithCursors: Array; -}; diff --git a/frontend/history-ui/src/lib/types/CursorPositionFromServer.ts b/frontend/history-ui/src/lib/types/CursorPositionFromServer.ts deleted file mode 100644 index 3a72c706..00000000 --- a/frontend/history-ui/src/lib/types/CursorPositionFromServer.ts +++ /dev/null @@ -1,4 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ClientCursors } from "./ClientCursors"; - -export type CursorPositionFromServer = { clients: Array }; diff --git a/frontend/history-ui/src/lib/types/DocumentUpdateResponse.ts b/frontend/history-ui/src/lib/types/DocumentUpdateResponse.ts deleted file mode 100644 index dd7eadda..00000000 --- a/frontend/history-ui/src/lib/types/DocumentUpdateResponse.ts +++ /dev/null @@ -1,10 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { DocumentVersion } from "./DocumentVersion"; -import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent"; - -/** - * Response to a create/update document request. - */ -export type DocumentUpdateResponse = - | ({ type: "FastForwardUpdate" } & DocumentVersionWithoutContent) - | ({ type: "MergingUpdate" } & DocumentVersion); diff --git a/frontend/history-ui/src/lib/types/DocumentVersion.ts b/frontend/history-ui/src/lib/types/DocumentVersion.ts deleted file mode 100644 index 50a6c591..00000000 --- a/frontend/history-ui/src/lib/types/DocumentVersion.ts +++ /dev/null @@ -1,12 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type DocumentVersion = { - vaultUpdateId: number; - documentId: string; - relativePath: string; - updatedDate: string; - contentBase64: string; - isDeleted: boolean; - userId: string; - deviceId: string; -}; diff --git a/frontend/history-ui/src/lib/types/DocumentVersionWithoutContent.ts b/frontend/history-ui/src/lib/types/DocumentVersionWithoutContent.ts deleted file mode 100644 index e3ed828a..00000000 --- a/frontend/history-ui/src/lib/types/DocumentVersionWithoutContent.ts +++ /dev/null @@ -1,16 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type DocumentVersionWithoutContent = { - vaultUpdateId: number; - documentId: string; - relativePath: string; - updatedDate: string; - isDeleted: boolean; - userId: string; - deviceId: string; - contentSize: number; - /** - * True iff this is the first version of the document - */ - isNewFile: boolean; -}; diff --git a/frontend/history-ui/src/lib/types/DocumentWithCursors.ts b/frontend/history-ui/src/lib/types/DocumentWithCursors.ts deleted file mode 100644 index ca6a2155..00000000 --- a/frontend/history-ui/src/lib/types/DocumentWithCursors.ts +++ /dev/null @@ -1,9 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { CursorSpan } from "./CursorSpan"; - -export type DocumentWithCursors = { - vaultUpdateId: number | null; - documentId: string; - relativePath: string; - cursors: Array; -}; diff --git a/frontend/history-ui/src/lib/types/FetchLatestDocumentsResponse.ts b/frontend/history-ui/src/lib/types/FetchLatestDocumentsResponse.ts deleted file mode 100644 index 141c2565..00000000 --- a/frontend/history-ui/src/lib/types/FetchLatestDocumentsResponse.ts +++ /dev/null @@ -1,13 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent"; - -/** - * Response to a fetch latest documents request. - */ -export type FetchLatestDocumentsResponse = { - latestDocuments: Array; - /** - * The update ID of the latest document in the response. - */ - lastUpdateId: bigint; -}; diff --git a/frontend/history-ui/src/lib/types/ListVaultsResponse.ts b/frontend/history-ui/src/lib/types/ListVaultsResponse.ts deleted file mode 100644 index 604ad958..00000000 --- a/frontend/history-ui/src/lib/types/ListVaultsResponse.ts +++ /dev/null @@ -1,11 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { VaultInfo } from "./VaultInfo"; - -/** - * Response to listing vaults accessible to the authenticated user. - */ -export type ListVaultsResponse = { - vaults: Array; - hasMore: boolean; - userName: string; -}; diff --git a/frontend/history-ui/src/lib/types/PingResponse.ts b/frontend/history-ui/src/lib/types/PingResponse.ts deleted file mode 100644 index 7e5ac4f8..00000000 --- a/frontend/history-ui/src/lib/types/PingResponse.ts +++ /dev/null @@ -1,25 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Response to a ping request. - */ -export type PingResponse = { - /** - * Semantic version of the server. - */ - serverVersion: string; - /** - * Whether the client is authenticated based on the sent Authorization - * header. - */ - isAuthenticated: boolean; - /** - * List of file extensions that are allowed to be merged. - */ - mergeableFileExtensions: Array; - /** - * API version ensuring backwards & forwards compatibility between the client - * and server. - */ - supportedApiVersion: number; -}; diff --git a/frontend/history-ui/src/lib/types/SerializedError.ts b/frontend/history-ui/src/lib/types/SerializedError.ts deleted file mode 100644 index 354305f6..00000000 --- a/frontend/history-ui/src/lib/types/SerializedError.ts +++ /dev/null @@ -1,7 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type SerializedError = { - errorType: string; - message: string; - causes: Array; -}; diff --git a/frontend/history-ui/src/lib/types/UpdateTextDocumentVersion.ts b/frontend/history-ui/src/lib/types/UpdateTextDocumentVersion.ts deleted file mode 100644 index 5a1978eb..00000000 --- a/frontend/history-ui/src/lib/types/UpdateTextDocumentVersion.ts +++ /dev/null @@ -1,7 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type UpdateTextDocumentVersion = { - parentVersionId: number; - relativePath: string | null; - content: Array; -}; diff --git a/frontend/history-ui/src/lib/types/VaultHistoryResponse.ts b/frontend/history-ui/src/lib/types/VaultHistoryResponse.ts deleted file mode 100644 index e69366f0..00000000 --- a/frontend/history-ui/src/lib/types/VaultHistoryResponse.ts +++ /dev/null @@ -1,10 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent"; - -/** - * Response to a vault history request (paginated). - */ -export type VaultHistoryResponse = { - versions: Array; - hasMore: boolean; -}; diff --git a/frontend/history-ui/src/lib/types/VaultInfo.ts b/frontend/history-ui/src/lib/types/VaultInfo.ts deleted file mode 100644 index 3f630ae9..00000000 --- a/frontend/history-ui/src/lib/types/VaultInfo.ts +++ /dev/null @@ -1,10 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Summary of a single vault returned by the list-vaults endpoint. - */ -export type VaultInfo = { - name: string; - documentCount: number; - createdAt: string | null; -}; diff --git a/frontend/history-ui/src/lib/types/WebSocketClientMessage.ts b/frontend/history-ui/src/lib/types/WebSocketClientMessage.ts deleted file mode 100644 index 9608f3af..00000000 --- a/frontend/history-ui/src/lib/types/WebSocketClientMessage.ts +++ /dev/null @@ -1,7 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { CursorPositionFromClient } from "./CursorPositionFromClient"; -import type { WebSocketHandshake } from "./WebSocketHandshake"; - -export type WebSocketClientMessage = - | ({ type: "handshake" } & WebSocketHandshake) - | ({ type: "cursorPositions" } & CursorPositionFromClient); diff --git a/frontend/history-ui/src/lib/types/WebSocketHandshake.ts b/frontend/history-ui/src/lib/types/WebSocketHandshake.ts deleted file mode 100644 index 8e51a121..00000000 --- a/frontend/history-ui/src/lib/types/WebSocketHandshake.ts +++ /dev/null @@ -1,7 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type WebSocketHandshake = { - token: string; - deviceId: string; - lastSeenVaultUpdateId: number | null; -}; diff --git a/frontend/history-ui/src/lib/types/WebSocketServerMessage.ts b/frontend/history-ui/src/lib/types/WebSocketServerMessage.ts deleted file mode 100644 index fd250b7b..00000000 --- a/frontend/history-ui/src/lib/types/WebSocketServerMessage.ts +++ /dev/null @@ -1,7 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { CursorPositionFromServer } from "./CursorPositionFromServer"; -import type { WebSocketVaultUpdate } from "./WebSocketVaultUpdate"; - -export type WebSocketServerMessage = - | ({ type: "vaultUpdate" } & WebSocketVaultUpdate) - | ({ type: "cursorPositions" } & CursorPositionFromServer); diff --git a/frontend/history-ui/src/lib/types/WebSocketVaultUpdate.ts b/frontend/history-ui/src/lib/types/WebSocketVaultUpdate.ts deleted file mode 100644 index 94d70c0a..00000000 --- a/frontend/history-ui/src/lib/types/WebSocketVaultUpdate.ts +++ /dev/null @@ -1,4 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent"; - -export type WebSocketVaultUpdate = { document: DocumentVersionWithoutContent }; diff --git a/frontend/history-ui/src/lib/view-types.ts b/frontend/history-ui/src/lib/view-types.ts deleted file mode 100644 index 8b8cb0ae..00000000 --- a/frontend/history-ui/src/lib/view-types.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { DocumentVersionWithoutContent } from "./types/DocumentVersionWithoutContent"; - -export type ActionType = - | "created" - | "updated" - | "renamed" - | "deleted" - | "restored"; - -export interface VersionEvent extends DocumentVersionWithoutContent { - action: ActionType; - previousPath?: string; -} - -export interface TreeNode { - name: string; - path: string; - isFolder: boolean; - children: TreeNode[]; - document?: DocumentVersionWithoutContent; - isDeleted?: boolean; -} diff --git a/frontend/history-ui/src/main.ts b/frontend/history-ui/src/main.ts deleted file mode 100644 index c72cabd0..00000000 --- a/frontend/history-ui/src/main.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { mount } from "svelte"; -import App from "./App.svelte"; -import "./app.css"; - -const app = mount(App, { target: document.getElementById("app")! }); - -export default app; diff --git a/frontend/history-ui/svelte.config.js b/frontend/history-ui/svelte.config.js deleted file mode 100644 index 76a68bfc..00000000 --- a/frontend/history-ui/svelte.config.js +++ /dev/null @@ -1,5 +0,0 @@ -import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; - -export default { - preprocess: vitePreprocess() -}; diff --git a/frontend/history-ui/tsconfig.json b/frontend/history-ui/tsconfig.json deleted file mode 100644 index 216dc140..00000000 --- a/frontend/history-ui/tsconfig.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "compilerOptions": { - "target": "ESNext", - "module": "ESNext", - "moduleResolution": "bundler", - "strict": true, - "esModuleInterop": true, - "skipLibCheck": true, - "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "isolatedModules": true, - "verbatimModuleSyntax": true, - "types": ["svelte"] - }, - "include": ["src/**/*", "src/**/*.svelte"] -} diff --git a/frontend/history-ui/vite.config.ts b/frontend/history-ui/vite.config.ts deleted file mode 100644 index 18f6be82..00000000 --- a/frontend/history-ui/vite.config.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { defineConfig } from "vite"; -import { svelte } from "@sveltejs/vite-plugin-svelte"; - -export default defineConfig({ - plugins: [svelte()], - build: { - outDir: "dist", - emptyOutDir: true - }, - server: { - proxy: { - "/vaults": "http://localhost:3010" - } - } -}); diff --git a/frontend/local-client-cli/src/args.test.ts b/frontend/local-client-cli/src/args.test.ts index c075d193..fdf0b6c8 100644 --- a/frontend/local-client-cli/src/args.test.ts +++ b/frontend/local-client-cli/src/args.test.ts @@ -150,25 +150,6 @@ test("parseArgs - default log level is INFO", () => { assert.equal(args.logLevel, LogLevel.INFO); }); -test("parseArgs - parse DEBUG log level", () => { - const args = parseArgs([ - "node", - "cli.js", - "-l", - "/path/to/vault", - "-r", - "https://sync.example.com", - "-t", - "mytoken", - "-v", - "default", - "--log-level", - "DEBUG" - ]); - - assert.equal(args.logLevel, LogLevel.DEBUG); -}); - test("parseArgs - parse ERROR log level", () => { const args = parseArgs([ "node", @@ -188,43 +169,6 @@ test("parseArgs - parse ERROR log level", () => { assert.equal(args.logLevel, LogLevel.ERROR); }); -test("parseArgs - log level is case insensitive", () => { - const args = parseArgs([ - "node", - "cli.js", - "-l", - "/path/to/vault", - "-r", - "https://sync.example.com", - "-t", - "mytoken", - "-v", - "default", - "--log-level", - "debug" - ]); - - assert.equal(args.logLevel, LogLevel.DEBUG); -}); - -test("parseArgs - throws on invalid log level", () => { - assert.throws(() => { - parseArgs([ - "node", - "cli.js", - "-l", - "/path/to/vault", - "-r", - "https://sync.example.com", - "-t", - "mytoken", - "-v", - "default", - "--log-level", - "INVALID" - ]); - }, /Invalid log level/); -}); test("parseArgs - reads required options from environment variables", () => { process.env.VAULTLINK_LOCAL_PATH = "/env/path"; @@ -267,184 +211,3 @@ test("parseArgs - CLI arguments take precedence over environment variables", () delete process.env.VAULTLINK_TOKEN; } }); - -test("parseArgs - reads log level from environment variable", () => { - process.env.VAULTLINK_LOG_LEVEL = "DEBUG"; - - try { - const args = parseArgs([ - "node", - "cli.js", - "-l", - "/path/to/vault", - "-r", - "https://sync.example.com", - "-t", - "mytoken", - "-v", - "default" - ]); - assert.equal(args.logLevel, LogLevel.DEBUG); - } finally { - delete process.env.VAULTLINK_LOG_LEVEL; - } -}); - -test("parseArgs - quiet defaults to false", () => { - const args = parseArgs([ - "node", - "cli.js", - "-l", - "/path/to/vault", - "-r", - "https://sync.example.com", - "-t", - "mytoken", - "-v", - "default" - ]); - - assert.equal(args.quiet, false); -}); - -test("parseArgs - parse --quiet flag", () => { - const args = parseArgs([ - "node", - "cli.js", - "-l", - "/path/to/vault", - "-r", - "https://sync.example.com", - "-t", - "mytoken", - "-v", - "default", - "--quiet" - ]); - - assert.equal(args.quiet, true); -}); - -test("parseArgs - parse -q short flag", () => { - const args = parseArgs([ - "node", - "cli.js", - "-l", - "/path/to/vault", - "-r", - "https://sync.example.com", - "-t", - "mytoken", - "-v", - "default", - "-q" - ]); - - assert.equal(args.quiet, true); -}); - -test("parseArgs - line-endings defaults to auto", () => { - const args = parseArgs([ - "node", - "cli.js", - "-l", - "/path/to/vault", - "-r", - "https://sync.example.com", - "-t", - "mytoken", - "-v", - "default" - ]); - - assert.equal(args.lineEndings, "auto"); -}); - -test("parseArgs - parse --line-endings lf", () => { - const args = parseArgs([ - "node", - "cli.js", - "-l", - "/path/to/vault", - "-r", - "https://sync.example.com", - "-t", - "mytoken", - "-v", - "default", - "--line-endings", - "lf" - ]); - - assert.equal(args.lineEndings, "lf"); -}); - -test("parseArgs - parse --line-endings crlf", () => { - const args = parseArgs([ - "node", - "cli.js", - "-l", - "/path/to/vault", - "-r", - "https://sync.example.com", - "-t", - "mytoken", - "-v", - "default", - "--line-endings", - "crlf" - ]); - - assert.equal(args.lineEndings, "crlf"); -}); - -test("parseArgs - throws on invalid remote URI protocol", () => { - assert.throws(() => { - parseArgs([ - "node", - "cli.js", - "-l", - "/path/to/vault", - "-r", - "ftp://sync.example.com", - "-t", - "mytoken", - "-v", - "default" - ]); - }, /Invalid remote URI/); -}); - -test("parseArgs - accepts http:// remote URI", () => { - const args = parseArgs([ - "node", - "cli.js", - "-l", - "/path/to/vault", - "-r", - "http://localhost:3000", - "-t", - "mytoken", - "-v", - "default" - ]); - - assert.equal(args.remoteUri, "http://localhost:3000"); -}); - -test("parseArgs - accepts wss:// remote URI", () => { - const args = parseArgs([ - "node", - "cli.js", - "-l", - "/path/to/vault", - "-r", - "wss://sync.example.com", - "-t", - "mytoken", - "-v", - "default" - ]); - - assert.equal(args.remoteUri, "wss://sync.example.com"); -}); diff --git a/frontend/local-client-cli/src/args.ts b/frontend/local-client-cli/src/args.ts index 442c4817..34d839b1 100644 --- a/frontend/local-client-cli/src/args.ts +++ b/frontend/local-client-cli/src/args.ts @@ -2,7 +2,8 @@ import { Command, Option } from "commander"; import packageJson from "../package.json"; import { LogLevel } from "sync-client"; -type LineEndingMode = "auto" | "lf" | "crlf"; +export const LINE_ENDING_MODES = ["auto", "lf", "crlf"] as const; +export type LineEndingMode = (typeof LINE_ENDING_MODES)[number]; interface CliArgs { remoteUri: string; @@ -21,6 +22,35 @@ interface CliArgs { const VALID_PROTOCOLS = ["http://", "https://", "ws://", "wss://"]; +const REQUIRED_OPTIONS = { + localPath: { + flags: "-l, --local-path ", + env: "VAULTLINK_LOCAL_PATH" + }, + remoteUri: { + flags: "-r, --remote-uri ", + env: "VAULTLINK_REMOTE_URI" + }, + token: { flags: "-t, --token ", env: "VAULTLINK_TOKEN" }, + vaultName: { + flags: "-v, --vault-name ", + env: "VAULTLINK_VAULT_NAME" + } +} as const; + +function requireOption( + value: T | undefined, + name: keyof typeof REQUIRED_OPTIONS +): T { + if (value === undefined) { + const { flags, env } = REQUIRED_OPTIONS[name]; + throw new Error( + `required option '${flags}' not specified (or set ${env})` + ); + } + return value; +} + export function parseArgs(argv: string[]): CliArgs { const program = new Command(); @@ -32,23 +62,25 @@ export function parseArgs(argv: string[]): CliArgs { .version(packageJson.version) .addOption( new Option( - "-l, --local-path ", + REQUIRED_OPTIONS.localPath.flags, "Local directory path to sync" - ).env("VAULTLINK_LOCAL_PATH") + ).env(REQUIRED_OPTIONS.localPath.env) ) .addOption( - new Option("-r, --remote-uri ", "Remote server URI").env( - "VAULTLINK_REMOTE_URI" - ) + new Option( + REQUIRED_OPTIONS.remoteUri.flags, + "Remote server URI" + ).env(REQUIRED_OPTIONS.remoteUri.env) ) .addOption( - new Option("-t, --token ", "Authentication token").env( - "VAULTLINK_TOKEN" - ) + new Option( + REQUIRED_OPTIONS.token.flags, + "Authentication token" + ).env(REQUIRED_OPTIONS.token.env) ) .addOption( - new Option("-v, --vault-name ", "Vault name").env( - "VAULTLINK_VAULT_NAME" + new Option(REQUIRED_OPTIONS.vaultName.flags, "Vault name").env( + REQUIRED_OPTIONS.vaultName.env ) ) .addOption( @@ -105,7 +137,7 @@ export function parseArgs(argv: string[]): CliArgs { "[OPTIONAL] Line ending style: auto (platform default), lf, crlf" ) .default("auto") - .choices(["auto", "lf", "crlf"]) + .choices([...LINE_ENDING_MODES]) .env("VAULTLINK_LINE_ENDINGS") ) .addHelpText( @@ -144,22 +176,6 @@ Environment variables: const lineEndingsStr = (opts.lineEndings as string | undefined) ?? "auto"; /* eslint-enable @typescript-eslint/no-unsafe-type-assertion */ - const requireOption = (value: T | undefined, name: string): T => { - if (value === undefined) { - const option = program.options.find( - (o) => o.attributeName() === name - ); - const envHint = - option?.envVar !== undefined - ? ` (or set ${option.envVar})` - : ""; - throw new Error( - `required option '${option?.flags ?? name}' not specified${envHint}` - ); - } - return value; - }; - const requiredLocalPath = requireOption(localPath, "localPath"); const requiredRemoteUri = requireOption(remoteUri, "remoteUri"); const requiredToken = requireOption(token, "token"); @@ -187,13 +203,11 @@ Environment variables: } const logLevel = logLevelUpper; - const validLineEndings: readonly string[] = ["auto", "lf", "crlf"]; - const isLineEndingMode = (value: string): value is LineEndingMode => { - return validLineEndings.includes(value); - }; + const isLineEndingMode = (value: string): value is LineEndingMode => + (LINE_ENDING_MODES as readonly string[]).includes(value); if (!isLineEndingMode(lineEndingsStr)) { throw new Error( - `Invalid line endings mode '${lineEndingsStr}'. Valid values are: ${validLineEndings.join(", ")}` + `Invalid line endings mode '${lineEndingsStr}'. Valid values are: ${LINE_ENDING_MODES.join(", ")}` ); } const lineEndings = lineEndingsStr; diff --git a/frontend/local-client-cli/src/cli.ts b/frontend/local-client-cli/src/cli.ts index e06fda47..31c81d5c 100644 --- a/frontend/local-client-cli/src/cli.ts +++ b/frontend/local-client-cli/src/cli.ts @@ -7,12 +7,12 @@ import { DEFAULT_SETTINGS, Logger, LogLevel, - type LogLine, + LogLine, type SyncSettings, type StoredDatabase } from "sync-client"; -import { parseArgs } from "./args"; -import { NodeFileSystemOperations } from "./node-filesystem"; +import { parseArgs, type LineEndingMode } from "./args"; +import { NodeFileSystemOperations, VAULTLINK_DIR } from "./node-filesystem"; import { FileWatcher } from "./file-watcher"; import { formatLogLine } from "./logger-formatter"; import packageJson from "../package.json"; @@ -50,7 +50,7 @@ function createLogHandler(minLevel: LogLevel): (logLine: LogLine) => void { const HEALTH_CHECK_INTERVAL_MS = 30 * 1000; const PROGRESS_LOG_INTERVAL_MS = 2000; -function resolveLineEndings(mode: "auto" | "lf" | "crlf"): string { +function resolveLineEndings(mode: LineEndingMode): string { switch (mode) { case "lf": return "\n"; @@ -65,9 +65,13 @@ async function main(): Promise { const args = parseArgs(process.argv); const absolutePath = path.resolve(args.localPath); - const logger = new Logger(); const logHandler = createLogHandler(args.logLevel); - logger.onLogEmitted.add(logHandler); + // Boot-time messages are emitted directly through logHandler before the + // SyncClient (and its Logger) exist; afterwards every log line flows + // through client.logger. + const emitBoot = (level: LogLevel, message: string): void => { + logHandler(new LogLine(level, message)); + }; if (!fsSync.existsSync(absolutePath)) { fsSync.mkdirSync(absolutePath, { recursive: true }); @@ -76,27 +80,31 @@ async function main(): Promise { try { const stats = await fs.stat(absolutePath); if (!stats.isDirectory()) { - logger.error(`${absolutePath} is not a directory`); + emitBoot(LogLevel.ERROR, `${absolutePath} is not a directory`); process.exit(1); } } catch (error) { - logger.error( + emitBoot( + LogLevel.ERROR, `Cannot access directory ${absolutePath}: ${error instanceof Error ? error.message : String(error)}` ); process.exit(1); } if (!args.quiet) { - logger.info(`VaultLink Local CLI v${packageJson.version}`); - logger.info(`Local path: ${absolutePath}`); - logger.info(`Remote URI: ${args.remoteUri}`); - logger.info(`Vault name: ${args.vaultName}`); + emitBoot(LogLevel.INFO, `VaultLink Local CLI v${packageJson.version}`); + emitBoot(LogLevel.INFO, `Local path: ${absolutePath}`); + emitBoot(LogLevel.INFO, `Remote URI: ${args.remoteUri}`); + emitBoot(LogLevel.INFO, `Vault name: ${args.vaultName}`); if (args.lineEndings !== "auto") { - logger.info(`Line endings: ${args.lineEndings.toUpperCase()}`); + emitBoot( + LogLevel.INFO, + `Line endings: ${args.lineEndings.toUpperCase()}` + ); } } - const dataDir = path.join(absolutePath, ".vaultlink"); + const dataDir = path.join(absolutePath, VAULTLINK_DIR); const dataFile = path.join(dataDir, "sync-data.json"); await fs.mkdir(dataDir, { recursive: true }); @@ -105,8 +113,7 @@ async function main(): Promise { const ignorePatterns = [ ...(args.ignorePatterns ?? []), - ".vaultlink/**", - ".git/**" + `${VAULTLINK_DIR}/**` ]; const settings: SyncSettings = { @@ -134,7 +141,10 @@ async function main(): Promise { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion database = JSON.parse(content) as Partial; } catch { - logger.warn(`Cannot read data file at ${dataFile}`); + emitBoot( + LogLevel.WARNING, + `Cannot read data file at ${dataFile}` + ); } return { @@ -269,7 +279,6 @@ async function main(): Promise { } main().catch((error: unknown) => { - // Last-resort handler before the logger exists // eslint-disable-next-line no-console console.error( `Unexpected error: ${error instanceof Error ? error.message : String(error)}` diff --git a/frontend/local-client-cli/src/node-filesystem.ts b/frontend/local-client-cli/src/node-filesystem.ts index 7b736c22..ba95ab6a 100644 --- a/frontend/local-client-cli/src/node-filesystem.ts +++ b/frontend/local-client-cli/src/node-filesystem.ts @@ -1,6 +1,7 @@ import * as fs from "fs/promises"; import type { Dirent } from "fs"; import * as path from "path"; +import { randomUUID } from "crypto"; import type { FileSystemOperations, RelativePath, @@ -8,8 +9,13 @@ import type { } from "sync-client"; import { toUnixPath } from "./path-utils"; +// VaultLink's per-vault metadata directory. Holds the persisted sync database +// and the tmp files atomicWrite renames into place; the matching `${VAULTLINK_DIR}/**` +// ignore pattern keeps everything in here invisible to the file watcher. +export const VAULTLINK_DIR = ".vaultlink"; + export class NodeFileSystemOperations implements FileSystemOperations { - public constructor(private readonly basePath: string) {} + public constructor(private readonly basePath: string) { } public async listFilesRecursively( directory: RelativePath | undefined @@ -132,12 +138,37 @@ export class NodeFileSystemOperations implements FileSystemOperations { content: Uint8Array | string, encoding?: BufferEncoding ): Promise { - const tmpPath = fullPath + ".tmp"; - await fs.writeFile(tmpPath, content, encoding); - const fd = await fs.open(tmpPath, "r"); - await fd.datasync(); - await fd.close(); - await fs.rename(tmpPath, fullPath); + const tmpDir = path.join(this.basePath, VAULTLINK_DIR); + await fs.mkdir(tmpDir, { recursive: true }); + const tmpPath = path.join(tmpDir, `atomic-write-${randomUUID()}.tmp`); + try { + await fs.writeFile(tmpPath, content, encoding); + const fd = await fs.open(tmpPath, "r"); + try { + await fd.datasync(); + } finally { + await fd.close(); + } + await fs.rename(tmpPath, fullPath); + await this.syncDirectory(path.dirname(fullPath)); + } catch (error) { + await fs.unlink(tmpPath).catch(() => undefined); + throw error; + } + } + + // Make the rename durable by fsync'ing the destination's parent directory. + // Skipped on Windows: fsync on a directory handle isn't supported there + private async syncDirectory(dir: string): Promise { + if (process.platform === "win32") { + return; + } + const fd = await fs.open(dir, "r"); + try { + await fd.sync(); + } finally { + await fd.close(); + } } private async walkDirectory( diff --git a/frontend/local-client-cli/src/path-utils.ts b/frontend/local-client-cli/src/path-utils.ts index dd89fa67..1ead144c 100644 --- a/frontend/local-client-cli/src/path-utils.ts +++ b/frontend/local-client-cli/src/path-utils.ts @@ -5,8 +5,14 @@ export function toUnixPath(nativePath: string): string { return nativePath.split(path.sep).join(path.posix.sep); } -// Match a file path against a glob pattern -// Extends path.matchesGlob so that "dir/**" also matches the directory itself +// Match a file path against a glob pattern. +// +// Behaves like Node's path.matchesGlob with one extension: `dir/**` matches +// the directory `dir` itself, not only its descendants. The watcher feeds us +// a directory's relative path (e.g. ".git") at the same time it's about to +// recurse into it, and the natural way for users to write the ignore pattern +// is `.git/**` — under stdlib semantics that pattern would let the directory +// through and only block its children, defeating the prune. export function matchesGlob(filePath: string, pattern: string): boolean { if (pattern.endsWith("/**") && filePath === pattern.slice(0, -3)) { return true; diff --git a/frontend/obsidian-plugin/webpack.config.js b/frontend/obsidian-plugin/webpack.config.js index 794f30de..12844fd7 100644 --- a/frontend/obsidian-plugin/webpack.config.js +++ b/frontend/obsidian-plugin/webpack.config.js @@ -47,7 +47,6 @@ module.exports = (env, argv) => ({ const destinations = [ "/volumes/syncthing/Desktop/test/test/.obsidian/plugins/vault-link", "/volumes/syncthing/Desktop/test/test2/.obsidian/plugins/vault-link" - // "/home/andras/obsidian-test/.obsidian/plugins/vault-link" ]; destinations.forEach((destination) => { fs.copy(source, destination) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index f0c60c83..4d8218ba 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,57 +9,35 @@ "sync-client", "obsidian-plugin", "test-client", - "deterministic-tests", - "local-client-cli", - "history-ui" + "local-client-cli" ], "devDependencies": { "concurrently": "^9.2.1", - "eslint": "9.39.2", - "eslint-plugin-unused-imports": "^4.3.0", - "npm-check-updates": "^19.2.0", - "prettier": "^3.7.4", - "typescript-eslint": "8.49.0" - } - }, - "deterministic-tests": { - "version": "0.14.0", - "bin": { - "deterministic-tests": "dist/cli.js" - }, - "devDependencies": { - "@types/node": "^25.0.2", - "sync-client": "file:../sync-client", - "ts-loader": "^9.5.4", - "tslib": "2.8.1", - "typescript": "5.9.3", - "webpack": "^5.103.0", - "webpack-cli": "^6.0.1" - } - }, - "history-ui": { - "version": "0.14.0", - "devDependencies": { - "@sveltejs/vite-plugin-svelte": "^5.0.0", - "svelte": "^5.0.0", - "vite": "^6.0.0" + "eclint": "^2.8.1", + "eslint": "9.38.0", + "eslint-plugin-unused-imports": "^4.1.4", + "npm-check-updates": "^19.1.1", + "prettier": "^3.6.2", + "typescript-eslint": "8.41.0" } }, "local-client-cli": { "version": "0.14.0", + "dependencies": { + "commander": "^14.0.2", + "watcher": "^2.3.1" + }, "bin": { "vaultlink": "dist/cli.js" }, "devDependencies": { - "@types/node": "^25.0.2", - "commander": "^14.0.2", + "@types/node": "^24.8.1", "sync-client": "file:../sync-client", - "ts-loader": "^9.5.4", + "ts-loader": "^9.5.2", "tslib": "2.8.1", - "tsx": "^4.21.0", - "typescript": "5.9.3", - "watcher": "^2.3.1", - "webpack": "^5.103.0", + "tsx": "^4.20.6", + "typescript": "5.8.3", + "webpack": "^5.99.9", "webpack-cli": "^6.0.1" } }, @@ -74,6 +52,20 @@ "@marijn/find-cluster-break": "^1.0.0" } }, + "node_modules/@codemirror/view": { + "version": "6.38.1", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.1.tgz", + "integrity": "sha512-RmTOkE7hRU3OVREqFVITWHz6ocgBjv08GoePscAakgVQfciA3SGCEk7mb9IzwW61cKKmlTpHXG6DUE5Ubx+MGQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@codemirror/state": "^6.5.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, "node_modules/@discoveryjs/json-ext": { "version": "0.6.3", "dev": true, @@ -83,9 +75,9 @@ } }, "node_modules/@esbuild/aix-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", - "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.9.tgz", + "integrity": "sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==", "cpu": [ "ppc64" ], @@ -100,9 +92,9 @@ } }, "node_modules/@esbuild/android-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", - "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.9.tgz", + "integrity": "sha512-5WNI1DaMtxQ7t7B6xa572XMXpHAaI/9Hnhk8lcxF4zVN4xstUgTlvuGDorBguKEnZO70qwEcLpfifMLoxiPqHQ==", "cpu": [ "arm" ], @@ -117,9 +109,9 @@ } }, "node_modules/@esbuild/android-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", - "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.9.tgz", + "integrity": "sha512-IDrddSmpSv51ftWslJMvl3Q2ZT98fUSL2/rlUXuVqRXHCs5EUF1/f+jbjF5+NG9UffUDMCiTyh8iec7u8RlTLg==", "cpu": [ "arm64" ], @@ -134,9 +126,9 @@ } }, "node_modules/@esbuild/android-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", - "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.9.tgz", + "integrity": "sha512-I853iMZ1hWZdNllhVZKm34f4wErd4lMyeV7BLzEExGEIZYsOzqDWDf+y082izYUE8gtJnYHdeDpN/6tUdwvfiw==", "cpu": [ "x64" ], @@ -151,9 +143,9 @@ } }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", - "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.9.tgz", + "integrity": "sha512-XIpIDMAjOELi/9PB30vEbVMs3GV1v2zkkPnuyRRURbhqjyzIINwj+nbQATh4H9GxUgH1kFsEyQMxwiLFKUS6Rg==", "cpu": [ "arm64" ], @@ -168,9 +160,9 @@ } }, "node_modules/@esbuild/darwin-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", - "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.9.tgz", + "integrity": "sha512-jhHfBzjYTA1IQu8VyrjCX4ApJDnH+ez+IYVEoJHeqJm9VhG9Dh2BYaJritkYK3vMaXrf7Ogr/0MQ8/MeIefsPQ==", "cpu": [ "x64" ], @@ -185,9 +177,9 @@ } }, "node_modules/@esbuild/freebsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", - "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.9.tgz", + "integrity": "sha512-z93DmbnY6fX9+KdD4Ue/H6sYs+bhFQJNCPZsi4XWJoYblUqT06MQUdBCpcSfuiN72AbqeBFu5LVQTjfXDE2A6Q==", "cpu": [ "arm64" ], @@ -202,9 +194,9 @@ } }, "node_modules/@esbuild/freebsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", - "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.9.tgz", + "integrity": "sha512-mrKX6H/vOyo5v71YfXWJxLVxgy1kyt1MQaD8wZJgJfG4gq4DpQGpgTB74e5yBeQdyMTbgxp0YtNj7NuHN0PoZg==", "cpu": [ "x64" ], @@ -219,9 +211,9 @@ } }, "node_modules/@esbuild/linux-arm": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", - "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.9.tgz", + "integrity": "sha512-HBU2Xv78SMgaydBmdor38lg8YDnFKSARg1Q6AT0/y2ezUAKiZvc211RDFHlEZRFNRVhcMamiToo7bDx3VEOYQw==", "cpu": [ "arm" ], @@ -236,9 +228,9 @@ } }, "node_modules/@esbuild/linux-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", - "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.9.tgz", + "integrity": "sha512-BlB7bIcLT3G26urh5Dmse7fiLmLXnRlopw4s8DalgZ8ef79Jj4aUcYbk90g8iCa2467HX8SAIidbL7gsqXHdRw==", "cpu": [ "arm64" ], @@ -253,9 +245,9 @@ } }, "node_modules/@esbuild/linux-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", - "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.9.tgz", + "integrity": "sha512-e7S3MOJPZGp2QW6AK6+Ly81rC7oOSerQ+P8L0ta4FhVi+/j/v2yZzx5CqqDaWjtPFfYz21Vi1S0auHrap3Ma3A==", "cpu": [ "ia32" ], @@ -270,9 +262,9 @@ } }, "node_modules/@esbuild/linux-loong64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", - "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.9.tgz", + "integrity": "sha512-Sbe10Bnn0oUAB2AalYztvGcK+o6YFFA/9829PhOCUS9vkJElXGdphz0A3DbMdP8gmKkqPmPcMJmJOrI3VYB1JQ==", "cpu": [ "loong64" ], @@ -287,9 +279,9 @@ } }, "node_modules/@esbuild/linux-mips64el": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", - "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.9.tgz", + "integrity": "sha512-YcM5br0mVyZw2jcQeLIkhWtKPeVfAerES5PvOzaDxVtIyZ2NUBZKNLjC5z3/fUlDgT6w89VsxP2qzNipOaaDyA==", "cpu": [ "mips64el" ], @@ -304,9 +296,9 @@ } }, "node_modules/@esbuild/linux-ppc64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", - "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.9.tgz", + "integrity": "sha512-++0HQvasdo20JytyDpFvQtNrEsAgNG2CY1CLMwGXfFTKGBGQT3bOeLSYE2l1fYdvML5KUuwn9Z8L1EWe2tzs1w==", "cpu": [ "ppc64" ], @@ -321,9 +313,9 @@ } }, "node_modules/@esbuild/linux-riscv64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", - "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.9.tgz", + "integrity": "sha512-uNIBa279Y3fkjV+2cUjx36xkx7eSjb8IvnL01eXUKXez/CBHNRw5ekCGMPM0BcmqBxBcdgUWuUXmVWwm4CH9kg==", "cpu": [ "riscv64" ], @@ -338,9 +330,9 @@ } }, "node_modules/@esbuild/linux-s390x": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", - "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.9.tgz", + "integrity": "sha512-Mfiphvp3MjC/lctb+7D287Xw1DGzqJPb/J2aHHcHxflUo+8tmN/6d4k6I2yFR7BVo5/g7x2Monq4+Yew0EHRIA==", "cpu": [ "s390x" ], @@ -355,13 +347,14 @@ } }, "node_modules/@esbuild/linux-x64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.1.tgz", - "integrity": "sha512-z3H/HYI9MM0HTv3hQZ81f+AKb+yEoCRlUby1F80vbQ5XdzEMyY/9iNlAmhqiBKw4MJXwfgsh7ERGEOhrM1niMA==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.9.tgz", + "integrity": "sha512-iSwByxzRe48YVkmpbgoxVzn76BXjlYFXC7NvLYq+b+kDjyyk30J0JY47DIn8z1MO3K0oSl9fZoRmZPQI4Hklzg==", "cpu": [ "x64" ], "dev": true, + "license": "MIT", "optional": true, "os": [ "linux" @@ -371,9 +364,9 @@ } }, "node_modules/@esbuild/netbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", - "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.9.tgz", + "integrity": "sha512-9jNJl6FqaUG+COdQMjSCGW4QiMHH88xWbvZ+kRVblZsWrkXlABuGdFJ1E9L7HK+T0Yqd4akKNa/lO0+jDxQD4Q==", "cpu": [ "arm64" ], @@ -388,9 +381,9 @@ } }, "node_modules/@esbuild/netbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", - "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.9.tgz", + "integrity": "sha512-RLLdkflmqRG8KanPGOU7Rpg829ZHu8nFy5Pqdi9U01VYtG9Y0zOG6Vr2z4/S+/3zIyOxiK6cCeYNWOFR9QP87g==", "cpu": [ "x64" ], @@ -405,9 +398,9 @@ } }, "node_modules/@esbuild/openbsd-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", - "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.9.tgz", + "integrity": "sha512-YaFBlPGeDasft5IIM+CQAhJAqS3St3nJzDEgsgFixcfZeyGPCd6eJBWzke5piZuZ7CtL656eOSYKk4Ls2C0FRQ==", "cpu": [ "arm64" ], @@ -422,9 +415,9 @@ } }, "node_modules/@esbuild/openbsd-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", - "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.9.tgz", + "integrity": "sha512-1MkgTCuvMGWuqVtAvkpkXFmtL8XhWy+j4jaSO2wxfJtilVCi0ZE37b8uOdMItIHz4I6z1bWWtEX4CJwcKYLcuA==", "cpu": [ "x64" ], @@ -439,9 +432,9 @@ } }, "node_modules/@esbuild/openharmony-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", - "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.9.tgz", + "integrity": "sha512-4Xd0xNiMVXKh6Fa7HEJQbrpP3m3DDn43jKxMjxLLRjWnRsfxjORYJlXPO4JNcXtOyfajXorRKY9NkOpTHptErg==", "cpu": [ "arm64" ], @@ -456,9 +449,9 @@ } }, "node_modules/@esbuild/sunos-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", - "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.9.tgz", + "integrity": "sha512-WjH4s6hzo00nNezhp3wFIAfmGZ8U7KtrJNlFMRKxiI9mxEK1scOMAaa9i4crUtu+tBr+0IN6JCuAcSBJZfnphw==", "cpu": [ "x64" ], @@ -473,9 +466,9 @@ } }, "node_modules/@esbuild/win32-arm64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", - "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.9.tgz", + "integrity": "sha512-mGFrVJHmZiRqmP8xFOc6b84/7xa5y5YvR1x8djzXpJBSv/UsNK6aqec+6JDjConTgvvQefdGhFDAs2DLAds6gQ==", "cpu": [ "arm64" ], @@ -490,9 +483,9 @@ } }, "node_modules/@esbuild/win32-ia32": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", - "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.9.tgz", + "integrity": "sha512-b33gLVU2k11nVx1OhX3C8QQP6UHQK4ZtN56oFWvVXvz2VkDoe6fbG8TOgHFxEvqeqohmRnIHe5A1+HADk4OQww==", "cpu": [ "ia32" ], @@ -507,9 +500,9 @@ } }, "node_modules/@esbuild/win32-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", - "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.9.tgz", + "integrity": "sha512-PPOl1mi6lpLNQxnGoyAfschAodRFYXJ+9fs6WHXz7CSWKbOqiMZsubC+BQsVKuul+3vKLuwTHsS2c2y9EoKwxQ==", "cpu": [ "x64" ], @@ -577,22 +570,24 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", - "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.1.tgz", + "integrity": "sha512-csZAzkNhsgwb0I/UAV6/RGFTbiakPCf0ZrGmrIxQpYvGZ00PhTkSnyKNolphgIvmnJeGw6rcGVEXfTzUnFuEvw==", "dev": true, + "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0" + "@eslint/core": "^0.16.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/core": { - "version": "0.17.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", - "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.16.0.tgz", + "integrity": "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==", "dev": true, + "license": "Apache-2.0", "dependencies": { "@types/json-schema": "^7.0.15" }, @@ -623,10 +618,11 @@ } }, "node_modules/@eslint/js": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", - "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "version": "9.38.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.38.0.tgz", + "integrity": "sha512-UZ1VpFvXf9J06YG9xQBdnzU+kthors6KjhMAl6f4gH4usHyh31rUf2DLGInT8RFYIReYXNSydgPY0V2LuWgl7A==", "dev": true, + "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -645,12 +641,13 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", - "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.0.tgz", + "integrity": "sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==", "dev": true, + "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.17.0", + "@eslint/core": "^0.16.0", "levn": "^0.4.1" }, "engines": { @@ -713,27 +710,6 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@isaacs/balanced-match": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz", - "integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==", - "dev": true, - "engines": { - "node": "20 || >=22" - } - }, - "node_modules/@isaacs/brace-expansion": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz", - "integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==", - "dev": true, - "dependencies": { - "@isaacs/balanced-match": "^4.0.1" - }, - "engines": { - "node": "20 || >=22" - } - }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -745,17 +721,6 @@ "@jridgewell/trace-mapping": "^0.3.24" } }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "dev": true, @@ -774,9 +739,7 @@ } }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "version": "1.5.0", "dev": true, "license": "MIT" }, @@ -796,8 +759,45 @@ "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", "dev": true, + "license": "MIT" + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, "license": "MIT", - "peer": true + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } }, "node_modules/@parcel/watcher": { "version": "2.5.1", @@ -872,520 +872,87 @@ "url": "https://opencollective.com/parcel" } }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", - "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", - "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", - "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", - "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", - "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", - "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", - "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", - "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", - "cpu": [ - "arm" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", - "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", - "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", - "cpu": [ - "arm64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", - "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", - "cpu": [ - "loong64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", - "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", - "cpu": [ - "loong64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", - "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", - "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", - "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", - "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", - "cpu": [ - "riscv64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", - "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", - "cpu": [ - "s390x" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", - "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "glibc" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", - "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", - "cpu": [ - "x64" - ], - "dev": true, - "libc": [ - "musl" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", - "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", - "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", - "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", - "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", - "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", - "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/@sentry-internal/browser-utils": { - "version": "10.30.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.30.0.tgz", - "integrity": "sha512-dVsHTUbvgaLNetWAQC6yJFnmgD0xUbVgCkmzNB7S28wIP570GcZ4cxFGPOkXbPx6dEBUfoOREeXzLqjJLtJPfg==", + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.8.0.tgz", + "integrity": "sha512-FaQX9eefc8sh3h3ZQy16U73KiH0xgDldXnrFiWK6OeWg8X4bJpnYbLqEi96LgHiQhjnnz+UQP1GDzH5oFuu5fA==", "dev": true, + "license": "MIT", "dependencies": { - "@sentry/core": "10.30.0" + "@sentry/core": "10.8.0" }, "engines": { "node": ">=18" } }, "node_modules/@sentry-internal/feedback": { - "version": "10.30.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.30.0.tgz", - "integrity": "sha512-+bnQZ6SNF265nTXrRlXTmq5Ila1fRfraDOAahlOT/VM4j6zqCvNZzmeDD9J6IbxiAdhlp/YOkrG3zbr5vgYo0A==", + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.8.0.tgz", + "integrity": "sha512-n7SqgFQItq4QSPG7bCjcZcIwK6AatKnnmSDJ/i6e8jXNIyLwkEuY2NyvTXACxVdO/kafGD5VmrwnTo3Ekc1AMg==", "dev": true, + "license": "MIT", "dependencies": { - "@sentry/core": "10.30.0" + "@sentry/core": "10.8.0" }, "engines": { "node": ">=18" } }, "node_modules/@sentry-internal/replay": { - "version": "10.30.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.30.0.tgz", - "integrity": "sha512-Pj/fMIZQkXzIw6YWpxKWUE5+GXffKq6CgXwHszVB39al1wYz1gTIrTqJqt31IBLIihfCy8XxYddglR2EW0BVIQ==", + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.8.0.tgz", + "integrity": "sha512-9+qDEoEjv4VopLuOzK1zM4LcvcUsvB5N0iJ+FRCM3XzzOCbebJOniXTQbt5HflJc3XLnQNKFdKfTfgj8M/0RKQ==", "dev": true, + "license": "MIT", "dependencies": { - "@sentry-internal/browser-utils": "10.30.0", - "@sentry/core": "10.30.0" + "@sentry-internal/browser-utils": "10.8.0", + "@sentry/core": "10.8.0" }, "engines": { "node": ">=18" } }, "node_modules/@sentry-internal/replay-canvas": { - "version": "10.30.0", - "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.30.0.tgz", - "integrity": "sha512-RIlIz+XQ4DUWaN60CjfmicJq2O2JRtDKM5lw0wB++M5ha0TBh6rv+Ojf6BDgiV3LOQ7lZvCM57xhmNUtrGmelg==", + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.8.0.tgz", + "integrity": "sha512-jC4OOwiNgrlIPeXIPMLkaW53BSS1do+toYHoWzzO5AXGpN6jRhanoSj36FpVuH2N3kFnxKVfVxrwh8L+/3vFWg==", "dev": true, + "license": "MIT", "dependencies": { - "@sentry-internal/replay": "10.30.0", - "@sentry/core": "10.30.0" + "@sentry-internal/replay": "10.8.0", + "@sentry/core": "10.8.0" }, "engines": { "node": ">=18" } }, "node_modules/@sentry/browser": { - "version": "10.30.0", - "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.30.0.tgz", - "integrity": "sha512-7M/IJUMLo0iCMLNxDV/OHTPI0WKyluxhCcxXJn7nrCcolu8A1aq9R8XjKxm0oTCO8ht5pz8bhGXUnYJj4eoEBA==", + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.8.0.tgz", + "integrity": "sha512-2J7HST8/ixCaboq17yFn/j/OEokXSXoCBMXRrFx4FKJggKWZ90e2Iau5mP/IPPhrW+W9zCptCgNMY0167wS4qA==", "dev": true, + "license": "MIT", "dependencies": { - "@sentry-internal/browser-utils": "10.30.0", - "@sentry-internal/feedback": "10.30.0", - "@sentry-internal/replay": "10.30.0", - "@sentry-internal/replay-canvas": "10.30.0", - "@sentry/core": "10.30.0" + "@sentry-internal/browser-utils": "10.8.0", + "@sentry-internal/feedback": "10.8.0", + "@sentry-internal/replay": "10.8.0", + "@sentry-internal/replay-canvas": "10.8.0", + "@sentry/core": "10.8.0" }, "engines": { "node": ">=18" } }, "node_modules/@sentry/core": { - "version": "10.30.0", - "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.30.0.tgz", - "integrity": "sha512-IfNuqIoGVO9pwphwbOptAEJJI1SCAfewS5LBU1iL7hjPBHYAnE8tCVzyZN+pooEkQQ47Q4rGanaG1xY8mjTT1A==", + "version": "10.8.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.8.0.tgz", + "integrity": "sha512-scYzM/UOItu4PjEq6CpHLdArpXjIS0laHYxE4YjkIbYIH6VMcXGQbD/FSBClsnCr1wXRnlXfXBzj0hrQAFyw+Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=18" } }, - "node_modules/@sveltejs/acorn-typescript": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.9.tgz", - "integrity": "sha512-lVJX6qEgs/4DOcRTpo56tmKzVPtoWAaVbL4hfO7t7NVwl9AAXzQR6cihesW1BmNMPl+bK6dreu2sOKBP2Q9CIA==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "acorn": "^8.9.0" - } - }, - "node_modules/@sveltejs/vite-plugin-svelte": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-5.1.1.tgz", - "integrity": "sha512-Y1Cs7hhTc+a5E9Va/xwKlAJoariQyHY+5zBgCZg4PFWNYQ1nMN9sjK1zhw1gK69DuqVP++sht/1GZg1aRwmAXQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@sveltejs/vite-plugin-svelte-inspector": "^4.0.1", - "debug": "^4.4.1", - "deepmerge": "^4.3.1", - "kleur": "^4.1.5", - "magic-string": "^0.30.17", - "vitefu": "^1.0.6" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22" - }, - "peerDependencies": { - "svelte": "^5.0.0", - "vite": "^6.0.0" - } - }, - "node_modules/@sveltejs/vite-plugin-svelte-inspector": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte-inspector/-/vite-plugin-svelte-inspector-4.0.1.tgz", - "integrity": "sha512-J/Nmb2Q2y7mck2hyCX4ckVHcR5tu2J+MtBEQqpDrrgELZ2uvraQcK/ioCV61AqkdXFgriksOKIceDcQmqnGhVw==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^4.3.7" - }, - "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22" - }, - "peerDependencies": { - "@sveltejs/vite-plugin-svelte": "^5.0.0", - "svelte": "^5.0.0", - "vite": "^6.0.0" - } - }, "node_modules/@types/codemirror": { "version": "5.60.8", "dev": true, @@ -1413,10 +980,9 @@ } }, "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==", - "dev": true + "version": "1.0.7", + "dev": true, + "license": "MIT" }, "node_modules/@types/json-schema": { "version": "7.0.15", @@ -1424,12 +990,13 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "25.0.2", - "resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.2.tgz", - "integrity": "sha512-gWEkeiyYE4vqjON/+Obqcoeffmk0NF15WSBwSs7zwVA2bAbTaE0SJ7P0WNGoJn8uE7fiaV5a7dKYIJriEqOrmA==", + "version": "24.8.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.8.1.tgz", + "integrity": "sha512-alv65KGRadQVfVcG69MuB4IzdYVpRwMG/mq8KWOaoOdyY617P5ivaDiMCGOFDWD2sAn5Q0mR3mRtUOgm99hL9Q==", "dev": true, + "license": "MIT", "dependencies": { - "undici-types": "~7.16.0" + "undici-types": "~7.14.0" } }, "node_modules/@types/tern": { @@ -1440,25 +1007,20 @@ "@types/estree": "*" } }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "dev": true, - "license": "MIT" - }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.49.0.tgz", - "integrity": "sha512-JXij0vzIaTtCwu6SxTh8qBc66kmf1xs7pI4UOiMDFVct6q86G0Zs7KRcEoJgY3Cav3x5Tq0MF5jwgpgLqgKG3A==", + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.41.0.tgz", + "integrity": "sha512-8fz6oa6wEKZrhXWro/S3n2eRJqlRcIa6SlDh59FXJ5Wp5XRZ8B9ixpJDcjadHq47hMx0u+HW6SNa6LjJQ6NLtw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.49.0", - "@typescript-eslint/type-utils": "8.49.0", - "@typescript-eslint/utils": "8.49.0", - "@typescript-eslint/visitor-keys": "8.49.0", + "@typescript-eslint/scope-manager": "8.41.0", + "@typescript-eslint/type-utils": "8.41.0", + "@typescript-eslint/utils": "8.41.0", + "@typescript-eslint/visitor-keys": "8.41.0", + "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" @@ -1471,7 +1033,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.49.0", + "@typescript-eslint/parser": "^8.41.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } @@ -1485,16 +1047,17 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.49.0.tgz", - "integrity": "sha512-N9lBGA9o9aqb1hVMc9hzySbhKibHmB+N3IpoShyV6HyQYRGIhlrO5rQgttypi+yEeKsKI4idxC8Jw6gXKD4THA==", + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.41.0.tgz", + "integrity": "sha512-gTtSdWX9xiMPA/7MV9STjJOOYtWwIJIYxkQxnSV1U3xcE+mnJSH3f6zI0RYP+ew66WSlZ5ed+h0VCxsvdC1jJg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "@typescript-eslint/scope-manager": "8.49.0", - "@typescript-eslint/types": "8.49.0", - "@typescript-eslint/typescript-estree": "8.49.0", - "@typescript-eslint/visitor-keys": "8.49.0", + "@typescript-eslint/scope-manager": "8.41.0", + "@typescript-eslint/types": "8.41.0", + "@typescript-eslint/typescript-estree": "8.41.0", + "@typescript-eslint/visitor-keys": "8.41.0", "debug": "^4.3.4" }, "engines": { @@ -1510,13 +1073,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.49.0.tgz", - "integrity": "sha512-/wJN0/DKkmRUMXjZUXYZpD1NEQzQAAn9QWfGwo+Ai8gnzqH7tvqS7oNVdTjKqOcPyVIdZdyCMoqN66Ia789e7g==", + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.41.0.tgz", + "integrity": "sha512-b8V9SdGBQzQdjJ/IO3eDifGpDBJfvrNTp2QD9P2BeqWTGrRibgfgIlBSw6z3b6R7dPzg752tOs4u/7yCLxksSQ==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.49.0", - "@typescript-eslint/types": "^8.49.0", + "@typescript-eslint/tsconfig-utils": "^8.41.0", + "@typescript-eslint/types": "^8.41.0", "debug": "^4.3.4" }, "engines": { @@ -1531,13 +1095,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.49.0.tgz", - "integrity": "sha512-npgS3zi+/30KSOkXNs0LQXtsg9ekZ8OISAOLGWA/ZOEn0ZH74Ginfl7foziV8DT+D98WfQ5Kopwqb/PZOaIJGg==", + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.41.0.tgz", + "integrity": "sha512-n6m05bXn/Cd6DZDGyrpXrELCPVaTnLdPToyhBoFkLIMznRUQUEQdSp96s/pcWSQdqOhrgR1mzJ+yItK7T+WPMQ==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.49.0", - "@typescript-eslint/visitor-keys": "8.49.0" + "@typescript-eslint/types": "8.41.0", + "@typescript-eslint/visitor-keys": "8.41.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1548,10 +1113,11 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.49.0.tgz", - "integrity": "sha512-8prixNi1/6nawsRYxet4YOhnbW+W9FK/bQPxsGB1D3ZrDzbJ5FXw5XmzxZv82X3B+ZccuSxo/X8q9nQ+mFecWA==", + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.41.0.tgz", + "integrity": "sha512-TDhxYFPUYRFxFhuU5hTIJk+auzM/wKvWgoNYOPcOf6i4ReYlOoYN8q1dV5kOTjNQNJgzWN3TUUQMtlLOcUgdUw==", "dev": true, + "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -1564,14 +1130,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.49.0.tgz", - "integrity": "sha512-KTExJfQ+svY8I10P4HdxKzWsvtVnsuCifU5MvXrRwoP2KOlNZ9ADNEWWsQTJgMxLzS5VLQKDjkCT/YzgsnqmZg==", + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.41.0.tgz", + "integrity": "sha512-63qt1h91vg3KsjVVonFJWjgSK7pZHSQFKH6uwqxAH9bBrsyRhO6ONoKyXxyVBzG1lJnFAJcKAcxLS54N1ee1OQ==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.49.0", - "@typescript-eslint/typescript-estree": "8.49.0", - "@typescript-eslint/utils": "8.49.0", + "@typescript-eslint/types": "8.41.0", + "@typescript-eslint/typescript-estree": "8.41.0", + "@typescript-eslint/utils": "8.41.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, @@ -1588,10 +1155,11 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.49.0.tgz", - "integrity": "sha512-e9k/fneezorUo6WShlQpMxXh8/8wfyc+biu6tnAqA81oWrEic0k21RHzP9uqqpyBBeBKu4T+Bsjy9/b8u7obXQ==", + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.41.0.tgz", + "integrity": "sha512-9EwxsWdVqh42afLbHP90n2VdHaWU/oWgbH2P0CfcNfdKL7CuKpwMQGjwev56vWu9cSKU7FWSu6r9zck6CVfnag==", "dev": true, + "license": "MIT", "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" }, @@ -1601,19 +1169,21 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.49.0.tgz", - "integrity": "sha512-jrLdRuAbPfPIdYNppHJ/D0wN+wwNfJ32YTAm10eJVsFmrVpXQnDWBn8niCSMlWjvml8jsce5E/O+86IQtTbJWA==", + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.41.0.tgz", + "integrity": "sha512-D43UwUYJmGhuwHfY7MtNKRZMmfd8+p/eNSfFe6tH5mbVDto+VQCayeAt35rOx3Cs6wxD16DQtIKw/YXxt5E0UQ==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.49.0", - "@typescript-eslint/tsconfig-utils": "8.49.0", - "@typescript-eslint/types": "8.49.0", - "@typescript-eslint/visitor-keys": "8.49.0", + "@typescript-eslint/project-service": "8.41.0", + "@typescript-eslint/tsconfig-utils": "8.41.0", + "@typescript-eslint/types": "8.41.0", + "@typescript-eslint/visitor-keys": "8.41.0", "debug": "^4.3.4", + "fast-glob": "^3.3.2", + "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", - "tinyglobby": "^0.2.15", "ts-api-utils": "^2.1.0" }, "engines": { @@ -1632,6 +1202,7 @@ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } @@ -1641,6 +1212,7 @@ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, @@ -1652,15 +1224,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.49.0.tgz", - "integrity": "sha512-N3W7rJw7Rw+z1tRsHZbK395TWSYvufBXumYtEGzypgMUthlg0/hmCImeA8hgO2d2G4pd7ftpxxul2J8OdtdaFA==", + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.41.0.tgz", + "integrity": "sha512-udbCVstxZ5jiPIXrdH+BZWnPatjlYwJuJkDA4Tbo3WyYLh8NvB+h/bKeSZHDOFKfphsZYJQqaFtLeXEqurQn1A==", "dev": true, + "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.49.0", - "@typescript-eslint/types": "8.49.0", - "@typescript-eslint/typescript-estree": "8.49.0" + "@typescript-eslint/scope-manager": "8.41.0", + "@typescript-eslint/types": "8.41.0", + "@typescript-eslint/typescript-estree": "8.41.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1675,12 +1248,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.49.0.tgz", - "integrity": "sha512-LlKaciDe3GmZFphXIc79THF/YYBugZ7FS1pO581E/edlVVNbZKDy93evqmrfQ9/Y4uN0vVhX4iuchq26mK/iiA==", + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.41.0.tgz", + "integrity": "sha512-+GeGMebMCy0elMNg67LRNoVnUFPIm37iu5CmHESVx56/9Jsfdpsvbv605DQ81Pi/x11IdKUsS5nzgTYbCQU9fg==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.49.0", + "@typescript-eslint/types": "8.41.0", "eslint-visitor-keys": "^4.2.1" }, "engines": { @@ -1879,6 +1453,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1886,18 +1461,6 @@ "node": ">=0.4.0" } }, - "node_modules/acorn-import-phases": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", - "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", - "dev": true, - "engines": { - "node": ">=10.13.0" - }, - "peerDependencies": { - "acorn": "^8.14.0" - } - }, "node_modules/acorn-jsx": { "version": "5.3.2", "dev": true, @@ -1922,6 +1485,7 @@ "version": "6.12.6", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -1977,6 +1541,68 @@ "ajv": "^6.9.1" } }, + "node_modules/ansi-colors": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-1.1.0.tgz", + "integrity": "sha512-SFKX67auSNoVR38N3L+nvsPjOE0bybKTYbkf5tRvushrAPQ9V75huw0ZxBkKVeRU9kqH3d6HA4xTckbwZ4ixmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-wrap": "^0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ansi-cyan": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/ansi-cyan/-/ansi-cyan-0.1.1.tgz", + "integrity": "sha512-eCjan3AVo/SxZ0/MyIYRtkpxIu/H3xZN7URr1vXVrISxeyz8fUFz0FJziamK4sS8I+t35y4rHg1b2PklyBe/7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-wrap": "0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ansi-escapes": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-3.2.0.tgz", + "integrity": "sha512-cBhpre4ma+U0T1oM5fXg7Dy1Jw7zzwv7lt/GoCpr+hDQJoYnKVPLL4dCvSEFMmQurOQvSrwT7SL/DAlhBI97RQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ansi-gray": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/ansi-gray/-/ansi-gray-0.1.1.tgz", + "integrity": "sha512-HrgGIZUl8h2EHuZaU9hTR/cU5nhKxpVE1V6kdGsQ8e4zirElJ5fvtfc8N7Q1oq1aatO275i8pUFUCpNWCAnVWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-wrap": "0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/ansi-red": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/ansi-red/-/ansi-red-0.1.1.tgz", + "integrity": "sha512-ewaIr5y+9CUTGFwZfpECUbFlGcC0GCw1oqR9RI6h1gQCd9Aj2GxSckCnPsVJnmfMZbwFYE+leZGASgkWl06Jow==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-wrap": "0.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ansi-regex": { "version": "5.0.1", "dev": true, @@ -1999,29 +1625,137 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/ansi-wrap": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/ansi-wrap/-/ansi-wrap-0.1.0.tgz", + "integrity": "sha512-ZyznvL8k/FZeQHr2T6LzcJ/+vBApDnMNZvfVFy3At0knswWd6rJ3/0Hhmpu8oqa6C92npmozs890sX9Dl6q+Qw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/append-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/append-buffer/-/append-buffer-1.0.2.tgz", + "integrity": "sha512-WLbYiXzD3y/ATLZFufV/rZvWdZOs+Z/+5v1rBZ463Jn398pa6kcde27cvozYnBoxXblGZTFfoPpsaEw0orU5BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-equal": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/argparse": { "version": "2.0.1", "dev": true, "license": "Python-2.0" }, - "node_modules/aria-query": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", - "integrity": "sha512-Z/ZeOgVl7bcSYZ/u/rh0fOpvEpq//LZmdbkXyc7syVzjPAhfOa9ebsdTSjEBDU4vs5nC98Kfduj1uFo0qyET3g==", + "node_modules/arr-diff": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz", + "integrity": "sha512-YVIQ82gZPGBebQV/a8dar4AitzCQs0jjXwMPZllpXMaGjXPYVUawSxQrRsjhjupyVxEvbHgUmIhKVlND+j02kA==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "engines": { - "node": ">= 0.4" + "node": ">=0.10.0" } }, - "node_modules/axobject-query": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", - "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "node_modules/arr-flatten": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/arr-flatten/-/arr-flatten-1.1.0.tgz", + "integrity": "sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg==", "dev": true, - "license": "Apache-2.0", + "license": "MIT", "engines": { - "node": ">= 0.4" + "node": ">=0.10.0" + } + }, + "node_modules/arr-union": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz", + "integrity": "sha512-sKpyeERZ02v1FeCZT8lrfJq5u6goHCtpTAzPwJYe7c8SPFOboNjNg1vz2L4VTn9T4PQxEx13TbXLmYUcS6Ug7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/array-differ": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-differ/-/array-differ-1.0.0.tgz", + "integrity": "sha512-LeZY+DZDRnvP7eMuQ6LHfCzUGxAAIViUBliK24P3hWXL6y4SortgR6Nim6xrkfSLlmH0+k+9NYNwVC2s53ZrYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/array-slice": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/array-slice/-/array-slice-0.2.3.tgz", + "integrity": "sha512-rlVfZW/1Ph2SNySXwR9QYkChp8EkOEiTMO5Vwx60usw04i4nWemkm9RXmQqgkQFaLHsqLuADvjp6IfgL9l2M8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/array-union": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz", + "integrity": "sha512-Dxr6QJj/RdU/hCaBjOfxW+q6lyuVE6JFWIrAUpuOOhoJJoQ99cUn3igRaHVB5P9WrgFVN0FfArM3x0cueOU8ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-uniq": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/array-uniq": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-uniq/-/array-uniq-1.0.3.tgz", + "integrity": "sha512-MNha4BWQ6JbwhFhj03YK552f7cb3AzoE8SzeljgChvL1dl3IcvggXVz1DilzySZkCja+CXuZbdW7yATchWn8/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/arrify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", + "integrity": "sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/assign-symbols": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz", + "integrity": "sha512-Q+JC7Whu8HhmTdBph/Tq59IoRtoy6KAm5zzPv00WdujX82lbAL8K7WVjne7vdCsAmbF4AYaDOPyO3k0kl8qIrw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/axios": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.18.1.tgz", + "integrity": "sha512-0BfJq4NSfQXd+SkFdrvFbG7addhYSBA2mQwISr46pD6E5iqkWg02RAs8vyTT/j0RTnoYmeXauBuSv1qKwR179g==", + "deprecated": "Critical security vulnerability fixed in v0.21.1. For more information, see https://github.com/axios/axios/pull/3410", + "dev": true, + "license": "MIT", + "dependencies": { + "follow-redirects": "1.5.10", + "is-buffer": "^2.0.2" } }, "node_modules/balanced-match": { @@ -2029,15 +1763,6 @@ "dev": true, "license": "MIT" }, - "node_modules/baseline-browser-mapping": { - "version": "2.9.7", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.7.tgz", - "integrity": "sha512-k9xFKplee6KIio3IDbwj+uaCLpqzOwakOgmqzPezM0sFJlFKcg30vk2wOiAJtkTSfx0SSQDSe8q+mWA/fSH5Zg==", - "dev": true, - "bin": { - "baseline-browser-mapping": "dist/cli.js" - } - }, "node_modules/big.js": { "version": "5.2.2", "dev": true, @@ -2046,6 +1771,16 @@ "node": "*" } }, + "node_modules/bignumber.js": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-2.4.0.tgz", + "integrity": "sha512-uw4ra6Cv483Op/ebM0GBKKfxZlSmn6NgFRby5L3yGTlunLj53KQgndDlqy2WVFOwgvurocApYkSud0aO+mvrpQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, "node_modules/brace-expansion": { "version": "1.1.12", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", @@ -2069,9 +1804,7 @@ } }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.24.4", "dev": true, "funding": [ { @@ -2088,12 +1821,12 @@ } ], "license": "MIT", + "peer": true, "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "caniuse-lite": "^1.0.30001688", + "electron-to-chromium": "^1.5.73", + "node-releases": "^2.0.19", + "update-browserslist-db": "^1.1.1" }, "bin": { "browserslist": "cli.js" @@ -2102,16 +1835,108 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer-equal": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal/-/buffer-equal-1.0.1.tgz", + "integrity": "sha512-QoV3ptgEaQpvVwbXdSO39iqPQTCxSF7A5U99AxbHYqUdCizL/lH2Z0A2y6nbZucxMEOtNyZfG2s6gsVugGpKkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/buffer-equals": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/buffer-equals/-/buffer-equals-1.0.4.tgz", + "integrity": "sha512-99MsCq0j5+RhubVEtKQgKaD6EM+UP3xJgIvQqwJ3SOLDUekzxMX1ylXBng+Wa2sh7mGT0W6RUly8ojjr1Tt6nA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "dev": true, "license": "MIT" }, + "node_modules/buffered-spawn": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/buffered-spawn/-/buffered-spawn-3.3.2.tgz", + "integrity": "sha512-YVdiyWEbFCH+lu3USRFoH6UtvS3mr/e/obxZNbOkbbL3heLEUYb3YpTjKUQFWt5d3k9ZILabY8Kh2pp+i4SQqg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^4.0.0" + }, + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/buffered-spawn/node_modules/cross-spawn": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-4.0.2.tgz", + "integrity": "sha512-yAXz/pA1tD8Gtg2S98Ekf/sewp3Lcp3YoFKJ4Hkp5h5yLWnKVTDU0kwjKJ8NDCYcfTLfyGkzTikst+jWypT1iA==", + "dev": true, + "license": "MIT", + "dependencies": { + "lru-cache": "^4.0.1", + "which": "^1.2.9" + } + }, + "node_modules/buffered-spawn/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/bufferstreams": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/bufferstreams/-/bufferstreams-2.0.1.tgz", + "integrity": "sha512-ZswyIoBfFb3cVDsnZLLj2IDJ/0ppYdil/v2EGlZXvoefO689FokEmFEldhN5dV7R2QBxFneqTJOMIpfqhj+n0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "^2.3.6" + }, + "engines": { + "node": ">=6.9.5" + } + }, "node_modules/byte-base64": { "version": "1.1.0", "dev": true, "license": "MIT" }, + "node_modules/call-bind": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", + "integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.0", + "es-define-property": "^1.0.0", + "get-intrinsic": "^1.2.4", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/call-bind-apply-helpers": { "version": "1.0.2", "dev": true, @@ -2147,10 +1972,18 @@ "node": ">=6" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/caniuse-lite": { - "version": "1.0.30001760", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001760.tgz", - "integrity": "sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==", + "version": "1.0.30001707", "dev": true, "funding": [ { @@ -2165,7 +1998,8 @@ "type": "github", "url": "https://github.com/sponsors/ai" } - ] + ], + "license": "CC-BY-4.0" }, "node_modules/chalk": { "version": "4.1.2", @@ -2193,6 +2027,16 @@ "node": ">=8" } }, + "node_modules/checkstyle-formatter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/checkstyle-formatter/-/checkstyle-formatter-1.1.0.tgz", + "integrity": "sha512-mak+5ooX5cDFBBIhsR+NqxoQ9+JQRqupr49G2PiUYXKn8OntoI9osjhECaScrzqq1l4phuRmK1VlMdxHdpwZvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-escape": "^1.0.0" + } + }, "node_modules/chokidar": { "version": "4.0.3", "dev": true, @@ -2215,6 +2059,74 @@ "node": ">=6.0" } }, + "node_modules/ci-info": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", + "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/cli-truncate": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-1.1.0.tgz", + "integrity": "sha512-bAtZo0u82gCfaAGfSNxUdTI9mNyza7D8w4CVCcaOsy7sgwDzvx6ekr6cuWJqY3UGzgnQ1+4wgENup5eIhgxEYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "slice-ansi": "^1.0.0", + "string-width": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cli-truncate/node_modules/ansi-regex": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.1.tgz", + "integrity": "sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/cli-truncate/node_modules/is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/cli-truncate/node_modules/string-width": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", + "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^4.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/cli-truncate/node_modules/strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha512-4XaJ2zQdCzROZDivEVIDPkcQn8LMFSa8kj8Gxb/Lnwzv9A8VctNZ+lfivC/sV3ivW8ElJTERXZoPBRrZKkNKow==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/cliui": { "version": "8.0.1", "dev": true, @@ -2228,6 +2140,26 @@ "node": ">=12" } }, + "node_modules/clone": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz", + "integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/clone-buffer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/clone-buffer/-/clone-buffer-1.0.0.tgz", + "integrity": "sha512-KLLTJWrvwIP+OPfMn0x2PheDEP20RPUcGXj/ERegTgdmPEZylALQldygiqrPPu8P45uNuPs7ckmReLY6v/iA5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/clone-deep": { "version": "4.0.1", "dev": true, @@ -2241,14 +2173,33 @@ "node": ">=6" } }, - "node_modules/clsx": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", - "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "node_modules/clone-stats": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/clone-stats/-/clone-stats-1.0.0.tgz", + "integrity": "sha512-au6ydSpg6nsrigcZ4m8Bc9hxjeW+GJ8xh5G3BJCMt4WXe1H10UNaVOamqQTmrx1kjVuxAHIQSNU6hY4Nsn9/ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/cloneable-readable": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/cloneable-readable/-/cloneable-readable-1.1.3.tgz", + "integrity": "sha512-2EF8zTQOxYq70Y4XKtorQupqF0m49MBz2/yf5Bj+MHjvpG3Hy7sImifnqD6UA+TKYxeSV+u6qqQPawN5UvnpKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.1", + "process-nextick-args": "^2.0.0", + "readable-stream": "^2.3.5" + } + }, + "node_modules/code-point-at": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", + "integrity": "sha512-RpAVKQA5T63xEj6/giIbUEtZwJ4UFIc3ZtvEkiaUERylqe8xb5IvqcgOurZLahv93CLKfxcw5YI+DZcUBRyLXA==", "dev": true, "license": "MIT", "engines": { - "node": ">=6" + "node": ">=0.10.0" } }, "node_modules/color-convert": { @@ -2267,11 +2218,20 @@ "dev": true, "license": "MIT" }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "dev": true, + "license": "ISC", + "bin": { + "color-support": "bin.js" + } + }, "node_modules/commander": { "version": "14.0.2", "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz", "integrity": "sha512-TywoWNNRbhoD0BXs1P3ZEScW8W5iKrnbithIl0YH+uCmBd0QpPOA8yc82DS3BIE5Ma6FnBVUsJ7wVUDz4dvOWQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=20" @@ -2314,13 +2274,19 @@ "dev": true, "license": "MIT" }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, "node_modules/crelt": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/cross-spawn": { "version": "7.0.6", @@ -2389,10 +2355,16 @@ "url": "https://github.com/sponsors/kossnocorp" } }, + "node_modules/date-format": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/date-format/-/date-format-0.0.2.tgz", + "integrity": "sha512-M4obuJx8jU5T91lcbwi0+QPNVaWOY1DQYz5xUuKYWO93osVzB2ZPqyDUc5T+mDjbA1X8VOb4JDZ+8r2MrSOp7Q==", + "deprecated": "0.x is no longer supported. Please upgrade to 4.x or higher.", + "dev": true, + "license": "MIT" + }, "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "version": "4.4.0", "dev": true, "license": "MIT", "dependencies": { @@ -2407,19 +2379,55 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/deep-is": { "version": "0.1.4", "dev": true, "license": "MIT" }, - "node_modules/deepmerge": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", - "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", "dev": true, "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, "engines": { - "node": ">=0.10.0" + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, "node_modules/detect-libc": { @@ -2434,22 +2442,10 @@ "node": ">=0.10" } }, - "node_modules/deterministic-tests": { - "resolved": "deterministic-tests", - "link": true - }, "node_modules/dettle": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/dettle/-/dettle-1.0.5.tgz", "integrity": "sha512-ZVyjhAJ7sCe1PNXEGveObOH9AC8QvMga3HJIghHawtG7mE4K5pW9nz/vDGAr/U7a3LWgdOzEE7ac9MURnyfaTA==", - "dev": true, - "license": "MIT" - }, - "node_modules/devalue": { - "version": "5.6.4", - "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.4.tgz", - "integrity": "sha512-Gp6rDldRsFh/7XuouDbxMH3Mx8GMCcgzIb1pDTvNyn8pZGQ22u+Wa+lGV9dQCltFQ7uVw0MhRyb8XDskNFOReA==", - "dev": true, "license": "MIT" }, "node_modules/dunder-proto": { @@ -2465,11 +2461,319 @@ "node": ">= 0.4" } }, + "node_modules/duplexify": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz", + "integrity": "sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.0.0", + "inherits": "^2.0.1", + "readable-stream": "^2.0.0", + "stream-shift": "^1.0.0" + } + }, + "node_modules/eclint": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/eclint/-/eclint-2.8.1.tgz", + "integrity": "sha512-0u1UubFXSOgZgXNhuPeliYyTFmjWStVph8JR6uD6NDuxl3xI5VSCsA1KX6/BSYtM9v4wQMifGoNFfN5VlRn4LQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "editorconfig": "^0.15.2", + "file-type": "^10.1.0", + "gulp-exclude-gitignore": "^1.2.0", + "gulp-filter": "^5.1.0", + "gulp-reporter": "^2.9.0", + "gulp-tap": "^1.0.1", + "linez": "^4.1.4", + "lodash": "^4.17.11", + "minimatch": "^3.0.4", + "os-locale": "^3.0.1", + "plugin-error": "^1.0.1", + "through2": "^2.0.3", + "vinyl": "^2.2.0", + "vinyl-fs": "^3.0.3", + "yargs": "^12.0.2" + }, + "bin": { + "eclint": "bin/eclint.js" + } + }, + "node_modules/eclint/node_modules/ansi-regex": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.1.tgz", + "integrity": "sha512-+O9Jct8wf++lXxxFc4hc8LsjaSq0HFzzL7cVsw8pRDIPdjKD2mT4ytDZlLuSBZ4cLKZFXIrMGO7DbQCtMJJMKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/eclint/node_modules/cliui": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-4.1.0.tgz", + "integrity": "sha512-4FG+RSG9DL7uEwRUZXZn3SS34DiDPfzP0VOiEwtUWlE+AR2EIg+hSyvrIgUUfhdgR/UkAeW2QHgeP+hWrXs7jQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^2.1.1", + "strip-ansi": "^4.0.0", + "wrap-ansi": "^2.0.0" + } + }, + "node_modules/eclint/node_modules/find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/eclint/node_modules/get-caller-file": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-1.0.3.tgz", + "integrity": "sha512-3t6rVToeoZfYSGd8YoLFR2DJkiQrIiUrGcjvFX2mDw3bn6k2OtwHN0TNCLbBO+w8qTvimhDkv+LSscbJY1vE6w==", + "dev": true, + "license": "ISC" + }, + "node_modules/eclint/node_modules/is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/eclint/node_modules/locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/eclint/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eclint/node_modules/p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/eclint/node_modules/path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/eclint/node_modules/string-width": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", + "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^4.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/eclint/node_modules/strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha512-4XaJ2zQdCzROZDivEVIDPkcQn8LMFSa8kj8Gxb/Lnwzv9A8VctNZ+lfivC/sV3ivW8ElJTERXZoPBRrZKkNKow==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/eclint/node_modules/wrap-ansi": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz", + "integrity": "sha512-vAaEaDM946gbNpH5pLVNR+vX2ht6n0Bt3GXwVB1AuAqZosOvHNF3P7wDnh8KLkSqgUh0uh77le7Owgoz+Z9XBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eclint/node_modules/wrap-ansi/node_modules/ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eclint/node_modules/wrap-ansi/node_modules/is-fullwidth-code-point": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "integrity": "sha512-1pqUqRjkhPJ9miNq9SwMfdvi6lBJcd6eFxvfaivQhaH3SgisfiuudvFntdKOmxuee/77l+FPjKrQjWvmPjWrRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "number-is-nan": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eclint/node_modules/wrap-ansi/node_modules/string-width": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "integrity": "sha512-0XsVpQLnVCXHJfyEs8tC0zpTVIr5PKKsQtkT29IwupnPTjtPmQ3xT/4yCREF9hYkV/3M3kzcUTSAZT6a6h81tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eclint/node_modules/wrap-ansi/node_modules/strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eclint/node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/eclint/node_modules/yargs": { + "version": "12.0.5", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-12.0.5.tgz", + "integrity": "sha512-Lhz8TLaYnxq/2ObqHDql8dX8CJi97oHxrjUcYtzKbbykPtVW9WB+poxI+NM2UIzsMgNCZTIf0AQwsjK5yMAqZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^4.0.0", + "decamelize": "^1.2.0", + "find-up": "^3.0.0", + "get-caller-file": "^1.0.1", + "os-locale": "^3.0.0", + "require-directory": "^2.1.1", + "require-main-filename": "^1.0.1", + "set-blocking": "^2.0.0", + "string-width": "^2.0.0", + "which-module": "^2.0.0", + "y18n": "^3.2.1 || ^4.0.0", + "yargs-parser": "^11.1.1" + } + }, + "node_modules/eclint/node_modules/yargs-parser": { + "version": "11.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-11.1.1.tgz", + "integrity": "sha512-C6kB/WJDiaxONLJQnF8ccx9SEeoTTLek8RVbaOIsrAUS8VrBEXfmeSnCZxygc+XC2sNMBIwOOnfcxiynjHsVSQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + } + }, + "node_modules/editorconfig": { + "version": "0.15.3", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-0.15.3.tgz", + "integrity": "sha512-M9wIMFx96vq0R4F+gRpY3o2exzb8hEj/n9S8unZtHSvYjibBp/iMufSzvmOcV/laG0ZtuTVGtiJggPOSW2r93g==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^2.19.0", + "lru-cache": "^4.1.5", + "semver": "^5.6.0", + "sigmund": "^1.0.1" + }, + "bin": { + "editorconfig": "bin/editorconfig" + } + }, + "node_modules/editorconfig/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/editorconfig/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, "node_modules/electron-to-chromium": { - "version": "1.5.267", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", - "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", - "dev": true + "version": "1.5.127", + "dev": true, + "license": "ISC" }, "node_modules/emoji-regex": { "version": "8.0.0", @@ -2484,6 +2788,106 @@ "node": ">= 4" } }, + "node_modules/emphasize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/emphasize/-/emphasize-2.1.0.tgz", + "integrity": "sha512-wRlO0Qulw2jieQynsS3STzTabIhHCyjTjZraSkchOiT8rdvWZlahJAJ69HRxwGkv2NThmci2MSnDfJ60jB39tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^2.4.0", + "highlight.js": "~9.12.0", + "lowlight": "~1.9.0" + } + }, + "node_modules/emphasize/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/emphasize/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/emphasize/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/emphasize/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/emphasize/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/emphasize/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/emphasize/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.18.1", "dev": true, @@ -2540,11 +2944,12 @@ } }, "node_modules/esbuild": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.1.tgz", - "integrity": "sha512-yY35KZckJJuVVPXpvjgxiCuVEJT67F6zDeVTv4rizyPrfGBUpZQsvmxnN+C371c2esD/hNMjj4tpBhuueLN7aA==", + "version": "0.25.9", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.9.tgz", + "integrity": "sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==", "dev": true, "hasInstallScript": true, + "license": "MIT", "bin": { "esbuild": "bin/esbuild" }, @@ -2552,457 +2957,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.1", - "@esbuild/android-arm": "0.27.1", - "@esbuild/android-arm64": "0.27.1", - "@esbuild/android-x64": "0.27.1", - "@esbuild/darwin-arm64": "0.27.1", - "@esbuild/darwin-x64": "0.27.1", - "@esbuild/freebsd-arm64": "0.27.1", - "@esbuild/freebsd-x64": "0.27.1", - "@esbuild/linux-arm": "0.27.1", - "@esbuild/linux-arm64": "0.27.1", - "@esbuild/linux-ia32": "0.27.1", - "@esbuild/linux-loong64": "0.27.1", - "@esbuild/linux-mips64el": "0.27.1", - "@esbuild/linux-ppc64": "0.27.1", - "@esbuild/linux-riscv64": "0.27.1", - "@esbuild/linux-s390x": "0.27.1", - "@esbuild/linux-x64": "0.27.1", - "@esbuild/netbsd-arm64": "0.27.1", - "@esbuild/netbsd-x64": "0.27.1", - "@esbuild/openbsd-arm64": "0.27.1", - "@esbuild/openbsd-x64": "0.27.1", - "@esbuild/openharmony-arm64": "0.27.1", - "@esbuild/sunos-x64": "0.27.1", - "@esbuild/win32-arm64": "0.27.1", - "@esbuild/win32-ia32": "0.27.1", - "@esbuild/win32-x64": "0.27.1" - } - }, - "node_modules/esbuild/node_modules/@esbuild/aix-ppc64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.1.tgz", - "integrity": "sha512-HHB50pdsBX6k47S4u5g/CaLjqS3qwaOVE5ILsq64jyzgMhLuCuZ8rGzM9yhsAjfjkbgUPMzZEPa7DAp7yz6vuA==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/esbuild/node_modules/@esbuild/android-arm": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.1.tgz", - "integrity": "sha512-kFqa6/UcaTbGm/NncN9kzVOODjhZW8e+FRdSeypWe6j33gzclHtwlANs26JrupOntlcWmB0u8+8HZo8s7thHvg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/esbuild/node_modules/@esbuild/android-arm64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.1.tgz", - "integrity": "sha512-45fuKmAJpxnQWixOGCrS+ro4Uvb4Re9+UTieUY2f8AEc+t7d4AaZ6eUJ3Hva7dtrxAAWHtlEFsXFMAgNnGU9uQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/esbuild/node_modules/@esbuild/android-x64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.1.tgz", - "integrity": "sha512-LBEpOz0BsgMEeHgenf5aqmn/lLNTFXVfoWMUox8CtWWYK9X4jmQzWjoGoNb8lmAYml/tQ/Ysvm8q7szu7BoxRQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/esbuild/node_modules/@esbuild/darwin-arm64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.1.tgz", - "integrity": "sha512-veg7fL8eMSCVKL7IW4pxb54QERtedFDfY/ASrumK/SbFsXnRazxY4YykN/THYqFnFwJ0aVjiUrVG2PwcdAEqQQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/esbuild/node_modules/@esbuild/darwin-x64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.1.tgz", - "integrity": "sha512-+3ELd+nTzhfWb07Vol7EZ+5PTbJ/u74nC6iv4/lwIU99Ip5uuY6QoIf0Hn4m2HoV0qcnRivN3KSqc+FyCHjoVQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/esbuild/node_modules/@esbuild/freebsd-arm64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.1.tgz", - "integrity": "sha512-/8Rfgns4XD9XOSXlzUDepG8PX+AVWHliYlUkFI3K3GB6tqbdjYqdhcb4BKRd7C0BhZSoaCxhv8kTcBrcZWP+xg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/esbuild/node_modules/@esbuild/freebsd-x64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.1.tgz", - "integrity": "sha512-GITpD8dK9C+r+5yRT/UKVT36h/DQLOHdwGVwwoHidlnA168oD3uxA878XloXebK4Ul3gDBBIvEdL7go9gCUFzQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/esbuild/node_modules/@esbuild/linux-arm": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.1.tgz", - "integrity": "sha512-ieMID0JRZY/ZeCrsFQ3Y3NlHNCqIhTprJfDgSB3/lv5jJZ8FX3hqPyXWhe+gvS5ARMBJ242PM+VNz/ctNj//eA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/esbuild/node_modules/@esbuild/linux-arm64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.1.tgz", - "integrity": "sha512-W9//kCrh/6in9rWIBdKaMtuTTzNj6jSeG/haWBADqLLa9P8O5YSRDzgD5y9QBok4AYlzS6ARHifAb75V6G670Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/esbuild/node_modules/@esbuild/linux-ia32": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.1.tgz", - "integrity": "sha512-VIUV4z8GD8rtSVMfAj1aXFahsi/+tcoXXNYmXgzISL+KB381vbSTNdeZHHHIYqFyXcoEhu9n5cT+05tRv13rlw==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/esbuild/node_modules/@esbuild/linux-loong64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.1.tgz", - "integrity": "sha512-l4rfiiJRN7sTNI//ff65zJ9z8U+k6zcCg0LALU5iEWzY+a1mVZ8iWC1k5EsNKThZ7XCQ6YWtsZ8EWYm7r1UEsg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/esbuild/node_modules/@esbuild/linux-mips64el": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.1.tgz", - "integrity": "sha512-U0bEuAOLvO/DWFdygTHWY8C067FXz+UbzKgxYhXC0fDieFa0kDIra1FAhsAARRJbvEyso8aAqvPdNxzWuStBnA==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/esbuild/node_modules/@esbuild/linux-ppc64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.1.tgz", - "integrity": "sha512-NzdQ/Xwu6vPSf/GkdmRNsOfIeSGnh7muundsWItmBsVpMoNPVpM61qNzAVY3pZ1glzzAxLR40UyYM23eaDDbYQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/esbuild/node_modules/@esbuild/linux-riscv64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.1.tgz", - "integrity": "sha512-7zlw8p3IApcsN7mFw0O1Z1PyEk6PlKMu18roImfl3iQHTnr/yAfYv6s4hXPidbDoI2Q0pW+5xeoM4eTCC0UdrQ==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/esbuild/node_modules/@esbuild/linux-s390x": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.1.tgz", - "integrity": "sha512-cGj5wli+G+nkVQdZo3+7FDKC25Uh4ZVwOAK6A06Hsvgr8WqBBuOy/1s+PUEd/6Je+vjfm6stX0kmib5b/O2Ykw==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/esbuild/node_modules/@esbuild/netbsd-arm64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.1.tgz", - "integrity": "sha512-wzC24DxAvk8Em01YmVXyjl96Mr+ecTPyOuADAvjGg+fyBpGmxmcr2E5ttf7Im8D0sXZihpxzO1isus8MdjMCXQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/esbuild/node_modules/@esbuild/netbsd-x64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.1.tgz", - "integrity": "sha512-1YQ8ybGi2yIXswu6eNzJsrYIGFpnlzEWRl6iR5gMgmsrR0FcNoV1m9k9sc3PuP5rUBLshOZylc9nqSgymI+TYg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/esbuild/node_modules/@esbuild/openbsd-arm64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.1.tgz", - "integrity": "sha512-5Z+DzLCrq5wmU7RDaMDe2DVXMRm2tTDvX2KU14JJVBN2CT/qov7XVix85QoJqHltpvAOZUAc3ndU56HSMWrv8g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/esbuild/node_modules/@esbuild/openbsd-x64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.1.tgz", - "integrity": "sha512-Q73ENzIdPF5jap4wqLtsfh8YbYSZ8Q0wnxplOlZUOyZy7B4ZKW8DXGWgTCZmF8VWD7Tciwv5F4NsRf6vYlZtqg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/esbuild/node_modules/@esbuild/openharmony-arm64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.1.tgz", - "integrity": "sha512-ajbHrGM/XiK+sXM0JzEbJAen+0E+JMQZ2l4RR4VFwvV9JEERx+oxtgkpoKv1SevhjavK2z2ReHk32pjzktWbGg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/esbuild/node_modules/@esbuild/sunos-x64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.1.tgz", - "integrity": "sha512-IPUW+y4VIjuDVn+OMzHc5FV4GubIwPnsz6ubkvN8cuhEqH81NovB53IUlrlBkPMEPxvNnf79MGBoz8rZ2iW8HA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/esbuild/node_modules/@esbuild/win32-arm64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.1.tgz", - "integrity": "sha512-RIVRWiljWA6CdVu8zkWcRmGP7iRRIIwvhDKem8UMBjPql2TXM5PkDVvvrzMtj1V+WFPB4K7zkIGM7VzRtFkjdg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/esbuild/node_modules/@esbuild/win32-ia32": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.1.tgz", - "integrity": "sha512-2BR5M8CPbptC1AK5JbJT1fWrHLvejwZidKx3UMSF0ecHMa+smhi16drIrCEggkgviBwLYd5nwrFLSl5Kho96RQ==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/esbuild/node_modules/@esbuild/win32-x64": { - "version": "0.27.1", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.1.tgz", - "integrity": "sha512-d5X6RMYv6taIymSk8JBP+nxv8DQAMY6A51GPgusqLdK9wBz5wWIXy1KjTck6HnjE9hqJzJRdk+1p/t5soSbCtw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=18" + "@esbuild/aix-ppc64": "0.25.9", + "@esbuild/android-arm": "0.25.9", + "@esbuild/android-arm64": "0.25.9", + "@esbuild/android-x64": "0.25.9", + "@esbuild/darwin-arm64": "0.25.9", + "@esbuild/darwin-x64": "0.25.9", + "@esbuild/freebsd-arm64": "0.25.9", + "@esbuild/freebsd-x64": "0.25.9", + "@esbuild/linux-arm": "0.25.9", + "@esbuild/linux-arm64": "0.25.9", + "@esbuild/linux-ia32": "0.25.9", + "@esbuild/linux-loong64": "0.25.9", + "@esbuild/linux-mips64el": "0.25.9", + "@esbuild/linux-ppc64": "0.25.9", + "@esbuild/linux-riscv64": "0.25.9", + "@esbuild/linux-s390x": "0.25.9", + "@esbuild/linux-x64": "0.25.9", + "@esbuild/netbsd-arm64": "0.25.9", + "@esbuild/netbsd-x64": "0.25.9", + "@esbuild/openbsd-arm64": "0.25.9", + "@esbuild/openbsd-x64": "0.25.9", + "@esbuild/openharmony-arm64": "0.25.9", + "@esbuild/sunos-x64": "0.25.9", + "@esbuild/win32-arm64": "0.25.9", + "@esbuild/win32-ia32": "0.25.9", + "@esbuild/win32-x64": "0.25.9" } }, "node_modules/escalade": { @@ -3025,20 +3005,21 @@ } }, "node_modules/eslint": { - "version": "9.39.2", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", - "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "version": "9.38.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.38.0.tgz", + "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.1", - "@eslint/config-helpers": "^0.4.2", - "@eslint/core": "^0.17.0", + "@eslint/config-helpers": "^0.4.1", + "@eslint/core": "^0.16.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.39.2", - "@eslint/plugin-kit": "^0.4.1", + "@eslint/js": "9.38.0", + "@eslint/plugin-kit": "^0.4.0", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -3085,10 +3066,9 @@ } }, "node_modules/eslint-plugin-unused-imports": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-unused-imports/-/eslint-plugin-unused-imports-4.3.0.tgz", - "integrity": "sha512-ZFBmXMGBYfHttdRtOG9nFFpmUvMtbHSjsKrS20vdWdbfiVYsO3yA2SGYy9i9XmZJDfMGBflZGBCm70SEnFQtOA==", + "version": "4.1.4", "dev": true, + "license": "MIT", "peerDependencies": { "@typescript-eslint/eslint-plugin": "^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0", "eslint": "^9.0.0 || ^8.0.0" @@ -3129,13 +3109,6 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/esm-env": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", - "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", - "dev": true, - "license": "MIT" - }, "node_modules/espree": { "version": "10.4.0", "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", @@ -3154,6 +3127,20 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/esquery": { "version": "1.6.0", "dev": true, @@ -3165,17 +3152,6 @@ "node": ">=0.10" } }, - "node_modules/esrap": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.2.4.tgz", - "integrity": "sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15", - "@typescript-eslint/types": "^8.2.0" - } - }, "node_modules/esrecurse": { "version": "4.3.0", "dev": true, @@ -3216,11 +3192,170 @@ "node": ">=0.8.x" } }, + "node_modules/execa": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz", + "integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^6.0.0", + "get-stream": "^4.0.0", + "is-stream": "^1.1.0", + "npm-run-path": "^2.0.0", + "p-finally": "^1.0.0", + "signal-exit": "^3.0.0", + "strip-eof": "^1.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/execa/node_modules/cross-spawn": { + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.6.tgz", + "integrity": "sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==", + "dev": true, + "license": "MIT", + "dependencies": { + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + }, + "engines": { + "node": ">=4.8" + } + }, + "node_modules/execa/node_modules/path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/execa/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver" + } + }, + "node_modules/execa/node_modules/shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/execa/node_modules/shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/execa/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/extend-shallow": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", + "integrity": "sha512-BwY5b5Ql4+qZoefgMj2NUmx+tehVTH/Kf4k1ZEtOHNFcm2wSxMRo992l6X3TIgni2eZVTZ85xMOjF31fwZAj6Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "assign-symbols": "^1.0.0", + "is-extendable": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/fancy-log": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/fancy-log/-/fancy-log-1.3.3.tgz", + "integrity": "sha512-k9oEhlyc0FrVh25qYuSELjr8oxsCoc4/LEZfg2iJJrfEk/tZL9bCoJE47gqAvI2m/AUjluCS4+3I0eTx8n3AEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-gray": "^0.1.1", + "color-support": "^1.1.3", + "parse-node-version": "^1.0.0", + "time-stamp": "^1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "dev": true, "license": "MIT" }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "dev": true, @@ -3254,6 +3389,30 @@ "node": ">= 4.9.1" } }, + "node_modules/fastq": { + "version": "1.19.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", + "integrity": "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fault": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz", + "integrity": "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==", + "dev": true, + "license": "MIT", + "dependencies": { + "format": "^0.2.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/file-entry-cache": { "version": "8.0.0", "dev": true, @@ -3284,6 +3443,16 @@ "webpack": "^4.0.0 || ^5.0.0" } }, + "node_modules/file-type": { + "version": "10.11.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-10.11.0.tgz", + "integrity": "sha512-uzk64HRpUZyTGZtVuvrjP0FYxzQrBf4rojot6J65YMEbwBLB0CWm0CLojVpwpmFmxcE/lkvYICgfcGozbBq6rw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/fill-range": { "version": "7.1.1", "dev": true, @@ -3335,11 +3504,60 @@ "dev": true, "license": "ISC" }, - "node_modules/fs-extra": { - "version": "11.3.2", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.2.tgz", - "integrity": "sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==", + "node_modules/flush-write-stream": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/flush-write-stream/-/flush-write-stream-1.1.1.tgz", + "integrity": "sha512-3Z4XhFZ3992uIq0XOqb9AreonueSYphE6oYbpt5+3u06JWklbsPkNv3ZKkP9Bz/r+1MWCaMoSQ28P85+1Yc77w==", "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "readable-stream": "^2.3.6" + } + }, + "node_modules/follow-redirects": { + "version": "1.5.10", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.5.10.tgz", + "integrity": "sha512-0V5l4Cizzvqt5D44aTXbFZz+FtyXV1vrDN6qrelxtfYQKW0KO0W2T/hkE8xvGa/540LkZlkaUjO4ailYTFtHVQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "=3.1.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/follow-redirects/node_modules/debug": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.1.0.tgz", + "integrity": "sha512-OX8XqP7/1a9cqkxYw2yXss15f26NKWBpDXQd0/uK/KPqdQhxbPa994hnzjcE2VqQpDslf55723cKPUOGSmMY3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/follow-redirects/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "dev": true, + "license": "MIT" + }, + "node_modules/format": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz", + "integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==", + "dev": true, + "engines": { + "node": ">=0.4.x" + } + }, + "node_modules/fs-extra": { + "version": "11.3.0", + "dev": true, + "license": "MIT", "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^6.0.1", @@ -3349,6 +3567,27 @@ "node": ">=14.14" } }, + "node_modules/fs-mkdirp-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-mkdirp-stream/-/fs-mkdirp-stream-1.0.0.tgz", + "integrity": "sha512-+vSd9frUnapVC2RZYfL3FCB2p3g4TBhaUmrsWlSudsGdnxIuUvBB2QM1VZeBtc49QFwrp+wQLrDs3+xxDgI5gQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.11", + "through2": "^2.0.3" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -3415,6 +3654,19 @@ "node": ">= 0.4" } }, + "node_modules/get-stream": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", + "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "pump": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/get-tsconfig": { "version": "4.10.1", "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.10.1.tgz", @@ -3428,6 +3680,28 @@ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" } }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "dev": true, @@ -3439,11 +3713,56 @@ "node": ">=10.13.0" } }, + "node_modules/glob-stream": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/glob-stream/-/glob-stream-6.1.0.tgz", + "integrity": "sha512-uMbLGAP3S2aDOHUDfdoYcdIePUCfysbAd0IAoWVZbeGU/oNQ8asHVSshLDJUPWxfzj8zsCG7/XeHPHTtow0nsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "extend": "^3.0.0", + "glob": "^7.1.1", + "glob-parent": "^3.1.0", + "is-negated-glob": "^1.0.0", + "ordered-read-streams": "^1.0.0", + "pumpify": "^1.3.5", + "readable-stream": "^2.1.5", + "remove-trailing-separator": "^1.0.1", + "to-absolute-glob": "^2.0.0", + "unique-stream": "^2.0.2" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/glob-stream/node_modules/glob-parent": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-3.1.0.tgz", + "integrity": "sha512-E8Ak/2+dZY6fnzlR7+ueWvhsH1SjHr4jjss4YS/h4py44jY9MhK/VFdaZJAWDz6BbL21KeteKxFSFpq8OS5gVA==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^3.1.0", + "path-dirname": "^1.0.0" + } + }, + "node_modules/glob-stream/node_modules/is-glob": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-3.1.0.tgz", + "integrity": "sha512-UFpDDrPgM6qpnFNI+rh/p3bUaq9hKLZN8bMUWzxmcnZVS3omf4IPK+BrewlnWjO1WmUsMYuSjKh4UJuV4+Lqmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/glob-to-regexp": { "version": "0.4.1", - "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", - "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", - "dev": true + "dev": true, + "license": "BSD-2-Clause" }, "node_modules/globals": { "version": "14.0.0", @@ -3472,6 +3791,368 @@ "dev": true, "license": "ISC" }, + "node_modules/graphemer": { + "version": "1.4.0", + "dev": true, + "license": "MIT" + }, + "node_modules/gulp-exclude-gitignore": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gulp-exclude-gitignore/-/gulp-exclude-gitignore-1.2.0.tgz", + "integrity": "sha512-J3LCmz9C1UU1pxf5Npx6SNc5o9YQptyc9IHaqLiBlihZmg44jaaTplWUZ0JPQkMdOTae0YgEDvT9TKlUWDSMUA==", + "dev": true, + "license": "ISC", + "dependencies": { + "gulp-ignore": "^2.0.2" + } + }, + "node_modules/gulp-filter": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/gulp-filter/-/gulp-filter-5.1.0.tgz", + "integrity": "sha512-ZERu1ipbPmjrNQ2dQD6lL4BjrJQG66P/c5XiyMMBqV+tUAJ+fLOyYIL/qnXd2pHmw/G/r7CLQb9ttANvQWbpfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "multimatch": "^2.0.0", + "plugin-error": "^0.1.2", + "streamfilter": "^1.0.5" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/gulp-filter/node_modules/arr-diff": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-1.1.0.tgz", + "integrity": "sha512-OQwDZUqYaQwyyhDJHThmzId8daf4/RFNLaeh3AevmSeZ5Y7ug4Ga/yKc6l6kTZOBW781rCj103ZuTh8GAsB3+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "arr-flatten": "^1.0.1", + "array-slice": "^0.2.3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/gulp-filter/node_modules/arr-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-2.1.0.tgz", + "integrity": "sha512-t5db90jq+qdgk8aFnxEkjqta0B/GHrM1pxzuuZz2zWsOXc5nKu3t+76s/PQBA8FTcM/ipspIH9jWG4OxCBc2eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/gulp-filter/node_modules/extend-shallow": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-1.1.4.tgz", + "integrity": "sha512-L7AGmkO6jhDkEBBGWlLtftA80Xq8DipnrRPr0pyi7GQLXkaq9JYA4xF4z6qnadIC6euiTDKco0cGSU9muw+WTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "kind-of": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/gulp-filter/node_modules/kind-of": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-1.1.0.tgz", + "integrity": "sha512-aUH6ElPnMGon2/YkxRIigV32MOpTVcoXQ1Oo8aYn40s+sJ3j+0gFZsT8HKDcxNy7Fi9zuquWtGaGAahOdv5p/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/gulp-filter/node_modules/plugin-error": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/plugin-error/-/plugin-error-0.1.2.tgz", + "integrity": "sha512-WzZHcm4+GO34sjFMxQMqZbsz3xiNEgonCskQ9v+IroMmYgk/tas8dG+Hr2D6IbRPybZ12oWpzE/w3cGJ6FJzOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-cyan": "^0.1.1", + "ansi-red": "^0.1.1", + "arr-diff": "^1.0.1", + "arr-union": "^2.0.1", + "extend-shallow": "^1.1.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/gulp-ignore": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/gulp-ignore/-/gulp-ignore-2.0.2.tgz", + "integrity": "sha512-KGtd/qgp0FLDlei986/aZ5xSyw1cqJ2BsiaWht0L0PzaQXxYKRCMkFcDPQ8fQx6JVA6Gx9OefmBFzxTtop5hMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "gulp-match": "^1.0.3", + "through2": "^2.0.1" + }, + "engines": { + "node": ">= 0.10.0" + } + }, + "node_modules/gulp-match": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/gulp-match/-/gulp-match-1.1.0.tgz", + "integrity": "sha512-DlyVxa1Gj24DitY2OjEsS+X6tDpretuxD6wTfhXE/Rw2hweqc1f6D/XtsJmoiCwLWfXgR87W9ozEityPCVzGtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimatch": "^3.0.3" + } + }, + "node_modules/gulp-reporter": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/gulp-reporter/-/gulp-reporter-2.10.0.tgz", + "integrity": "sha512-HeruxN7TL/enOB+pJfFmeekVsXsZzQvVGpL7vOLdUe7y7VdqHUvMQRRW5qMIvVSKqRs3EtQiR/kURu3WWfXq6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-escapes": "^3.1.0", + "axios": "^0.18.0", + "buffered-spawn": "^3.3.2", + "bufferstreams": "^2.0.1", + "chalk": "^2.4.1", + "checkstyle-formatter": "^1.1.0", + "ci-info": "^2.0.0", + "cli-truncate": "^1.1.0", + "emphasize": "^2.0.0", + "fancy-log": "^1.3.3", + "fs-extra": "^7.0.1", + "in-gfw": "^1.2.0", + "is-windows": "^1.0.2", + "js-yaml": "^3.12.0", + "junit-report-builder": "^1.3.1", + "lodash.get": "^4.4.2", + "os-locale": "^3.0.1", + "plugin-error": "^1.0.1", + "string-width": "^3.0.0", + "term-size": "^1.2.0", + "through2": "^3.0.0", + "to-time": "^1.0.2" + } + }, + "node_modules/gulp-reporter/node_modules/ansi-regex": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.1.tgz", + "integrity": "sha512-ILlv4k/3f6vfQ4OoP2AGvirOktlQ98ZEL1k9FaQjxa3L1abBgbuTDAdPOpvbGncC0BTVQrl+OM8xZGK6tWXt7g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/gulp-reporter/node_modules/ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^1.9.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/gulp-reporter/node_modules/argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "sprintf-js": "~1.0.2" + } + }, + "node_modules/gulp-reporter/node_modules/chalk": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", + "integrity": "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/gulp-reporter/node_modules/color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "1.1.3" + } + }, + "node_modules/gulp-reporter/node_modules/color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==", + "dev": true, + "license": "MIT" + }, + "node_modules/gulp-reporter/node_modules/emoji-regex": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", + "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", + "dev": true, + "license": "MIT" + }, + "node_modules/gulp-reporter/node_modules/escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/gulp-reporter/node_modules/fs-extra": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", + "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2", + "jsonfile": "^4.0.0", + "universalify": "^0.1.0" + }, + "engines": { + "node": ">=6 <7 || >=8" + } + }, + "node_modules/gulp-reporter/node_modules/has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/gulp-reporter/node_modules/is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/gulp-reporter/node_modules/js-yaml": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.2.tgz", + "integrity": "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/gulp-reporter/node_modules/jsonfile": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", + "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", + "dev": true, + "license": "MIT", + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/gulp-reporter/node_modules/string-width": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", + "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/gulp-reporter/node_modules/strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^4.1.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/gulp-reporter/node_modules/supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/gulp-reporter/node_modules/through2": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/through2/-/through2-3.0.2.tgz", + "integrity": "sha512-enaDQ4MUyP2W6ZyT6EsMzqBPZaM/avg8iuo+l2d3QCs0J+6RaqkHV/2/lOwDTueBHeJ/2LG9lrLW3d5rWPucuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "inherits": "^2.0.4", + "readable-stream": "2 || 3" + } + }, + "node_modules/gulp-reporter/node_modules/universalify": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", + "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/gulp-tap": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gulp-tap/-/gulp-tap-1.0.1.tgz", + "integrity": "sha512-VpCARRSyr+WP16JGnoIg98/AcmyQjOwCpQgYoE35CWTdEMSbpgtAIK2fndqv2yY7aXstW27v3ZNBs0Ltb0Zkbg==", + "dev": true, + "license": "MIT", + "dependencies": { + "through2": "^2.0.3" + } + }, "node_modules/has-flag": { "version": "4.0.0", "dev": true, @@ -3480,6 +4161,19 @@ "node": ">=8" } }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "dev": true, @@ -3502,9 +4196,29 @@ "node": ">= 0.4" } }, - "node_modules/history-ui": { - "resolved": "history-ui", - "link": true + "node_modules/highlight.js": { + "version": "9.12.0", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-9.12.0.tgz", + "integrity": "sha512-qNnYpBDO/FQwYVur1+sQBQw7v0cxso1nOYLklqWh6af8ROwwTVoII5+kf/BVa8354WL4ad6rURHYGUXCbD9mMg==", + "deprecated": "Version no longer supported. Upgrade to @latest", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } }, "node_modules/icss-utils": { "version": "5.1.0", @@ -3571,6 +4285,37 @@ "node": ">=0.8.19" } }, + "node_modules/in-gfw": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/in-gfw/-/in-gfw-1.2.0.tgz", + "integrity": "sha512-LgSoQXzuSS/x/nh0eIggq7PsI7gs/sQdXNEolRmHaFUj6YMFmPO1kxQ7XpcT3nPpC3DMwYiJmgnluqJmFXYiMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "glob": "^7.1.2", + "is-wsl": "^1.1.0", + "mem": "^3.0.1" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, "node_modules/interpret": { "version": "3.1.1", "dev": true, @@ -3579,6 +4324,54 @@ "node": ">=10.13.0" } }, + "node_modules/invert-kv": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/invert-kv/-/invert-kv-2.0.0.tgz", + "integrity": "sha512-wPVv/y/QQ/Uiirj/vh3oP+1Ww+AWehmi1g5fFWGPF6IpCBCDVrhgHRMvrLfdYcwDh3QJbGXDW4JAuzxElLSqKA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/is-absolute": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-absolute/-/is-absolute-1.0.0.tgz", + "integrity": "sha512-dOWoqflvcydARa360Gvv18DZ/gRuHKi2NU/wU5X1ZFzdYfH29nkiNZsF3mp4OJ3H4yo9Mx8A/uAGNzpzPN3yBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-relative": "^1.0.0", + "is-windows": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-buffer": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", + "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/is-core-module": { "version": "2.16.1", "dev": true, @@ -3593,6 +4386,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-plain-object": "^2.0.4" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "dev": true, @@ -3620,6 +4426,16 @@ "node": ">=0.10.0" } }, + "node_modules/is-negated-glob": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-negated-glob/-/is-negated-glob-1.0.0.tgz", + "integrity": "sha512-czXVVn/QEmgvej1f50BZ648vUI+em0xqMq2Sn+QncCLN4zj1UAxlT+kw/6ggQTOaZPd1HqKQGEqbpQVtJucWug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-number": { "version": "7.0.0", "dev": true, @@ -3639,16 +4455,86 @@ "node": ">=0.10.0" } }, - "node_modules/is-reference": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", - "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", + "node_modules/is-relative": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-relative/-/is-relative-1.0.0.tgz", + "integrity": "sha512-Kw/ReK0iqwKeu0MITLFuj0jbPAmEiOsIwyIXvvbfa6QfmN9pkD1M+8pdk7Rl/dTKbH34/XBFMbgD4iMJhLQbGA==", "dev": true, "license": "MIT", "dependencies": { - "@types/estree": "^1.0.6" + "is-unc-path": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" } }, + "node_modules/is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-unc-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-unc-path/-/is-unc-path-1.0.0.tgz", + "integrity": "sha512-mrGpVd0fs7WWLfVsStvgF6iEJnbjDFZh9/emhRDcGWTduTfNHd9CHeUwH3gYIjdbwo4On6hunkztwOaAw0yllQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "unc-path-regex": "^0.1.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-utf8": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz", + "integrity": "sha512-rMYPYvCzsXywIsldgLaSoPlw5PfoB/ssr7hY4pLfcodrA5M/eArza1a9VmTiNIBNMjOGr1Ow9mTyU2o69U6U9Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-valid-glob": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-valid-glob/-/is-valid-glob-1.0.0.tgz", + "integrity": "sha512-AhiROmoEFDSsjx8hW+5sGwgKVIORcXnrlAx/R0ZSeaPw70Vw0CqkGBBhHGL58Uox2eXnU1AnvXJl1XlyedO5bA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-windows": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", + "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-wsl": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-1.1.0.tgz", + "integrity": "sha512-gfygJYZ2gLTDlmbWMI0CE2MwnFzSN/2SZfkMlItC4K/JBlsWVDB0bO6XhqcY13YXE7iMcAJnzTCJjPiTeJJ0Mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "dev": true, @@ -3717,6 +4603,19 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/junit-report-builder": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/junit-report-builder/-/junit-report-builder-1.3.3.tgz", + "integrity": "sha512-75bwaXjP/3ogyzOSkkcshXGG7z74edkJjgTZlJGAyzxlOHaguexM3VLG6JyD9ZBF8mlpgsUPB1sIWU4LISgeJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "date-format": "0.0.2", + "lodash": "^4.17.15", + "mkdirp": "^0.5.0", + "xmlbuilder": "^10.0.0" + } + }, "node_modules/keyv": { "version": "4.5.4", "dev": true, @@ -3733,16 +4632,45 @@ "node": ">=0.10.0" } }, - "node_modules/kleur": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", - "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", "dev": true, "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lcid": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/lcid/-/lcid-2.0.0.tgz", + "integrity": "sha512-avPEb8P8EGnwXKClwsNUgryVjllcRqtMYa49NTsbQagYuT1DcXnl1915oxWjoyGrXR6zH/Y0Zc96xWsPcoDKeA==", + "dev": true, + "license": "MIT", + "dependencies": { + "invert-kv": "^2.0.0" + }, "engines": { "node": ">=6" } }, + "node_modules/lead": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/lead/-/lead-1.0.0.tgz", + "integrity": "sha512-IpSVCk9AYvLHo5ctcIXxOBpMWUe+4TKN3VPWAKUbJikkmsGp0VrSM8IttVc32D6J4WUsiPE6aEFRNmIoF/gdow==", + "dev": true, + "license": "MIT", + "dependencies": { + "flush-write-stream": "^1.0.2" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/levn": { "version": "0.4.1", "dev": true, @@ -3755,17 +4683,23 @@ "node": ">= 0.8.0" } }, - "node_modules/loader-runner": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.1.tgz", - "integrity": "sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q==", + "node_modules/linez": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/linez/-/linez-4.1.4.tgz", + "integrity": "sha512-TsqcAfotPMB9xodBIklBaJz3sRIXtkca8Kv/MO8nzAufsitCKRoYWU5MZccdCVYB81tGexYHRsrSIEiJsQhpVQ==", "dev": true, + "license": "MIT", + "dependencies": { + "buffer-equals": "^1.0.4", + "iconv-lite": "^0.4.15" + } + }, + "node_modules/loader-runner": { + "version": "4.3.0", + "dev": true, + "license": "MIT", "engines": { "node": ">=6.11.5" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" } }, "node_modules/loader-utils": { @@ -3785,13 +4719,6 @@ "resolved": "local-client-cli", "link": true }, - "node_modules/locate-character": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", - "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", - "dev": true, - "license": "MIT" - }, "node_modules/locate-path": { "version": "6.0.0", "dev": true, @@ -3806,19 +4733,59 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "dev": true, "license": "MIT" }, - "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==", + "node_modules/lowlight": { + "version": "1.9.2", + "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.9.2.tgz", + "integrity": "sha512-Ek18ElVCf/wF/jEm1b92gTnigh94CtBNWiZ2ad+vTgW7cTmQxUY3I98BjHK68gZAJEWmybGBZgx9qv3QxLQB/Q==", "dev": true, "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.5" + "fault": "^1.0.2", + "highlight.js": "~9.12.0" + } + }, + "node_modules/lru-cache": { + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.5.tgz", + "integrity": "sha512-sWZlbEP2OsHNkXrMl5GYk/jKk70MBng6UU4YI/qGDYbgf6YbP4EvmqISbXCoJiRKs+1bSpFHVgQxvJ17F2li5g==", + "dev": true, + "license": "ISC", + "dependencies": { + "pseudomap": "^1.0.2", + "yallist": "^2.1.2" + } + }, + "node_modules/map-age-cleaner": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/map-age-cleaner/-/map-age-cleaner-0.1.3.tgz", + "integrity": "sha512-bJzx6nMoP6PDLPBFmg7+xRKeFZvFboMrGlxmNj9ClvX53KrmvM5bXFXEWjbz4cz1AFn+jWJ9z/DJSz7hrs0w3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-defer": "^1.0.0" + }, + "engines": { + "node": ">=6" } }, "node_modules/math-intrinsics": { @@ -3829,11 +4796,35 @@ "node": ">= 0.4" } }, + "node_modules/mem": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mem/-/mem-3.0.1.tgz", + "integrity": "sha512-QKs47bslvOE0NbXOqG6lMxn6Bk0Iuw0vfrIeLykmQle2LkCw1p48dZDdzE+D88b/xqRJcZGcMNeDvSVma+NuIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^1.0.0", + "p-is-promise": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/merge-stream": { "version": "2.0.0", "dev": true, "license": "MIT" }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, "node_modules/micromatch": { "version": "4.0.8", "dev": true, @@ -3865,11 +4856,20 @@ "node": ">= 0.6" } }, - "node_modules/mini-css-extract-plugin": { - "version": "2.9.4", - "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.9.4.tgz", - "integrity": "sha512-ZWYT7ln73Hptxqxk2DxPU9MmapXRhxkJD6tkSR04dnQxm8BGu2hzgKLugK5yySD97u/8yy7Ma7E76k9ZdvtjkQ==", + "node_modules/mimic-fn": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", + "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/mini-css-extract-plugin": { + "version": "2.9.2", + "dev": true, + "license": "MIT", "dependencies": { "schema-utils": "^4.0.0", "tapable": "^2.2.1" @@ -3889,6 +4889,7 @@ "version": "8.17.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -3945,6 +4946,29 @@ "node": "*" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/moment": { "version": "2.29.4", "dev": true, @@ -3958,6 +4982,22 @@ "dev": true, "license": "MIT" }, + "node_modules/multimatch": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/multimatch/-/multimatch-2.1.0.tgz", + "integrity": "sha512-0mzK8ymiWdehTBiJh0vClAzGyQbdtyWqzSVx//EK4N/D+599RFlGfTAsKw2zMSABtDG9C6Ul2+t8f2Lbdjf5mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-differ": "^1.0.0", + "array-union": "^1.0.1", + "arrify": "^1.0.0", + "minimatch": "^3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/nanoid": { "version": "3.3.11", "dev": true, @@ -3985,23 +5025,67 @@ "dev": true, "license": "MIT" }, + "node_modules/nice-try": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", + "dev": true, + "license": "MIT" + }, "node_modules/node-addon-api": { "version": "7.1.1", "dev": true, "license": "MIT", "optional": true }, + "node_modules/node-gyp-build": { + "version": "4.8.4", + "dev": true, + "license": "MIT", + "optional": true, + "bin": { + "node-gyp-build": "bin.js", + "node-gyp-build-optional": "optional.js", + "node-gyp-build-test": "build-test.js" + } + }, "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", - "dev": true + "version": "2.0.19", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz", + "integrity": "sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "remove-trailing-separator": "^1.0.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/now-and-later": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/now-and-later/-/now-and-later-2.0.1.tgz", + "integrity": "sha512-KGvQ0cB70AQfg107Xvs/Fbu+dGmZoTRJp2TaPwcwQm3/7PteUyN2BCgk8KBMPGBUXZdVwyWS8fDCGFygBm19UQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.3.2" + }, + "engines": { + "node": ">= 0.10" + } }, "node_modules/npm-check-updates": { - "version": "19.2.0", - "resolved": "https://registry.npmjs.org/npm-check-updates/-/npm-check-updates-19.2.0.tgz", - "integrity": "sha512-XSIuL0FNgzXPDZa4lje7+OwHjiyEt84qQm6QMsQRbixNY5EHEM9nhgOjxjlK9jIbN+ysvSqOV8DKNS0zydwbdg==", + "version": "19.1.1", + "resolved": "https://registry.npmjs.org/npm-check-updates/-/npm-check-updates-19.1.1.tgz", + "integrity": "sha512-vy/uNbaK6Xfj/QzM8OXeALZak67E0uHjUlbdT1YGy4bdj0xlBU6AVd+8bscY8vlDpyzL6Y7mxcrX8kzEDeEpNg==", "dev": true, + "license": "Apache-2.0", "bin": { "ncu": "build/cli.js", "npm-check-updates": "build/cli.js" @@ -4011,6 +5095,39 @@ "npm": ">=8.12.1" } }, + "node_modules/npm-run-path": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz", + "integrity": "sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/npm-run-path/node_modules/path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/number-is-nan": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", + "integrity": "sha512-4jbtZXNAsfZbAHiiqjLPBiCl16dES1zI4Hpzzxw61Tk+loF+sBDBKx1ICKKKwIqQ7M0mFn1TmkN7euSncWgHiQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "dev": true, @@ -4022,6 +5139,62 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0", + "has-symbols": "^1.1.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/obsidian": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.10.2.tgz", + "integrity": "sha512-bX03YCHf06OTzI/D+QK71ajCPCmwr/cjxzlVXjQa10DjK5iHRWhtJJpp83arSCyayFMp23u+UHcY7hxcEx2Mvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/codemirror": "5.60.8", + "moment": "2.29.4" + }, + "peerDependencies": { + "@codemirror/state": "6.5.0", + "@codemirror/view": "6.38.1" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/optionator": { "version": "0.9.4", "dev": true, @@ -4038,6 +5211,96 @@ "node": ">= 0.8.0" } }, + "node_modules/ordered-read-streams": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/ordered-read-streams/-/ordered-read-streams-1.0.1.tgz", + "integrity": "sha512-Z87aSjx3r5c0ZB7bcJqIgIRX5bxR7A4aSzvIbaxd0oTkWBCOoKfuGHiKj60CHVUgg1Phm5yMZzBdt8XqRs73Mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.1" + } + }, + "node_modules/os-locale": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/os-locale/-/os-locale-3.1.0.tgz", + "integrity": "sha512-Z8l3R4wYWM40/52Z+S265okfFj8Kt2cC2MKY+xNi3kFs+XGI7WXu/I309QQQYbRW4ijiZ+yxs9pqEhJh0DqW3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^1.0.0", + "lcid": "^2.0.0", + "mem": "^4.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/os-locale/node_modules/mem": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/mem/-/mem-4.3.0.tgz", + "integrity": "sha512-qX2bG48pTqYRVmDB37rn/6PT7LcR8T7oAX3bf99u1Tt1nzxYfxkgqDwUwolPlXweM0XzBOBFzSx4kfp7KP1s/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "map-age-cleaner": "^0.1.1", + "mimic-fn": "^2.0.0", + "p-is-promise": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/os-locale/node_modules/mimic-fn": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/os-locale/node_modules/p-is-promise": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/p-is-promise/-/p-is-promise-2.1.0.tgz", + "integrity": "sha512-Y3W0wlRPK8ZMRbNq97l4M5otioeA5lm1z7bkNkxCka8HSPjR0xRWmpCmc9utiaLP9Jb1eD8BgeIxTW4AIF45Pg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/p-defer": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-defer/-/p-defer-1.0.0.tgz", + "integrity": "sha512-wB3wfAxZpk2AzOfUMJNL+d36xothRSyj8EXOa4f6GMqYDN9BJaaSISbsk+wS9abmnebVw95C2Kb5t85UmpCxuw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/p-is-promise": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/p-is-promise/-/p-is-promise-1.1.0.tgz", + "integrity": "sha512-zL7VE4JVS2IFSkR2GQKDSPEVxkoH43/p7oEnwpdCndKYJO0HVeRB7fA8TJwuLOTBREtK0ea8eHaxdwcpob5dmg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/p-limit": { "version": "3.1.0", "dev": true, @@ -4067,28 +5330,26 @@ } }, "node_modules/p-queue": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.0.1.tgz", - "integrity": "sha512-RhBdVhSwJb7Ocn3e8ULk4NMwBEuOxe+1zcgphUy9c2e5aR/xbEsdVXxHJ3lynw6Qiqu7OINEyHlZkiblEpaq7w==", + "version": "8.1.0", "dev": true, + "license": "MIT", "dependencies": { "eventemitter3": "^5.0.1", - "p-timeout": "^7.0.0" + "p-timeout": "^6.1.2" }, "engines": { - "node": ">=20" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/p-timeout": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-7.0.1.tgz", - "integrity": "sha512-AxTM2wDGORHGEkPCt8yqxOTMgpfbEHqF51f/5fJCmwFC3C/zNcGT63SymH2ttOAaiIws2zVg4+izQCjrakcwHg==", + "version": "6.1.4", "dev": true, + "license": "MIT", "engines": { - "node": ">=20" + "node": ">=14.16" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -4113,6 +5374,23 @@ "node": ">=6" } }, + "node_modules/parse-node-version": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parse-node-version/-/parse-node-version-1.0.1.tgz", + "integrity": "sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/path-dirname": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-dirname/-/path-dirname-1.0.2.tgz", + "integrity": "sha512-ALzNPpyNq9AqXMBjeymIjFDAkAFH06mHJH/cSBHAgU0s4vfpBn6b2nf8tiRLvagKD8RbTpq2FKTBg7cl9l3c7Q==", + "dev": true, + "license": "MIT" + }, "node_modules/path-exists": { "version": "4.0.0", "dev": true, @@ -4121,6 +5399,16 @@ "node": ">=8" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "dev": true, @@ -4209,6 +5497,22 @@ "node": ">=8" } }, + "node_modules/plugin-error": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/plugin-error/-/plugin-error-1.0.1.tgz", + "integrity": "sha512-L1zP0dk7vGweZME2i+EeakvUNqSrdiI3F91TwEoYiGrAfUXmVv6fJIq4g82PAXxNsWOp0J7ZqQy/3Szz0ajTxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-colors": "^1.0.1", + "arr-diff": "^4.0.0", + "arr-union": "^3.1.0", + "extend-shallow": "^3.0.2" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/postcss": { "version": "8.5.3", "dev": true, @@ -4227,6 +5531,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.8", "picocolors": "^1.1.1", @@ -4317,10 +5622,11 @@ } }, "node_modules/prettier": { - "version": "3.7.4", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz", - "integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==", + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz", + "integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==", "dev": true, + "license": "MIT", "bin": { "prettier": "bin/prettier.cjs" }, @@ -4331,11 +5637,17 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, "node_modules/promise-make-counter": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/promise-make-counter/-/promise-make-counter-1.0.2.tgz", "integrity": "sha512-FJAxTBWQuQoAs4ZOYuKX1FHXxEgKLEzBxUvwr4RoOglkTpOjWuM+RXsK3M9q5lMa8kjqctUrhwYeZFT4ygsnag==", - "dev": true, "license": "MIT", "dependencies": { "promise-make-naked": "^3.0.2" @@ -4345,9 +5657,49 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/promise-make-naked/-/promise-make-naked-3.0.2.tgz", "integrity": "sha512-B+b+kQ1YrYS7zO7P7bQcoqqMUizP06BOyNSBEnB5VJKDSWo8fsVuDkfSmwdjF0JsRtaNh83so5MMFJ95soH5jg==", - "dev": true, "license": "MIT" }, + "node_modules/pseudomap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", + "integrity": "sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/pump": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/pumpify": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/pumpify/-/pumpify-1.5.1.tgz", + "integrity": "sha512-oClZI37HvuUJJxSKKrC17bZ9Cu0ZYhEAGPsPUy9KlMUmv9dKX2o77RUmq7f3XjIxbwyGwYzbzQ1L2Ks8sIradQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "duplexify": "^3.6.0", + "inherits": "^2.0.3", + "pump": "^2.0.0" + } + }, + "node_modules/pumpify/node_modules/pump": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pump/-/pump-2.0.1.tgz", + "integrity": "sha512-ruPMNRkN3MHP1cWJc9OWr+T/xDP0jhXYCLfJcBuX54hhfIBnaQmAUMfDcG4DM5UMWByBbJY69QSphm3jtDKIkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "dev": true, @@ -4357,9 +5709,7 @@ } }, "node_modules/qs": { - "version": "6.14.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", - "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "version": "6.14.0", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -4372,6 +5722,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/randombytes": { "version": "2.1.0", "dev": true, @@ -4380,6 +5751,29 @@ "safe-buffer": "^5.1.0" } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, "node_modules/readdirp": { "version": "4.1.2", "dev": true, @@ -4404,9 +5798,9 @@ } }, "node_modules/reconcile-text": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/reconcile-text/-/reconcile-text-0.11.0.tgz", - "integrity": "sha512-a3sy3obazoc1BMEHx6IQn8ESZKnakVWZuRLi7OSEB56E8evRtrXBMj7yuo10fMoG4JkcZC6tokOfzpwZAKX+PQ==", + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/reconcile-text/-/reconcile-text-0.8.0.tgz", + "integrity": "sha512-evskVha3YgpP2ZelsFxP9t7CuKnwE7TrsH3FdrH2mfKbzjUWiNF7scHXsFbFS921lmFlAOB94DHNAWPvL34Mqg==", "dev": true, "license": "MIT" }, @@ -4415,6 +5809,59 @@ "dev": true, "license": "MIT" }, + "node_modules/remove-bom-buffer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/remove-bom-buffer/-/remove-bom-buffer-3.0.0.tgz", + "integrity": "sha512-8v2rWhaakv18qcvNeli2mZ/TMTL2nEyAKRvzo1WtnZBl15SHyEhrCu2/xKlJyUFKHiHgfXIyuY6g2dObJJycXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-buffer": "^1.1.5", + "is-utf8": "^0.2.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/remove-bom-buffer/node_modules/is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "dev": true, + "license": "MIT" + }, + "node_modules/remove-bom-stream": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/remove-bom-stream/-/remove-bom-stream-1.2.0.tgz", + "integrity": "sha512-wigO8/O08XHb8YPzpDDT+QmRANfW6vLqxfaXm1YXhnFf3AkSLyjfG3GEFg4McZkmgL7KvCj5u2KczkvSP6NfHA==", + "dev": true, + "license": "MIT", + "dependencies": { + "remove-bom-buffer": "^3.0.0", + "safe-buffer": "^5.1.0", + "through2": "^2.0.3" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/remove-trailing-separator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", + "integrity": "sha512-/hS+Y0u3aOfIETiaiirUFwDBDzmXPvO+jAfKTitUngIPzdKc6Z0LoFjM/CK5PL4C+eKwHohlHAb6H0VFfmmUsw==", + "dev": true, + "license": "ISC" + }, + "node_modules/replace-ext": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/replace-ext/-/replace-ext-1.0.1.tgz", + "integrity": "sha512-yD5BHCe7quCgBph4rMQ+0KkIRKwWCrHDOX1p1Gp6HwjPM5kVoCdKGNhN7ydqqsX6lJEnQDKZ/tFMiEdQ1dvPEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/require-directory": { "version": "2.1.1", "dev": true, @@ -4431,6 +5878,13 @@ "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-1.0.1.tgz", + "integrity": "sha512-IqSUtOVP4ksd1C/ej5zeEh/BIP2ajqpn8c5x+q99gvcIG/Qf0cud5raVnE/Dwd0ua9TXYDoDc0RE5hBSdz22Ug==", + "dev": true, + "license": "ISC" + }, "node_modules/resolve": { "version": "1.22.10", "dev": true, @@ -4477,6 +5931,19 @@ "node": ">=4" } }, + "node_modules/resolve-options": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/resolve-options/-/resolve-options-1.1.0.tgz", + "integrity": "sha512-NYDgziiroVeDC29xq7bp/CacZERYsA9bXYd1ZmcJlF3BcrZv5pTb4NG7SjdyKDnXZ84aC4vo2u6sNKIA1LCu/A==", + "dev": true, + "license": "MIT", + "dependencies": { + "value-or-function": "^3.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/resolve-pkg-maps": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", @@ -4502,49 +5969,39 @@ "node": ">=12" } }, - "node_modules/rollup": { - "version": "4.59.0", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", - "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", "dev": true, "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.59.0", - "@rollup/rollup-android-arm64": "4.59.0", - "@rollup/rollup-darwin-arm64": "4.59.0", - "@rollup/rollup-darwin-x64": "4.59.0", - "@rollup/rollup-freebsd-arm64": "4.59.0", - "@rollup/rollup-freebsd-x64": "4.59.0", - "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", - "@rollup/rollup-linux-arm-musleabihf": "4.59.0", - "@rollup/rollup-linux-arm64-gnu": "4.59.0", - "@rollup/rollup-linux-arm64-musl": "4.59.0", - "@rollup/rollup-linux-loong64-gnu": "4.59.0", - "@rollup/rollup-linux-loong64-musl": "4.59.0", - "@rollup/rollup-linux-ppc64-gnu": "4.59.0", - "@rollup/rollup-linux-ppc64-musl": "4.59.0", - "@rollup/rollup-linux-riscv64-gnu": "4.59.0", - "@rollup/rollup-linux-riscv64-musl": "4.59.0", - "@rollup/rollup-linux-s390x-gnu": "4.59.0", - "@rollup/rollup-linux-x64-gnu": "4.59.0", - "@rollup/rollup-linux-x64-musl": "4.59.0", - "@rollup/rollup-openbsd-x64": "4.59.0", - "@rollup/rollup-openharmony-arm64": "4.59.0", - "@rollup/rollup-win32-arm64-msvc": "4.59.0", - "@rollup/rollup-win32-ia32-msvc": "4.59.0", - "@rollup/rollup-win32-x64-gnu": "4.59.0", - "@rollup/rollup-win32-x64-msvc": "4.59.0", - "fsevents": "~2.3.2" + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" } }, "node_modules/rxjs": { @@ -4574,12 +6031,20 @@ ], "license": "MIT" }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, "node_modules/sass": { - "version": "1.96.0", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.96.0.tgz", - "integrity": "sha512-8u4xqqUeugGNCYwr9ARNtQKTOj4KmYiJAVKXf2CTIivTCR51j96htbMKWDru8H5SaQWpyVgTfOF8Ylyf5pun1Q==", + "version": "1.91.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.91.0.tgz", + "integrity": "sha512-aFOZHGf+ur+bp1bCHZ+u8otKGh77ZtmFyXDo4tlYvT7PWql41Kwd8wdkPqhhT+h2879IVblcHFglIMofsFd1EA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "chokidar": "^4.0.0", "immutable": "^5.0.2", @@ -4672,6 +6137,31 @@ "randombytes": "^2.1.0" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "dev": true, + "license": "ISC" + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/shallow-clone": { "version": "3.0.1", "dev": true, @@ -4783,6 +6273,43 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sigmund": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/sigmund/-/sigmund-1.0.1.tgz", + "integrity": "sha512-fCvEXfh6NWpm+YSuY2bpXb/VIihqWA6hLsgboC+0nl71Q7N7o2eaCW8mJa/NLvQhs6jpd3VZV4UiUQlV6+lc8g==", + "dev": true, + "license": "ISC" + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/slice-ansi": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-1.0.0.tgz", + "integrity": "sha512-POqxBK6Lb3q6s047D/XsDVNPnF9Dl8JSaqe9h9lURl0OdNqy/ujDrOiIHtsqXMGbWWTIomRzAMaTyawAU//Reg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-fullwidth-code-point": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/slice-ansi/node_modules/is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha512-VHskAKYM8RfSFXwee5t5cbN5PZeq1Wrh6qd5bkyiXIf6UQcN6w/A0eXM9r6t8d+GYOh+o6ZhiEnb88LN/Y8m2w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/source-map": { "version": "0.6.1", "dev": true, @@ -4799,6 +6326,47 @@ "node": ">=0.10.0" } }, + "node_modules/sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/stream-shift": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/streamfilter": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/streamfilter/-/streamfilter-1.0.7.tgz", + "integrity": "sha512-Gk6KZM+yNA1JpW0KzlZIhjo3EaBJDkYfXtYSbOwNIQ7Zd6006E6+sCFlW1NDvFG/vnXhKmw6TJJgiEQg/8lXfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, "node_modules/string-width": { "version": "4.2.3", "dev": true, @@ -4823,6 +6391,16 @@ "node": ">=8" } }, + "node_modules/strip-eof": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", + "integrity": "sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "dev": true, @@ -4837,16 +6415,14 @@ "node_modules/stubborn-fs": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-1.2.5.tgz", - "integrity": "sha512-H2N9c26eXjzL/S/K+i/RHHcFanE74dptvvjM8iwzwbVcWY/zjBbgRqF3K0DY4+OD+uTTASTBvDoxPDaPN02D7g==", - "dev": true + "integrity": "sha512-H2N9c26eXjzL/S/K+i/RHHcFanE74dptvvjM8iwzwbVcWY/zjBbgRqF3K0DY4+OD+uTTASTBvDoxPDaPN02D7g==" }, "node_modules/style-mod": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/supports-color": { "version": "8.1.1", @@ -4873,49 +6449,106 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/svelte": { - "version": "5.53.12", - "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.53.12.tgz", - "integrity": "sha512-4x/uk4rQe/d7RhfvS8wemTfNjQ0bJbKvamIzRBfTe2eHHjzBZ7PZicUQrC2ryj83xxEacfA1zHKd1ephD1tAxA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/remapping": "^2.3.4", - "@jridgewell/sourcemap-codec": "^1.5.0", - "@sveltejs/acorn-typescript": "^1.0.5", - "@types/estree": "^1.0.5", - "@types/trusted-types": "^2.0.7", - "acorn": "^8.12.1", - "aria-query": "5.3.1", - "axobject-query": "^4.1.0", - "clsx": "^2.1.1", - "devalue": "^5.6.4", - "esm-env": "^1.2.1", - "esrap": "^2.2.2", - "is-reference": "^3.0.3", - "locate-character": "^3.0.0", - "magic-string": "^0.30.11", - "zimmerframe": "^1.1.2" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/sync-client": { "resolved": "sync-client", "link": true }, "node_modules/tapable": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", - "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "version": "2.2.1", "dev": true, + "license": "MIT", "engines": { "node": ">=6" + } + }, + "node_modules/term-size": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/term-size/-/term-size-1.2.0.tgz", + "integrity": "sha512-7dPUZQGy/+m3/wjVz3ZW5dobSoD/02NxJpoXUX0WIyjfVS3l0c+b/+9phIDFA7FHzkYtwtMFgeGZ/Y8jVTeqQQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "execa": "^0.7.0" }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/webpack" + "engines": { + "node": ">=4" + } + }, + "node_modules/term-size/node_modules/cross-spawn": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz", + "integrity": "sha512-pTgQJ5KC0d2hcY8eyL1IzlBPYjTkyH72XRZPnLyKus2mBfNjQs3klqbJU2VILqZryAZUt9JOb3h/mWMy23/f5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "lru-cache": "^4.0.1", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + } + }, + "node_modules/term-size/node_modules/execa": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-0.7.0.tgz", + "integrity": "sha512-RztN09XglpYI7aBBrJCPW95jEH7YF1UEPOoX9yDhUTPdp7mK+CQvnLTuD10BNXZ3byLTu2uehZ8EcKT/4CGiFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^5.0.1", + "get-stream": "^3.0.0", + "is-stream": "^1.1.0", + "npm-run-path": "^2.0.0", + "p-finally": "^1.0.0", + "signal-exit": "^3.0.0", + "strip-eof": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/term-size/node_modules/get-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", + "integrity": "sha512-GlhdIUuVakc8SJ6kK0zAFbiGzRFzNnY4jUuEbV9UROo4Y+0Ny4fjvcZFVTeDA4odpFyOQzaw6hXukJSq/f28sQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/term-size/node_modules/shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^1.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/term-size/node_modules/shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/term-size/node_modules/which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "which": "bin/which" } }, "node_modules/terser": { @@ -4936,10 +6569,9 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.3.16", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz", - "integrity": "sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q==", + "version": "5.3.14", "dev": true, + "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "jest-worker": "^27.4.5", @@ -4973,6 +6605,7 @@ "version": "8.17.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -5051,59 +6684,59 @@ "resolved": "test-client", "link": true }, + "node_modules/through2": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", + "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "~2.3.6", + "xtend": "~4.0.1" + } + }, + "node_modules/through2-filter": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/through2-filter/-/through2-filter-3.0.0.tgz", + "integrity": "sha512-jaRjI2WxN3W1V8/FMZ9HKIBXixtiqs3SQSX4/YGIiP3gL6djW48VoZq9tDqeCWs3MT8YY5wb/zli8VW8snY1CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "through2": "~2.0.0", + "xtend": "~4.0.0" + } + }, + "node_modules/time-stamp": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/time-stamp/-/time-stamp-1.1.0.tgz", + "integrity": "sha512-gLCeArryy2yNTRzTGKbZbloctj64jkZ57hj5zdraXue6aFgd6PmvVtEyiUU+hvU0v7q08oVv8r8ev0tRo6bvgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/tiny-readdir": { "version": "2.7.4", "resolved": "https://registry.npmjs.org/tiny-readdir/-/tiny-readdir-2.7.4.tgz", "integrity": "sha512-721U+zsYwDirjr8IM6jqpesD/McpZooeFi3Zc6mcjy1pse2C+v19eHPFRqz4chGXZFw7C3KITDjAtHETc2wj7Q==", - "dev": true, "license": "MIT", "dependencies": { "promise-make-counter": "^1.0.2" } }, - "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "node_modules/to-absolute-glob": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/to-absolute-glob/-/to-absolute-glob-2.0.2.tgz", + "integrity": "sha512-rtwLUQEwT8ZeKQbyFJyomBRYXyE16U5VKuy0ftxLMK/PZb2fkOsg5r9kHdauuVDbsNdIBoC/HCthpidamQFXYA==", "dev": true, + "license": "MIT", "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "is-absolute": "^1.0.0", + "is-negated-glob": "^1.0.0" }, "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinyglobby/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, - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" + "node": ">=0.10.0" } }, "node_modules/to-regex-range": { @@ -5117,6 +6750,29 @@ "node": ">=8.0" } }, + "node_modules/to-through": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-through/-/to-through-2.0.0.tgz", + "integrity": "sha512-+QIz37Ly7acM4EMdw2PRN389OneM5+d844tirkGp4dPKzI5OE72V9OsbFp+CIYJDahZ41ZV05hNtcPAQUAm9/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "through2": "^2.0.3" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/to-time": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/to-time/-/to-time-1.0.2.tgz", + "integrity": "sha512-+wqaiQvnido2DI1bpiQ/Zv1LiOE9Fd0v35ySnNeqFmKNYJTJY/+ENI+3sHXCMzbAAOR/43aNyLM0XTpi0/zSQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "bignumber.js": "^2.4.0" + } + }, "node_modules/tree-kill": { "version": "1.2.2", "dev": true, @@ -5130,6 +6786,7 @@ "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=18.12" }, @@ -5138,10 +6795,9 @@ } }, "node_modules/ts-loader": { - "version": "9.5.4", - "resolved": "https://registry.npmjs.org/ts-loader/-/ts-loader-9.5.4.tgz", - "integrity": "sha512-nCz0rEwunlTZiy6rXFByQU1kVVpCIgUpc/psFiKVrUwrizdnIbRFu8w7bxhUF0X613DYwT4XzrZHpVyMe758hQ==", + "version": "9.5.2", "dev": true, + "license": "MIT", "dependencies": { "chalk": "^4.1.0", "enhanced-resolve": "^5.0.0", @@ -5171,12 +6827,13 @@ "license": "0BSD" }, "node_modules/tsx": { - "version": "4.21.0", - "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz", - "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", + "version": "4.20.6", + "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.20.6.tgz", + "integrity": "sha512-ytQKuwgmrrkDTFP4LjR0ToE2nqgy886GpvRSpU0JAnrdBYppuY5rLkRUYPU1yCryb24SsKBTL/hlDQAEFVwtZg==", "dev": true, + "license": "MIT", "dependencies": { - "esbuild": "~0.27.0", + "esbuild": "~0.25.0", "get-tsconfig": "^4.7.5" }, "bin": { @@ -5201,11 +6858,10 @@ } }, "node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "version": "5.8.3", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5215,15 +6871,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.49.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.49.0.tgz", - "integrity": "sha512-zRSVH1WXD0uXczCXw+nsdjGPUdx4dfrs5VQoHnUWmv1U3oNlAKv4FUNdLDhVUg+gYn+a5hUESqch//Rv5wVhrg==", + "version": "8.41.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.41.0.tgz", + "integrity": "sha512-n66rzs5OBXW3SFSnZHr2T685q1i4ODm2nulFJhMZBotaTavsS8TrI3d7bDlRSs9yWo7HmyWrN9qDu14Qv7Y0Dw==", "dev": true, + "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.49.0", - "@typescript-eslint/parser": "8.49.0", - "@typescript-eslint/typescript-estree": "8.49.0", - "@typescript-eslint/utils": "8.49.0" + "@typescript-eslint/eslint-plugin": "8.41.0", + "@typescript-eslint/parser": "8.41.0", + "@typescript-eslint/typescript-estree": "8.41.0", + "@typescript-eslint/utils": "8.41.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5237,11 +6894,33 @@ "typescript": ">=4.8.4 <6.0.0" } }, + "node_modules/unc-path-regex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/unc-path-regex/-/unc-path-regex-0.1.2.tgz", + "integrity": "sha512-eXL4nmJT7oCpkZsHZUOJo8hcX3GbsiDOa0Qu9F646fi8dT3XuSVopVqAcEiVzSKKH7UoDti23wNX3qGFxcW5Qg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/undici-types": { - "version": "7.16.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", - "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", - "dev": true + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz", + "integrity": "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==", + "dev": true, + "license": "MIT" + }, + "node_modules/unique-stream": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/unique-stream/-/unique-stream-2.4.0.tgz", + "integrity": "sha512-V6QarSfeSgDipGA9EZdoIzu03ZDlOFkk+FbEP5cwgrZXN3iIkYR91IjU2EnM6rB835kGQsqHX8qncObTXV+6KA==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-stable-stringify-without-jsonify": "^1.0.1", + "through2-filter": "3.0.0" + } }, "node_modules/universalify": { "version": "2.0.1", @@ -5252,9 +6931,7 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.2.tgz", - "integrity": "sha512-E85pfNzMQ9jpKkA7+TJAi4TJN+tBCuWh5rUcS/sv6cFi+1q9LYDwDI5dpUL0u/73EElyQ8d3TEaeW4sPedBqYA==", + "version": "1.1.3", "dev": true, "funding": [ { @@ -5270,6 +6947,7 @@ "url": "https://github.com/sponsors/ai" } ], + "license": "MIT", "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" @@ -5325,193 +7003,84 @@ "uuid": "dist-node/bin/uuid" } }, + "node_modules/value-or-function": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/value-or-function/-/value-or-function-3.0.0.tgz", + "integrity": "sha512-jdBB2FrWvQC/pnPtIqcLsMaQgjhdb6B7tk1MMyTKapox+tQZbdRP4uLxu/JY0t7fbfDCUMnuelzEYv5GsxHhdg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vault-link-obsidian-plugin": { "resolved": "obsidian-plugin", "link": true }, - "node_modules/vite": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", - "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "node_modules/vinyl": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-2.2.1.tgz", + "integrity": "sha512-LII3bXRFBZLlezoG5FfZVcXflZgWP/4dCwKtxd5ky9+LOtM4CS3bIRQsmR1KMnMW07jpE8fqR2lcxPZ+8sJIcw==", "dev": true, "license": "MIT", "dependencies": { - "esbuild": "^0.25.0", - "fdir": "^6.4.4", - "picomatch": "^4.0.2", - "postcss": "^8.5.3", - "rollup": "^4.34.9", - "tinyglobby": "^0.2.13" - }, - "bin": { - "vite": "bin/vite.js" + "clone": "^2.1.1", + "clone-buffer": "^1.0.0", + "clone-stats": "^1.0.0", + "cloneable-readable": "^1.0.0", + "remove-trailing-separator": "^1.0.1", + "replace-ext": "^1.0.0" }, "engines": { - "node": "^18.0.0 || ^20.0.0 || >=22.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", - "jiti": ">=1.21.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "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": ">= 0.10" } }, - "node_modules/vite/node_modules/@esbuild/linux-x64": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", - "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", - "cpu": [ - "x64" - ], + "node_modules/vinyl-fs": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/vinyl-fs/-/vinyl-fs-3.0.3.tgz", + "integrity": "sha512-vIu34EkyNyJxmP0jscNzWBSygh7VWhqun6RmqVfXePrOwi9lhvRs//dOaGOTRUQr4tx7/zd26Tk5WeSVZitgng==", "dev": true, "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=18" - } - }, - "node_modules/vite/node_modules/esbuild": { - "version": "0.25.12", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", - "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" + "dependencies": { + "fs-mkdirp-stream": "^1.0.0", + "glob-stream": "^6.1.0", + "graceful-fs": "^4.0.0", + "is-valid-glob": "^1.0.0", + "lazystream": "^1.0.0", + "lead": "^1.0.0", + "object.assign": "^4.0.4", + "pumpify": "^1.3.5", + "readable-stream": "^2.3.3", + "remove-bom-buffer": "^3.0.0", + "remove-bom-stream": "^1.2.0", + "resolve-options": "^1.1.0", + "through2": "^2.0.0", + "to-through": "^2.0.0", + "value-or-function": "^3.0.0", + "vinyl": "^2.0.0", + "vinyl-sourcemap": "^1.1.0" }, "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.12", - "@esbuild/android-arm": "0.25.12", - "@esbuild/android-arm64": "0.25.12", - "@esbuild/android-x64": "0.25.12", - "@esbuild/darwin-arm64": "0.25.12", - "@esbuild/darwin-x64": "0.25.12", - "@esbuild/freebsd-arm64": "0.25.12", - "@esbuild/freebsd-x64": "0.25.12", - "@esbuild/linux-arm": "0.25.12", - "@esbuild/linux-arm64": "0.25.12", - "@esbuild/linux-ia32": "0.25.12", - "@esbuild/linux-loong64": "0.25.12", - "@esbuild/linux-mips64el": "0.25.12", - "@esbuild/linux-ppc64": "0.25.12", - "@esbuild/linux-riscv64": "0.25.12", - "@esbuild/linux-s390x": "0.25.12", - "@esbuild/linux-x64": "0.25.12", - "@esbuild/netbsd-arm64": "0.25.12", - "@esbuild/netbsd-x64": "0.25.12", - "@esbuild/openbsd-arm64": "0.25.12", - "@esbuild/openbsd-x64": "0.25.12", - "@esbuild/openharmony-arm64": "0.25.12", - "@esbuild/sunos-x64": "0.25.12", - "@esbuild/win32-arm64": "0.25.12", - "@esbuild/win32-ia32": "0.25.12", - "@esbuild/win32-x64": "0.25.12" + "node": ">= 0.10" } }, - "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==", + "node_modules/vinyl-sourcemap": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/vinyl-sourcemap/-/vinyl-sourcemap-1.1.0.tgz", + "integrity": "sha512-NiibMgt6VJGJmyw7vtzhctDcfKch4e4n9TBeoWlirb7FMg9/1Ov9k+A5ZRAtywBpRPiyECvQRQllYM8dECegVA==", "dev": true, "license": "MIT", + "dependencies": { + "append-buffer": "^1.0.2", + "convert-source-map": "^1.5.0", + "graceful-fs": "^4.1.6", + "normalize-path": "^2.1.1", + "now-and-later": "^2.0.0", + "remove-bom-buffer": "^3.0.0", + "vinyl": "^2.0.0" + }, "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/vite/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/vitefu": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/vitefu/-/vitefu-1.1.2.tgz", - "integrity": "sha512-zpKATdUbzbsycPFBN71nS2uzBUQiVnFoOrr2rvqv34S1lcAgMKKkjWleLGeiJlZ8lwCXvtWaRn7R3ZC16SYRuw==", - "dev": true, - "license": "MIT", - "workspaces": [ - "tests/deps/*", - "tests/projects/*", - "tests/projects/workspace/packages/*" - ], - "peerDependencies": { - "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-beta.0" - }, - "peerDependenciesMeta": { - "vite": { - "optional": true - } + "node": ">= 0.10" } }, "node_modules/w3c-keyname": { @@ -5519,14 +7088,12 @@ "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/watcher": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/watcher/-/watcher-2.3.1.tgz", "integrity": "sha512-d3yl+ey35h05r5EFP0TafE2jsmQUJ9cc2aernRVyAkZiu0J3+3TbNugNcqdUJDoWOfL2p+bNsN427stsBC/HnA==", - "dev": true, "dependencies": { "dettle": "^1.0.2", "stubborn-fs": "^1.2.5", @@ -5534,10 +7101,9 @@ } }, "node_modules/watchpack": { - "version": "2.4.4", - "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz", - "integrity": "sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA==", + "version": "2.4.2", "dev": true, + "license": "MIT", "dependencies": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" @@ -5547,37 +7113,35 @@ } }, "node_modules/webpack": { - "version": "5.103.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.103.0.tgz", - "integrity": "sha512-HU1JOuV1OavsZ+mfigY0j8d1TgQgbZ6M+J75zDkpEAwYeXjWSqrGJtgnPblJjd/mAyTNQ7ygw0MiKOn6etz8yw==", + "version": "5.99.9", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", - "@types/estree": "^1.0.8", + "@types/estree": "^1.0.6", "@types/json-schema": "^7.0.15", "@webassemblyjs/ast": "^1.14.1", "@webassemblyjs/wasm-edit": "^1.14.1", "@webassemblyjs/wasm-parser": "^1.14.1", - "acorn": "^8.15.0", - "acorn-import-phases": "^1.0.3", - "browserslist": "^4.26.3", + "acorn": "^8.14.0", + "browserslist": "^4.24.0", "chrome-trace-event": "^1.0.2", - "enhanced-resolve": "^5.17.3", + "enhanced-resolve": "^5.17.1", "es-module-lexer": "^1.2.1", "eslint-scope": "5.1.1", "events": "^3.2.0", "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.2.11", "json-parse-even-better-errors": "^2.3.1", - "loader-runner": "^4.3.1", + "loader-runner": "^4.2.0", "mime-types": "^2.1.27", "neo-async": "^2.6.2", - "schema-utils": "^4.3.3", - "tapable": "^2.3.0", + "schema-utils": "^4.3.2", + "tapable": "^2.1.1", "terser-webpack-plugin": "^5.3.11", - "watchpack": "^2.4.4", - "webpack-sources": "^3.3.3" + "watchpack": "^2.4.1", + "webpack-sources": "^3.2.3" }, "bin": { "webpack": "bin/webpack.js" @@ -5599,6 +7163,7 @@ "version": "6.0.1", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.6.1", "@webpack-cli/configtest": "^3.0.1", @@ -5663,20 +7228,18 @@ } }, "node_modules/webpack-sources": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.3.3.tgz", - "integrity": "sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg==", + "version": "3.2.3", "dev": true, + "license": "MIT", "engines": { "node": ">=10.13.0" } }, "node_modules/webpack/node_modules/ajv": { "version": "8.17.1", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", - "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -5690,9 +7253,8 @@ }, "node_modules/webpack/node_modules/ajv-keywords": { "version": "5.1.0", - "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", - "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", "dev": true, + "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3" }, @@ -5722,15 +7284,13 @@ }, "node_modules/webpack/node_modules/json-schema-traverse": { "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/webpack/node_modules/schema-utils": { - "version": "4.3.3", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", - "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "version": "4.3.2", "dev": true, + "license": "MIT", "dependencies": { "@types/json-schema": "^7.0.9", "ajv": "^8.9.0", @@ -5759,6 +7319,13 @@ "node": ">= 8" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "dev": true, + "license": "ISC" + }, "node_modules/wildcard": { "version": "2.0.1", "dev": true, @@ -5788,6 +7355,62 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-escape": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/xml-escape/-/xml-escape-1.1.0.tgz", + "integrity": "sha512-B/T4sDK8Z6aUh/qNr7mjKAwwncIljFuUP+DO/D5hloYFj+90O88z8Wf7oSucZTHxBAsC1/CTP4rtx/x1Uf72Mg==", + "dev": true, + "license": "MIT License" + }, + "node_modules/xmlbuilder": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-10.1.1.tgz", + "integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, "node_modules/y18n": { "version": "5.0.8", "dev": true, @@ -5796,6 +7419,13 @@ "node": ">=10" } }, + "node_modules/yallist": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", + "integrity": "sha512-ncTzHV7NvsQZkYe1DW7cbDLm0YpzHmZF5r/iyP3ZnQtMiJ+pjzisCiMNI+Sj+xQF5pXhSHxSB3uDbsBTzY/c2A==", + "dev": true, + "license": "ISC" + }, "node_modules/yargs": { "version": "17.7.2", "dev": true, @@ -5832,92 +7462,69 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/zimmerframe": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.4.tgz", - "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", - "dev": true, - "license": "MIT" - }, "obsidian-plugin": { "name": "vault-link-obsidian-plugin", "version": "0.14.0", "license": "MIT", "devDependencies": { - "@types/node": "^25.0.2", + "@types/node": "^24.8.1", "css-loader": "^7.1.2", "date-fns": "^4.1.0", "file-loader": "^6.2.0", - "fs-extra": "^11.3.2", - "mini-css-extract-plugin": "^2.9.4", - "obsidian": "1.11.0", - "reconcile-text": "^0.11.0", + "fs-extra": "^11.3.0", + "mini-css-extract-plugin": "^2.9.2", + "obsidian": "1.10.2", + "reconcile-text": "^0.8.0", "resolve-url-loader": "^5.0.0", - "sass": "^1.96.0", + "sass": "^1.91.0", "sass-loader": "^16.0.6", "sync-client": "file:../sync-client", - "terser-webpack-plugin": "^5.3.16", - "ts-loader": "^9.5.4", + "terser-webpack-plugin": "^5.3.14", + "ts-loader": "^9.5.2", "tslib": "2.8.1", - "tsx": "^4.21.0", - "typescript": "5.9.3", + "tsx": "^4.20.6", + "typescript": "5.8.3", "url": "^0.11.4", - "webpack": "^5.103.0", + "webpack": "^5.99.9", "webpack-cli": "^6.0.1" } }, - "obsidian-plugin/node_modules/@codemirror/view": { - "version": "6.38.6", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.6.tgz", - "integrity": "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==", - "dev": true, - "peer": true, - "dependencies": { - "@codemirror/state": "^6.5.0", - "crelt": "^1.0.6", - "style-mod": "^4.1.0", - "w3c-keyname": "^2.2.4" - } - }, - "obsidian-plugin/node_modules/obsidian": { - "version": "1.11.0", - "resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.11.0.tgz", - "integrity": "sha512-lVqN9AmDWHzhNATi2tDnjqVgI6WUYKeT+lIsAycAyLt4XCC6zRsWzb+tFCiB7Rn3PpttefjoovilhYwvS4Iqxw==", - "dev": true, - "dependencies": { - "@types/codemirror": "5.60.8", - "moment": "2.29.4" - }, - "peerDependencies": { - "@codemirror/state": "6.5.0", - "@codemirror/view": "6.38.6" - } - }, "sync-client": { "version": "0.14.0", "devDependencies": { - "@sentry/browser": "^10.30.0", - "@types/node": "^25.0.2", + "@sentry/browser": "^10.8.0", + "@types/node": "^24.8.1", "byte-base64": "^1.1.0", - "minimatch": "^10.1.1", - "p-queue": "^9.0.1", + "minimatch": "^10.0.1", + "p-queue": "^8.1.0", "reconcile-text": "^0.8.0", - "ts-loader": "^9.5.4", + "ts-loader": "^9.5.2", "tslib": "2.8.1", - "tsx": "^4.21.0", - "typescript": "5.9.3", - "webpack": "^5.103.0", + "tsx": "^4.20.6", + "typescript": "5.8.3", + "uuid": "^13.0.0", + "webpack": "^5.99.9", "webpack-cli": "^6.0.1", - "webpack-merge": "^6.0.1" + "webpack-merge": "^6.0.1", + "ws": "^8.18.3" + } + }, + "sync-client/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" } }, "sync-client/node_modules/minimatch": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", - "integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==", + "version": "10.0.1", "dev": true, + "license": "ISC", "dependencies": { - "@isaacs/brace-expansion": "^5.0.0" + "brace-expansion": "^2.0.1" }, "engines": { "node": "20 || >=22" @@ -5926,27 +7533,20 @@ "url": "https://github.com/sponsors/isaacs" } }, - "sync-client/node_modules/reconcile-text": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/reconcile-text/-/reconcile-text-0.8.0.tgz", - "integrity": "sha512-evskVha3YgpP2ZelsFxP9t7CuKnwE7TrsH3FdrH2mfKbzjUWiNF7scHXsFbFS921lmFlAOB94DHNAWPvL34Mqg==", - "dev": true, - "license": "MIT" - }, "test-client": { "version": "0.14.0", "bin": { "test-client": "dist/cli.js" }, "devDependencies": { - "@types/node": "^25.0.2", + "@types/node": "^24.8.1", "sync-client": "file:../sync-client", - "ts-loader": "^9.5.4", + "ts-loader": "^9.5.2", "tslib": "2.8.1", - "tsx": "^4.21.0", - "typescript": "5.9.3", + "tsx": "^4.20.6", + "typescript": "5.8.3", "uuid": "^13.0.0", - "webpack": "^5.103.0", + "webpack": "^5.99.9", "webpack-cli": "^6.0.1" } } diff --git a/frontend/package.json b/frontend/package.json index 69edb1fe..0dd9057d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -6,40 +6,28 @@ "obsidian-plugin", "test-client", "deterministic-tests", - "local-client-cli", - "history-ui" + "local-client-cli" ], "prettier": { "trailingComma": "none", "tabWidth": 4, "useTabs": false, - "endOfLine": "lf", - "overrides": [ - { - "files": [ - "*.yml", - "*.yaml", - "*.md" - ], - "options": { - "tabWidth": 2 - } - } - ] + "endOfLine": "lf" }, "scripts": { "build": "npm run build --workspaces", "dev": "concurrently --kill-others \"npm run dev -w sync-client\" \"npm run dev -w obsidian-plugin\"", "test": "npm run test --workspaces", "lint": "eslint --fix sync-client obsidian-plugin test-client deterministic-tests local-client-cli && prettier --write \"**/*.ts\"", - "update": "ncu -u" + "update": "ncu -u -ws" }, "devDependencies": { "concurrently": "^9.2.1", - "eslint": "9.39.2", - "eslint-plugin-unused-imports": "^4.3.0", - "npm-check-updates": "^19.2.0", - "prettier": "^3.7.4", - "typescript-eslint": "8.49.0" + "eclint": "^2.8.1", + "eslint": "9.38.0", + "eslint-plugin-unused-imports": "^4.1.4", + "npm-check-updates": "^19.1.1", + "prettier": "^3.6.2", + "typescript-eslint": "8.41.0" } } diff --git a/frontend/sync-client/package.json b/frontend/sync-client/package.json index 45c33764..aa369fa7 100644 --- a/frontend/sync-client/package.json +++ b/frontend/sync-client/package.json @@ -14,17 +14,19 @@ }, "devDependencies": { "byte-base64": "^1.1.0", - "minimatch": "^10.1.1", - "p-queue": "^9.0.1", + "minimatch": "^10.0.1", + "p-queue": "^8.1.0", "reconcile-text": "^0.8.0", - "@types/node": "^25.0.2", - "ts-loader": "^9.5.4", + "uuid": "^13.0.0", + "@types/node": "^24.8.1", + "ts-loader": "^9.5.2", "tslib": "2.8.1", - "tsx": "^4.21.0", - "typescript": "5.9.3", - "webpack": "^5.103.0", + "tsx": "^4.20.6", + "typescript": "5.8.3", + "webpack": "^5.99.9", "webpack-cli": "^6.0.1", "webpack-merge": "^6.0.1", - "@sentry/browser": "^10.30.0" + "@sentry/browser": "^10.8.0", + "ws": "^8.18.3" } } diff --git a/frontend/sync-client/src/consts.ts b/frontend/sync-client/src/consts.ts index 86319fd7..da70ba47 100644 --- a/frontend/sync-client/src/consts.ts +++ b/frontend/sync-client/src/consts.ts @@ -1,6 +1,6 @@ export const TIMEOUT_FOR_MERGING_HISTORY_ENTRIES_IN_SECONDS = 60; +export const DIFF_CACHE_SIZE_MB = 2; export const MAX_LOG_MESSAGE_COUNT = 100000; export const MAX_HISTORY_ENTRY_COUNT = 5000; -export const SUPPORTED_API_VERSION = 3; -export const WEBSOCKET_DISCONNECT_TIMEOUT_IN_SECONDS = 10; -export const WEBSOCKET_CONNECTION_TIMEOUT_IN_SECONDS = 10; +export const SUPPORTED_API_VERSION = 2; +export const WEBSOCKET_DISCONNECT_TIMEOUT_IN_S = 10; diff --git a/frontend/sync-client/src/errors/file-already-exists-error.ts b/frontend/sync-client/src/errors/file-already-exists-error.ts deleted file mode 100644 index 35f51a66..00000000 --- a/frontend/sync-client/src/errors/file-already-exists-error.ts +++ /dev/null @@ -1,9 +0,0 @@ -export class FileAlreadyExistsError extends Error { - public constructor( - message: string, - public readonly filePath: string - ) { - super(message); - this.name = "FileAlreadyExistsError"; - } -} diff --git a/frontend/sync-client/src/errors/http-client-error.ts b/frontend/sync-client/src/errors/http-client-error.ts deleted file mode 100644 index 2475cf35..00000000 --- a/frontend/sync-client/src/errors/http-client-error.ts +++ /dev/null @@ -1,9 +0,0 @@ -export class HttpClientError extends Error { - public constructor( - public readonly statusCode: number, - message: string - ) { - super(message); - this.name = "HttpClientError"; - } -} diff --git a/frontend/sync-client/src/errors/file-not-found-error.ts b/frontend/sync-client/src/file-operations/file-not-found-error.ts similarity index 100% rename from frontend/sync-client/src/errors/file-not-found-error.ts rename to frontend/sync-client/src/file-operations/file-not-found-error.ts diff --git a/frontend/sync-client/src/file-operations/file-operations.test.ts b/frontend/sync-client/src/file-operations/file-operations.test.ts index 7916ab57..998e47ec 100644 --- a/frontend/sync-client/src/file-operations/file-operations.test.ts +++ b/frontend/sync-client/src/file-operations/file-operations.test.ts @@ -1,14 +1,15 @@ import { describe, it } from "node:test"; -import assert from "node:assert/strict"; -import type { RelativePath } from "../sync-operations/types"; +import type { + Database, + DocumentRecord, + RelativePath +} from "../persistence/database"; import { FileOperations } from "./file-operations"; import { Logger } from "../tracing/logger"; import { assertSetContainsExactly } from "../utils/assert-set-contains-exactly"; import type { FileSystemOperations } from "./filesystem-operations"; import type { TextWithCursors } from "reconcile-text"; import type { ServerConfig, ServerConfigData } from "../services/server-config"; -import { ExpectedFsEvents } from "../sync-operations/expected-fs-events"; -import { FileAlreadyExistsError } from "../errors/file-already-exists-error"; class MockServerConfig implements Pick { public async getConfig(): Promise { @@ -20,13 +21,29 @@ class MockServerConfig implements Pick { } } +class MockDatabase implements Partial { + public getLatestDocumentByRelativePath( + _find: RelativePath + ): DocumentRecord | undefined { + // no-op + return undefined; + } + + public move( + _oldRelativePath: RelativePath, + _newRelativePath: RelativePath + ): void { + // no-op + } +} + class FakeFileSystemOperations implements FileSystemOperations { public readonly names = new Set(); public async listFilesRecursively( _root: RelativePath | undefined ): Promise { - return Array.from(this.names); + return ["file.md"]; } public async read(_path: RelativePath): Promise { throw new Error("Method not implemented."); @@ -46,14 +63,17 @@ class FakeFileSystemOperations implements FileSystemOperations { public async getFileSize(_path: RelativePath): Promise { throw new Error("Method not implemented."); } + public async getModificationTime(_path: RelativePath): Promise { + throw new Error("Method not implemented."); + } public async exists(path: RelativePath): Promise { return this.names.has(path); } public async createDirectory(_path: RelativePath): Promise { - // no-op for the in-memory fake; we only track files + // this is called but irrelevant for this mock } - public async delete(path: RelativePath): Promise { - this.names.delete(path); + public async delete(_path: RelativePath): Promise { + throw new Error("Method not implemented."); } public async rename( oldPath: RelativePath, @@ -64,92 +84,152 @@ class FakeFileSystemOperations implements FileSystemOperations { } } -function makeOps(): { - fs: FakeFileSystemOperations; - ops: FileOperations; -} { - const fs = new FakeFileSystemOperations(); - const ops = new FileOperations( - new Logger(), - fs, - new MockServerConfig() as ServerConfig, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion - new ExpectedFsEvents() - ); - return { fs, ops }; -} - describe("File operations", () => { - it("create writes the file at the requested path", async () => { - const { fs, ops } = makeOps(); - - const result = await ops.create("a", new Uint8Array()); - - assertSetContainsExactly(fs.names, "a"); - assert.equal(result.actualPath, "a"); - }); - - it("create throws FileAlreadyExistsError when the path is occupied", async () => { - const { fs, ops } = makeOps(); - - await ops.create("note.md", new Uint8Array()); - await assert.rejects( - ops.create("note.md", new Uint8Array()), - FileAlreadyExistsError + it("should deconflict renames", async () => { + const fileSystemOperations = new FakeFileSystemOperations(); + const fileOperations = new FileOperations( + new Logger(), + new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + fileSystemOperations, + new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion ); - // The original file is left intact and no other entries appeared. - assertSetContainsExactly(fs.names, "note.md"); + await fileOperations.create("a", new Uint8Array()); + assertSetContainsExactly(fileSystemOperations.names, "a"); + await fileOperations.move("a", "b"); + assertSetContainsExactly(fileSystemOperations.names, "b"); + + await fileOperations.create("c", new Uint8Array()); + assertSetContainsExactly(fileSystemOperations.names, "b", "c"); + + await fileOperations.move("c", "b"); + assertSetContainsExactly(fileSystemOperations.names, "b", "b (1)"); + + await fileOperations.create("c", new Uint8Array()); + await fileOperations.move("c", "b"); + assertSetContainsExactly( + fileSystemOperations.names, + "b", + "b (1)", + "b (2)" + ); }); - it("move to an empty target just renames the file", async () => { - const { fs, ops } = makeOps(); - - await ops.create("a", new Uint8Array()); - assertSetContainsExactly(fs.names, "a"); - - const result = await ops.move("a", "b"); - assertSetContainsExactly(fs.names, "b"); - assert.equal(result.actualPath, "b"); - }); - - it("move with same source and target is a no-op", async () => { - const { fs, ops } = makeOps(); - - await ops.create("a", new Uint8Array()); - const result = await ops.move("a", "a"); - - assertSetContainsExactly(fs.names, "a"); - assert.equal(result.actualPath, "a"); - }); - - it("move throws FileAlreadyExistsError when the target is occupied", async () => { - const { fs, ops } = makeOps(); - - await ops.create("source.md", new Uint8Array()); - await ops.create("dest.md", new Uint8Array()); - - await assert.rejects( - ops.move("source.md", "dest.md"), - FileAlreadyExistsError + it("should deconflict renames with file extension", async () => { + const fileSystemOperations = new FakeFileSystemOperations(); + const fileOperations = new FileOperations( + new Logger(), + new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + fileSystemOperations, + new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion ); - // Both files are left intact — no displacement happens. - assertSetContainsExactly(fs.names, "source.md", "dest.md"); + await fileOperations.create("b.md", new Uint8Array()); + await fileOperations.create("c.md", new Uint8Array()); + await fileOperations.move("c.md", "b.md"); + assertSetContainsExactly( + fileSystemOperations.names, + "b.md", + "b (1).md" + ); + + await fileOperations.create("d.md", new Uint8Array()); + await fileOperations.move("d.md", "b.md"); + assertSetContainsExactly( + fileSystemOperations.names, + "b.md", + "b (1).md", + "b (2).md" + ); + + await fileOperations.create("file-23.md", new Uint8Array()); + await fileOperations.create("file-23 (1).md", new Uint8Array()); + await fileOperations.move("file-23.md", "file-23 (1).md"); + assertSetContainsExactly( + fileSystemOperations.names, + "b.md", + "b (1).md", + "b (2).md", + "file-23 (1).md", + "file-23 (2).md" + ); }); - it("create works for nested paths (parent-directory creation)", async () => { - const { fs, ops } = makeOps(); + it("should deconflict renames with paths", async () => { + const fileSystemOperations = new FakeFileSystemOperations(); + const fileOperations = new FileOperations( + new Logger(), + new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + fileSystemOperations, + new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + ); - await ops.create("a/b.c/d", new Uint8Array()); - assertSetContainsExactly(fs.names, "a/b.c/d"); + await fileOperations.create("a/b.c/d", new Uint8Array()); + await fileOperations.create("a/b.c/e", new Uint8Array()); + await fileOperations.move("a/b.c/d", "a/b.c/e"); + assertSetContainsExactly( + fileSystemOperations.names, + "a/b.c/e", + "a/b.c/e (1)" + ); }); - it("move works for nested target paths (parent-directory creation)", async () => { - const { fs, ops } = makeOps(); + it("should continue deconfliction from existing number in filename", async () => { + const fileSystemOperations = new FakeFileSystemOperations(); + const fileOperations = new FileOperations( + new Logger(), + new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + fileSystemOperations, + new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + ); - await ops.create("source", new Uint8Array()); - await ops.move("source", "a/b.c/dest"); + await fileOperations.create("document (5).md", new Uint8Array()); + await fileOperations.create("other.md", new Uint8Array()); - assertSetContainsExactly(fs.names, "a/b.c/dest"); + await fileOperations.move("other.md", "document (5).md"); + assertSetContainsExactly( + fileSystemOperations.names, + "document (5).md", + "document (6).md" + ); + + await fileOperations.create("another.md", new Uint8Array()); + await fileOperations.move("another.md", "document (5).md"); + assertSetContainsExactly( + fileSystemOperations.names, + "document (5).md", + "document (6).md", + "document (7).md" + ); + }); + + it("should handle dotfiles correctly", async () => { + const fileSystemOperations = new FakeFileSystemOperations(); + const fileOperations = new FileOperations( + new Logger(), + new MockDatabase() as Database, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + fileSystemOperations, + new MockServerConfig() as ServerConfig // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + ); + + await fileOperations.create(".gitignore", new Uint8Array()); + await fileOperations.create("temp", new Uint8Array()); + await fileOperations.move("temp", ".gitignore"); + assertSetContainsExactly( + fileSystemOperations.names, + ".gitignore", + ".gitignore (1)" + ); + + await fileOperations.create(".config.json", new Uint8Array()); + await fileOperations.create("temp2", new Uint8Array()); + await fileOperations.move("temp2", ".config.json"); + assertSetContainsExactly( + fileSystemOperations.names, + ".gitignore", + ".gitignore (1)", + ".config.json", + ".config (1).json" + ); }); }); diff --git a/frontend/sync-client/src/file-operations/file-operations.ts b/frontend/sync-client/src/file-operations/file-operations.ts index 17a2c655..2864bd20 100644 --- a/frontend/sync-client/src/file-operations/file-operations.ts +++ b/frontend/sync-client/src/file-operations/file-operations.ts @@ -1,40 +1,28 @@ import type { Logger } from "../tracing/logger"; import type { FileSystemOperations } from "./filesystem-operations"; -import type { RelativePath } from "../sync-operations/types"; +import type { Database, RelativePath } from "../persistence/database"; import { SafeFileSystemOperations } from "./safe-filesystem-operations"; import type { TextWithCursors } from "reconcile-text"; import { reconcile } from "reconcile-text"; import { isFileTypeMergable } from "../utils/is-file-type-mergable"; import { isBinary } from "../utils/is-binary"; import type { ServerConfig } from "../services/server-config"; -import { FileNotFoundError } from "../errors/file-not-found-error"; -import { FileAlreadyExistsError } from "../errors/file-already-exists-error"; -import type { ExpectedFsEvents } from "../sync-operations/expected-fs-events"; - -/** - * Outcome of a `move`/`create`. `actualPath` is where the file ended up; - * with the conflict-path machinery removed it is always equal to the - * requested path. The shape is preserved so callers don't all need to - * change. - */ -export interface FileOpResult { - actualPath: RelativePath; -} export class FileOperations { + private static readonly PARENTHESES_REGEX = / \((?\d+)\)$/; private readonly fs: SafeFileSystemOperations; public constructor( private readonly logger: Logger, + private readonly database: Database, fs: FileSystemOperations, private readonly serverConfig: ServerConfig, - private readonly expectedFsEvents: ExpectedFsEvents, private readonly nativeLineEndings = "\n" ) { this.fs = new SafeFileSystemOperations(fs, logger); } - private static getParentDirAndFileName( + private static getParentDirAndFile( path: RelativePath ): [RelativePath, RelativePath] { const pathParts = path.split("/"); @@ -57,42 +45,43 @@ export class FileOperations { } /** - * Create a file at the specified path. - * - * Throws `FileAlreadyExistsError` if a file already lives at `path`. - * Parent directories are created if necessary. The reconciler is the - * only caller that places files now and pre-checks for conflicts; - * the throw guards against a TOCTOU race rather than being a normal - * code path. - */ + * Create a file at the specified path. + * + * If a file with the same name already exists, it is moved before creating the new one. + * Parent directories are created if necessary. + */ public async create( path: RelativePath, newContent: Uint8Array - ): Promise { - if (await this.fs.exists(path)) { - throw new FileAlreadyExistsError( - `Refusing to create '${path}': file already exists`, - path - ); - } - await this.createParentDirectories(path); + ): Promise { + await this.ensureClearPath(path); + return this.fs.write(path, this.toNativeLineEndings(newContent)); + } - this.expectedFsEvents.expectCreate(path); - try { - await this.fs.write(path, this.toNativeLineEndings(newContent)); - } catch (e) { - this.expectedFsEvents.unexpectCreate(path); - throw e; + public async ensureClearPath(path: RelativePath): Promise { + if (await this.fs.exists(path)) { + const deconflictedPath = await this.deconflictPath(path); + try { + this.logger.debug( + `Didn't expect ${path} to exist, deconflicting by moving it to '${deconflictedPath}'` + ); + + this.database.move(path, deconflictedPath); + await this.fs.rename(path, deconflictedPath, true); + } finally { + this.fs.unlock(deconflictedPath); + } + } else { + await this.createParentDirectories(path); } - return { actualPath: path }; } /** - * Update the file at the given path. - * - * Performs a 3-way merge before writing if the file's content differs from `expectedContent`. - * Does not recreate the file if it no longer exists, returning an empty array instead. - */ + * Update the file at the given path. + * + * Performs a 3-way merge before writing if the file's content differs from `expectedContent`. + * Does not recreate the file if it no longer exists, returning an empty array instead. + */ public async write( path: RelativePath, expectedContent: Uint8Array, @@ -105,96 +94,58 @@ export class FileOperations { return; } - // Single-source the expectation registration: register exactly once - // per call, and unexpect from the catch if the underlying fs op - // throws (FileNotFoundError or otherwise). The previous shape - // registered inside each branch and let the catch swallow - // FileNotFoundError, leaking the expectation into the map. - this.expectedFsEvents.expectUpdate(path); - try { - if ( - !isFileTypeMergable( - path, - (await this.serverConfig.getConfig()) - .mergeableFileExtensions - ) || - isBinary(expectedContent) || - isBinary(newContent) - ) { - this.logger.debug( - `The expected content is not mergable, so we won't perform a 3-way merge, just overwrite it` - ); - await this.fs.write( - path, - // `newContent` might not be binary so we still have to ensure the line endings are correct - this.toNativeLineEndings(newContent) - ); - return; - } - - let expectedText = ""; - let newText = ""; - try { - expectedText = new TextDecoder("utf-8", { fatal: true }).decode( - expectedContent - ); // this comes from a previous read which must only have \n line endings - newText = new TextDecoder("utf-8", { fatal: true }).decode( - newContent - ); // this comes from the server which stores text with \n line endings - } catch (decodeError) { - this.logger.warn( - `3-way merge aborted for ${path}: one of expected/new is not valid UTF-8 (${decodeError}); falling back to overwrite` - ); - await this.fs.write(path, this.toNativeLineEndings(newContent)); - return; - } - - await this.fs.atomicUpdateText( + if ( + !isFileTypeMergable( path, - ({ text, cursors }: TextWithCursors): TextWithCursors => { - this.logger.debug( - `Performing a 3-way merge for ${path} with the expected content` - ); - - text = text.replaceAll(this.nativeLineEndings, "\n"); - const merged = reconcile( - expectedText, - { text, cursors }, - newText - ); - - const resultText = merged.text.replaceAll( - "\n", - this.nativeLineEndings - ); - - return { - text: resultText, - cursors: merged.cursors - }; - } + (await this.serverConfig.getConfig()).mergeableFileExtensions + ) || + isBinary(expectedContent) || + isBinary(newContent) + ) { + this.logger.debug( + `The expected content is not mergable, so we won't perform a 3-way merge, just overwrite it` ); - } catch (e) { - this.expectedFsEvents.unexpectUpdate(path); - if (e instanceof FileNotFoundError) { - this.logger.debug( - `File ${path} disappeared during write; not recreating` - ); - return; - } - throw e; + await this.fs.write( + path, + // `newContent` might not be binary so we still have to ensure the line endings are correct + this.toNativeLineEndings(newContent) + ); + return; } + + const expectedText = new TextDecoder().decode(expectedContent); // this comes from a previous read which must only have \n line endings + const newText = new TextDecoder().decode(newContent); // this comes from the server which stores text with \n line endings + + await this.fs.atomicUpdateText( + path, + ({ text, cursors }: TextWithCursors): TextWithCursors => { + this.logger.debug( + `Performing a 3-way merge for ${path} with the expected content` + ); + + text = text.replaceAll(this.nativeLineEndings, "\n"); + const merged = reconcile( + expectedText, + { text, cursors }, + newText + ); + + const resultText = merged.text.replaceAll( + "\n", + this.nativeLineEndings + ); + + return { + text: resultText, + cursors: merged.cursors + }; + } + ); } public async delete(path: RelativePath): Promise { if (await this.exists(path)) { - this.expectedFsEvents.expectDelete(path); - try { - await this.fs.delete(path); - } catch (e) { - this.expectedFsEvents.unexpectDelete(path); - throw e; - } + await this.fs.delete(path); await this.deletingEmptyParentDirectoriesOfDeletedFile(path); } else { this.logger.debug(`No need to delete '${path}', it doesn't exist`); @@ -209,39 +160,23 @@ export class FileOperations { return this.fs.exists(path); } - /** - * Move the file at `oldPath` to `newPath`. - * - * Throws `FileAlreadyExistsError` if a file already lives at `newPath` - * (and `oldPath !== newPath`). The reconciler is the only caller that - * relocates tracked records and pre-checks for conflicts; the throw - * guards against a TOCTOU race. - */ public async move( oldPath: RelativePath, newPath: RelativePath - ): Promise { + ): Promise { if (oldPath === newPath) { - return { actualPath: oldPath }; + return; } - if (await this.fs.exists(newPath)) { - throw new FileAlreadyExistsError( - `Refusing to move '${oldPath}' onto '${newPath}': target already exists`, - newPath - ); - } - await this.createParentDirectories(newPath); + await this.ensureClearPath(newPath); - this.expectedFsEvents.expectRename(oldPath, newPath); - try { - await this.fs.rename(oldPath, newPath); - } catch (e) { - this.expectedFsEvents.unexpectRename(oldPath, newPath); - throw e; - } + this.database.move(oldPath, newPath); + await this.fs.rename(oldPath, newPath); await this.deletingEmptyParentDirectoriesOfDeletedFile(oldPath); - return { actualPath: newPath }; + } + + public reset(): void { + this.fs.reset(); } private async deletingEmptyParentDirectoriesOfDeletedFile( @@ -250,7 +185,7 @@ export class FileOperations { let directory = path; // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition while (true) { - [directory] = FileOperations.getParentDirAndFileName(directory); + [directory] = FileOperations.getParentDirAndFile(directory); if (directory.length === 0) { break; } @@ -302,4 +237,55 @@ export class FileOperations { } } } + + /** + * Deconflicts the given path by appending (1), (2), etc. before the file extension until a non-existent path is found. + * The returned path has a lock acquired on it; it must be released by the caller when no longer needed. + * + * @param path The starting path to deconflict + * @returns a non-existent path with a lock acquired on it + */ + private async deconflictPath(path: RelativePath): Promise { + // eslint-disable-next-line prefer-const + let [directory, fileName] = FileOperations.getParentDirAndFile(path); + + if (directory) { + directory += "/"; + } + + const nameParts = fileName.split("."); + // Handle dotfiles: ".gitignore" should have no extension, ".config.json" should have ".json" + const isDotfile = fileName.startsWith(".") && nameParts[0] === ""; + const extension = + nameParts.length > 1 && !(isDotfile && nameParts.length === 2) + ? "." + nameParts[nameParts.length - 1] + : ""; + let stem = extension ? nameParts.slice(0, -1).join(".") : fileName; + let currentCount = Number.parseInt( + FileOperations.PARENTHESES_REGEX.exec(stem)?.groups?.count ?? "0" + ); + stem = stem.replace(FileOperations.PARENTHESES_REGEX, ""); + + let newName = path; + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + while (true) { + currentCount++; + newName = `${directory}${stem} (${currentCount})${extension}`; + + // Avoid multiple deconflictPath calls returning the same path + if (this.fs.tryLock(newName)) { + const newDocument = + this.database.getLatestDocumentByRelativePath(newName); + if ( + newDocument?.isDeleted === false || // the document might have been confirmed by the server at a new path but haven't yet moved there locally + (await this.fs.exists(newName, true)) + ) { + this.fs.unlock(newName); + } else { + return newName; + } + } + } + } } diff --git a/frontend/sync-client/src/file-operations/filesystem-operations.ts b/frontend/sync-client/src/file-operations/filesystem-operations.ts index a5fb006b..36dddfe6 100644 --- a/frontend/sync-client/src/file-operations/filesystem-operations.ts +++ b/frontend/sync-client/src/file-operations/filesystem-operations.ts @@ -1,4 +1,4 @@ -import type { RelativePath } from "../sync-operations/types"; +import type { RelativePath } from "../persistence/database"; import type { TextWithCursors } from "reconcile-text"; diff --git a/frontend/sync-client/src/file-operations/safe-filesystem-operations.ts b/frontend/sync-client/src/file-operations/safe-filesystem-operations.ts index 89b5008c..904bf805 100644 --- a/frontend/sync-client/src/file-operations/safe-filesystem-operations.ts +++ b/frontend/sync-client/src/file-operations/safe-filesystem-operations.ts @@ -1,18 +1,24 @@ -import type { RelativePath } from "../sync-operations/types"; +import type { RelativePath } from "../persistence/database"; import type { FileSystemOperations } from "./filesystem-operations"; import type { Logger } from "../tracing/logger"; -import { FileNotFoundError } from "../errors/file-not-found-error"; +import { Locks } from "../utils/data-structures/locks"; +import { FileNotFoundError } from "./file-not-found-error"; import type { TextWithCursors } from "reconcile-text"; /** * Decorates `FileSystemOperations` to replace errors with `FileNotFoundError` - * if the accessed file doesn't exist. + * if the accessed file doesn't exist. It also ensures that there's at most a + * single request in-flight for any one file through the use of locks. */ export class SafeFileSystemOperations implements FileSystemOperations { + private readonly locks: Locks; + public constructor( private readonly fs: FileSystemOperations, private readonly logger: Logger - ) {} + ) { + this.locks = new Locks(logger); + } public async listFilesRecursively( root: RelativePath | undefined @@ -25,12 +31,19 @@ export class SafeFileSystemOperations implements FileSystemOperations { public async read(path: RelativePath): Promise { this.logger.debug(`Reading file '${path}'`); - return this.safeOperation(path, async () => this.fs.read(path), "read"); + return this.safeOperation( + path, + async () => + this.locks.withLock(path, async () => this.fs.read(path)), + "read" + ); } public async write(path: RelativePath, content: Uint8Array): Promise { this.logger.debug(`Writing to file '${path}'`); - return this.fs.write(path, content); + return this.locks.withLock(path, async () => + this.fs.write(path, content) + ); } public async atomicUpdateText( @@ -40,7 +53,10 @@ export class SafeFileSystemOperations implements FileSystemOperations { this.logger.debug(`Atomically updating file '${path}'`); return this.safeOperation( path, - async () => this.fs.atomicUpdateText(path, updater), + async () => + this.locks.withLock(path, async () => + this.fs.atomicUpdateText(path, updater) + ), "atomicUpdateText" ); } @@ -49,43 +65,80 @@ export class SafeFileSystemOperations implements FileSystemOperations { // Logging this would be too noisy return this.safeOperation( path, - async () => this.fs.getFileSize(path), + async () => + this.locks.withLock(path, async () => + this.fs.getFileSize(path) + ), "getFileSize" ); } - public async exists(path: RelativePath): Promise { + public async exists( + path: RelativePath, + skipLock = false + ): Promise { this.logger.debug(`Checking if file '${path}' exists`); - return this.fs.exists(path); + if (skipLock) { + return this.fs.exists(path); + } else { + return this.locks.withLock(path, async () => this.fs.exists(path)); + } } public async createDirectory(path: RelativePath): Promise { this.logger.debug(`Creating directory '${path}'`); - return this.fs.createDirectory(path); + return this.locks.withLock(path, async () => + this.fs.createDirectory(path) + ); } public async delete(path: RelativePath): Promise { this.logger.debug(`Deleting file '${path}'`); - return this.fs.delete(path); + return this.locks.withLock(path, async () => this.fs.delete(path)); } public async rename( oldPath: RelativePath, - newPath: RelativePath + newPath: RelativePath, + skipLock = false ): Promise { this.logger.debug(`Renaming file '${oldPath}' to '${newPath}'`); return this.safeOperation( oldPath, - async () => this.fs.rename(oldPath, newPath), + async () => { + if (skipLock) { + return this.fs.rename(oldPath, newPath); + } else { + return this.locks.withLock([oldPath, newPath], async () => + this.fs.rename(oldPath, newPath) + ); + } + }, "rename" ); } + public tryLock(path: RelativePath): boolean { + return this.locks.tryLock(path); + } + + public async waitForLock(path: RelativePath): Promise { + return this.locks.waitForLock(path); + } + + public unlock(path: RelativePath): void { + this.locks.unlock(path); + } + + public reset(): void { + this.locks.reset(); + } + /** - * Decorate an operation to ensure that the file exists before running it. - * If the operation fails, it will check if the file still exists and throw - * a FileNotFoundError if it doesn't. - */ + * Decorate an operation to ensure that the file exists before running it. + * If the operation fails, it will check if the file still exists and throw + * a FileNotFoundError if it doesn't. + */ private async safeOperation( path: RelativePath, operation: () => Promise, @@ -101,6 +154,9 @@ export class SafeFileSystemOperations implements FileSystemOperations { try { return await operation(); } catch (error) { + // Without locking the file, this isn't atomic, however, it's good enough in practice. + // This will only break if the file exists, gets deleted and then immediately + // recreated while `operation` is running. if (await this.fs.exists(path)) { throw error; } else { diff --git a/frontend/sync-client/src/index.ts b/frontend/sync-client/src/index.ts index f06523a6..cfcc5071 100644 --- a/frontend/sync-client/src/index.ts +++ b/frontend/sync-client/src/index.ts @@ -2,7 +2,6 @@ import { awaitAll } from "./utils/await-all"; import { logToConsole } from "./utils/debugging/log-to-console"; import { slowFetchFactory } from "./utils/debugging/slow-fetch-factory"; import { slowWebSocketFactory } from "./utils/debugging/slow-web-socket-factory"; -import { InMemoryFileSystem } from "./utils/debugging/in-memory-file-system"; import { getRandomColor } from "./utils/get-random-color"; import { lineAndColumnToPosition } from "./utils/line-and-column-to-position"; import { positionToLineAndColumn } from "./utils/position-to-line-and-column"; @@ -22,19 +21,14 @@ export { export { Logger, LogLevel, LogLine } from "./tracing/logger"; export { type SyncSettings, DEFAULT_SETTINGS } from "./persistence/settings"; export { rateLimit } from "./utils/rate-limit"; -export type { - RelativePath, - StoredSyncState as StoredDatabase, - DocumentRecord -} from "./sync-operations/types"; +export type { RelativePath, StoredDatabase } from "./persistence/database"; export type { FileSystemOperations } from "./file-operations/filesystem-operations"; export type { PersistenceProvider } from "./persistence/persistence"; export type { CursorSpan } from "./services/types/CursorSpan"; export type { ClientCursors } from "./services/types/ClientCursors"; export type { NetworkConnectionStatus } from "./types/network-connection-status"; -export type { ServerVersionMismatchError } from "./errors/server-version-mismatch-error"; -export type { AuthenticationError } from "./errors/authentication-error"; -export { SyncResetError } from "./errors/sync-reset-error"; +export type { ServerVersionMismatchError } from "./services/server-version-mismatch-error"; +export type { AuthenticationError } from "./services/authentication-error"; export type { MaybeOutdatedClientCursors } from "./types/maybe-outdated-client-cursors"; export { DocumentSyncStatus } from "./types/document-sync-status"; export { SyncClient } from "./sync-client"; @@ -43,8 +37,7 @@ export type { TextWithCursors, CursorPosition } from "reconcile-text"; export const debugging = { slowFetchFactory, slowWebSocketFactory, - logToConsole, - InMemoryFileSystem + logToConsole }; export const utils = { diff --git a/frontend/sync-client/src/persistence/database.ts b/frontend/sync-client/src/persistence/database.ts new file mode 100644 index 00000000..86b2845c --- /dev/null +++ b/frontend/sync-client/src/persistence/database.ts @@ -0,0 +1,374 @@ +import type { Logger } from "../tracing/logger"; +import { EMPTY_HASH } from "../utils/hash"; +import { CoveredValues } from "../utils/data-structures/min-covered"; +import { awaitAll } from "../utils/await-all"; +import { removeFromArray } from "../utils/remove-from-array"; + +export type VaultUpdateId = number; +export type DocumentId = string; +export type RelativePath = string; + +export interface DocumentMetadata { + parentVersionId: VaultUpdateId; + hash: string; + remoteRelativePath?: RelativePath; +} + +export interface StoredDocumentMetadata { + relativePath: RelativePath; + documentId: DocumentId; + parentVersionId: VaultUpdateId; + remoteRelativePath?: RelativePath; + hash: string; +} + +export interface StoredDatabase { + documents: StoredDocumentMetadata[]; + lastSeenUpdateId: VaultUpdateId | undefined; + hasInitialSyncCompleted: boolean; +} + +/** + * Represents a document in the database. + * + * It is mutable and its content should always represent the latest + * state of the document on disk based on the update events we have seen. + */ +export interface DocumentRecord { + relativePath: RelativePath; + documentId: DocumentId; + metadata: DocumentMetadata | undefined; + isDeleted: boolean; + updates: Promise[]; + parallelVersion: number; +} + +export class Database { + private documents: DocumentRecord[]; + private lastSeenUpdateIds: CoveredValues; + private hasInitialSyncCompleted: boolean; + + public constructor( + private readonly logger: Logger, + initialState: Partial | undefined, + private readonly saveData: (data: StoredDatabase) => Promise + ) { + initialState ??= {}; + + this.documents = + initialState.documents?.map( + ({ relativePath, documentId, ...metadata }) => ({ + relativePath, + documentId, + metadata, + isDeleted: false, + updates: [], + parallelVersion: 0 + }) + ) ?? []; + + this.ensureConsistency(); + this.logger.debug(`Loaded ${this.documents.length} documents`); + + const { lastSeenUpdateId } = initialState; + this.logger.debug(`Loaded last seen update id: ${lastSeenUpdateId}`); + this.lastSeenUpdateIds = new CoveredValues( + Math.max(0, lastSeenUpdateId ?? 0) // the first updateId will be 1 which is the first integer after -1 + ); + + this.documents.forEach((doc) => { + this.lastSeenUpdateIds.add(doc.metadata?.parentVersionId); + }); + + this.hasInitialSyncCompleted = + initialState.hasInitialSyncCompleted ?? false; + this.logger.debug( + `Loaded hasInitialSyncCompleted: ${this.hasInitialSyncCompleted}` + ); + } + + public get length(): number { + return this.documents.length; + } + + public get resolvedDocuments(): DocumentRecord[] { + const paths = new Map(); + this.documents + // eslint-disable-next-line no-restricted-syntax -- Type narrowing, not removing a specific item + .filter(({ metadata }) => metadata !== undefined) + .forEach((record) => + paths.set(record.relativePath, [ + record, + ...(paths.get(record.relativePath) ?? []) + ]) + ); + + return Array.from(paths.values()).map((records) => { + records.sort( + (a, b) => b.parallelVersion - a.parallelVersion // descending + ); + + if ( + records.length > 1 && + records.some((current, i) => + i === 0 + ? false + : records[i - 1].parallelVersion === + current.parallelVersion + ) + ) { + throw new Error( + `Multiple documents with the same parallel version and path at ${records[0].relativePath}` + ); + } + return records[0]; + }); + } + + public updateDocumentMetadata( + metadata: { + parentVersionId: VaultUpdateId; + hash: string; + remoteRelativePath: RelativePath; + }, + toUpdate: DocumentRecord + ): void { + if (!this.documents.includes(toUpdate)) { + throw new Error("Document not found in database"); + } + + toUpdate.metadata = metadata; + + this.saveInTheBackground(); + } + + public removeDocumentPromise(promise: Promise): void { + const entry = this.documents.find(({ updates }) => + updates.includes(promise) + ); + + if (entry === undefined) { + // This method should be idempotent and tolerant of + // stragglers calling it after the databse has been reset. + return; + } + + removeFromArray(entry.updates, promise); + // No need to save as Promises don't get serialized + } + + public removeDocument(find: DocumentRecord): void { + removeFromArray(this.documents, find); + this.saveInTheBackground(); + } + + public getLatestDocumentByRelativePath( + find: RelativePath + ): DocumentRecord | undefined { + const candidates = this.documents.filter( + ({ relativePath }) => relativePath === find + ); + candidates.sort((a, b) => b.parallelVersion - a.parallelVersion); // descending + return candidates[0]; + } + + public async getResolvedDocumentByRelativePath( + relativePath: RelativePath, + promise: Promise + ): Promise { + const entry = this.getLatestDocumentByRelativePath(relativePath); + + if (entry === undefined) { + throw new Error( + `Document not found by relative path: ${relativePath}, ${JSON.stringify( + this.documents, + null, + 2 + )}` + ); + } + + const currentPromises = entry.updates; + entry.updates = [...currentPromises, promise]; + await awaitAll(currentPromises); + + return entry; + } + + public createNewPendingDocument( + documentId: DocumentId, + relativePath: RelativePath, + promise: Promise + ): DocumentRecord { + this.logger.debug( + `Creating new pending document: ${relativePath} (${documentId})` + ); + const previousEntry = + this.getLatestDocumentByRelativePath(relativePath); + + const entry = { + relativePath, + documentId, + metadata: undefined, + isDeleted: false, + updates: [promise], + parallelVersion: + previousEntry?.parallelVersion === undefined + ? 0 + : previousEntry.parallelVersion + 1 + }; + + this.documents.push(entry); + this.saveInTheBackground(); + + return entry; + } + + public createNewEmptyDocument( + documentId: DocumentId, + parentVersionId: VaultUpdateId, + relativePath: RelativePath + ): DocumentRecord { + const entry = { + relativePath, + documentId, + metadata: { + parentVersionId, + hash: EMPTY_HASH, + remoteRelativePath: relativePath + }, + isDeleted: false, + updates: [], + parallelVersion: 0 + }; + + this.documents.push(entry); + this.saveInTheBackground(); + + return entry; + } + + public getDocumentByDocumentId( + find: DocumentId + ): DocumentRecord | undefined { + return this.documents.find(({ documentId }) => documentId === find); + } + + public move( + oldRelativePath: RelativePath, + newRelativePath: RelativePath + ): void { + const oldDocument = + this.getLatestDocumentByRelativePath(oldRelativePath); + + if (oldDocument === undefined) { + return; + } + + const newDocument = + this.getLatestDocumentByRelativePath(newRelativePath); + if (newDocument?.isDeleted === false) { + throw new Error( + `Document already exists at new location: ${newRelativePath}` + ); + } + + oldDocument.relativePath = newRelativePath; + // We're in a strange state where the target of the move has just got deleted, + // however, its metadata might already have a bunch of updates queued up for + // the document at the new location. We need to keep these updates. + oldDocument.parallelVersion = + newDocument !== undefined ? newDocument.parallelVersion + 1 : 0; + + this.saveInTheBackground(); + } + + public delete(relativePath: RelativePath): void { + const candidate = this.getLatestDocumentByRelativePath(relativePath); + if (candidate === undefined) { + throw new Error( + `Document not found by relative path: ${relativePath}` + ); + } + candidate.isDeleted = true; + } + + public getHasInitialSyncCompleted(): boolean { + return this.hasInitialSyncCompleted; + } + + public setHasInitialSyncCompleted(value: boolean): void { + this.hasInitialSyncCompleted = value; + this.saveInTheBackground(); + } + + public getLastSeenUpdateId(): VaultUpdateId { + return this.lastSeenUpdateIds.min; + } + + public addSeenUpdateId(value: number): void { + const previousMin = this.lastSeenUpdateIds.min; + this.lastSeenUpdateIds.add(value); + if (previousMin !== this.lastSeenUpdateIds.min) { + this.saveInTheBackground(); + } + } + + public setLastSeenUpdateId(value: number): void { + this.lastSeenUpdateIds.min = value; + this.saveInTheBackground(); + } + + public reset(): void { + this.documents = []; + this.lastSeenUpdateIds = new CoveredValues( + 0 // the first updateId will be 1 which is the first integer after -1 + ); + this.hasInitialSyncCompleted = false; + this.saveInTheBackground(); + } + + public async save(): Promise { + return this.saveData({ + documents: this.resolvedDocuments.map( + ({ relativePath, documentId, metadata }) => ({ + documentId, + relativePath, + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + ...metadata! // `resolvedDocuments` only returns docs with metadata set + }) + ), + lastSeenUpdateId: this.lastSeenUpdateIds.min, + hasInitialSyncCompleted: this.hasInitialSyncCompleted + }); + } + + private ensureConsistency(): void { + const idToPath = new Map(); + + this.resolvedDocuments.forEach(({ relativePath, documentId }) => { + idToPath.set(documentId, [ + ...(idToPath.get(documentId) ?? []), + relativePath + ]); + }); + + const duplicates = Array.from(idToPath.entries()) + .filter(([_, paths]) => paths.length > 1) + .map(([id, paths]) => `${id} (${paths.join(", ")})`); + + if (duplicates.length > 0) { + throw new Error( + "Document IDs are not unique, found duplicates: " + + duplicates.join("; ") + ); + } + } + + private saveInTheBackground(): void { + this.ensureConsistency(); + void this.save().catch((error: unknown) => { + this.logger.error(`Error saving data: ${error}`); + }); + } +} diff --git a/frontend/sync-client/src/persistence/settings.ts b/frontend/sync-client/src/persistence/settings.ts index c954134f..d78170e6 100644 --- a/frontend/sync-client/src/persistence/settings.ts +++ b/frontend/sync-client/src/persistence/settings.ts @@ -6,6 +6,7 @@ export interface SyncSettings { remoteUri: string; token: string; vaultName: string; + syncConcurrency: number; isSyncEnabled: boolean; maxFileSizeMB: number; ignorePatterns: string[]; @@ -13,19 +14,22 @@ export interface SyncSettings { diffCacheSizeMB: number; enableTelemetry: boolean; networkRetryIntervalMs: number; + minimumSaveIntervalMs: number; } export const DEFAULT_SETTINGS: SyncSettings = { remoteUri: "", token: "", vaultName: "default", + syncConcurrency: 1, isSyncEnabled: false, maxFileSizeMB: 10, ignorePatterns: [], webSocketRetryIntervalMs: 3500, diffCacheSizeMB: 4, enableTelemetry: false, - networkRetryIntervalMs: 1000 + networkRetryIntervalMs: 1000, + minimumSaveIntervalMs: 1000 }; export class Settings { @@ -34,7 +38,7 @@ export class Settings { >(); private settings: SyncSettings; - private readonly lock: Lock; + private readonly lock: Lock = new Lock(); public constructor( private readonly logger: Logger, @@ -46,8 +50,6 @@ export class Settings { ...(initialState ?? {}) }; - this.lock = new Lock(Settings.name, this.logger); - this.logger.debug( `Loaded settings: ${JSON.stringify(this.settings, null, 2)}` ); diff --git a/frontend/sync-client/src/errors/authentication-error.ts b/frontend/sync-client/src/services/authentication-error.ts similarity index 100% rename from frontend/sync-client/src/errors/authentication-error.ts rename to frontend/sync-client/src/services/authentication-error.ts diff --git a/frontend/sync-client/src/services/build-vault-url.ts b/frontend/sync-client/src/services/build-vault-url.ts deleted file mode 100644 index 1f5002d7..00000000 --- a/frontend/sync-client/src/services/build-vault-url.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { Settings } from "../persistence/settings"; - -export function buildVaultUrl(settings: Settings, path: string): string { - const { vaultName, remoteUri } = settings.getSettings(); - const remoteUriWithoutTrailingSlash = remoteUri.replace(/\/+$/, ""); - const encodedVaultName = encodeURIComponent(vaultName.trim()); - return `${remoteUriWithoutTrailingSlash}/vaults/${encodedVaultName}${path}`; -} diff --git a/frontend/sync-client/src/services/fetch-controller.test.ts b/frontend/sync-client/src/services/fetch-controller.test.ts index a1b791a6..94fa8424 100644 --- a/frontend/sync-client/src/services/fetch-controller.test.ts +++ b/frontend/sync-client/src/services/fetch-controller.test.ts @@ -3,7 +3,7 @@ import { describe, it, mock, beforeEach, afterEach } from "node:test"; import assert from "node:assert"; import { FetchController } from "./fetch-controller"; import { Logger } from "../tracing/logger"; -import { SyncResetError } from "../errors/sync-reset-error"; +import { SyncResetError } from "./sync-reset-error"; import { sleep } from "../utils/sleep"; describe("FetchController", () => { diff --git a/frontend/sync-client/src/services/fetch-controller.ts b/frontend/sync-client/src/services/fetch-controller.ts index f5bb8664..77b87e3a 100644 --- a/frontend/sync-client/src/services/fetch-controller.ts +++ b/frontend/sync-client/src/services/fetch-controller.ts @@ -1,5 +1,6 @@ import type { Logger } from "../tracing/logger"; -import { SyncResetError } from "../errors/sync-reset-error"; +import { createPromise } from "../utils/create-promise"; +import { SyncResetError } from "./sync-reset-error"; /** * Offers a resettable fetch implementation that waits until syncing is enabled @@ -12,43 +13,37 @@ export class FetchController { // Promise resolves on the next state change: sync enabled/disabled or reset started/ended private until: Promise; - private resolveUntil: (value: symbol | PromiseLike) => void; - private rejectUntil: (reason?: unknown) => void; + private resolveUntil: (result: symbol) => unknown; + private rejectUntil: (reason: unknown) => unknown; public constructor( private _canFetch: boolean, private readonly logger: Logger ) { - ({ - promise: this.until, - resolve: this.resolveUntil, - reject: this.rejectUntil - } = Promise.withResolvers()); + [this.until, this.resolveUntil, this.rejectUntil] = + createPromise(); } /** - * Whether the fetch implementation can immediately send requests once outside of a reset. - */ + * Whether the fetch implementation can immediately send requests once outside of a reset. + */ public get canFetch(): boolean { return this._canFetch; } /** - * Allow or disallow fetching. The changes only take effect if not resetting. - * When called during a reset, its effect is deferred until the reset is finished. - * - * @param canFetch Whether fetching is enabled - */ + * Allow or disallow fetching. The changes only take effect if not resetting. + * When called during a reset, its effect is deferred until the reset is finished. + * + * @param canFetch Whether fetching is enabled + */ public set canFetch(canFetch: boolean) { this._canFetch = canFetch; if (!this.isResetting) { const previousResolve = this.resolveUntil; - ({ - promise: this.until, - resolve: this.resolveUntil, - reject: this.rejectUntil - } = Promise.withResolvers()); + [this.until, this.resolveUntil, this.rejectUntil] = + createPromise(); previousResolve(FetchController.UNTIL_RESOLUTION); } } @@ -64,9 +59,9 @@ export class FetchController { } /** - * Starts a reset, causing all ongoing and future fetches to be rejected - * with a SyncResetError until finishReset is called. - */ + * Starts a reset, causing all ongoing and future fetches to be rejected + * with a SyncResetError until finishReset is called. + */ public startReset(): void { this.isResetting = true; this.rejectUntil(new SyncResetError()); @@ -77,36 +72,32 @@ export class FetchController { } /** - * Finishes a reset, allowing fetches to proceed or wait again depending on - * the current sync settings. - */ + * Finishes a reset, allowing fetches to proceed or wait again depending on + * the current sync settings. + */ public finishReset(): void { if (!this.isResetting) { return; } this.isResetting = false; - ({ - promise: this.until, - resolve: this.resolveUntil, - reject: this.rejectUntil - } = Promise.withResolvers()); + [this.until, this.resolveUntil, this.rejectUntil] = createPromise(); } /** - * - * |------------------|---------------|-----------------------------------------------------| - * | | Sync enabled | Sync disabled | - * |------------------|-------------- |-----------------------------------------------------| - * | During reset | Rejects with SyncResetError without sending request | - * |------------------|-------------- |-----------------------------------------------------| - * | Outside of reset | Same as fetch | Blocks until sync is enabled and then same as fetch | - * |------------------|---------------|-----------------------------------------------------| - * - * @param logger for errors - * @param fetch to wrap - * @returns a wrapped fetch implementation affected by the FetchController state - */ + * + * |------------------|---------------|-----------------------------------------------------| + * | | Sync enabled | Sync disabled | + * |------------------|-------------- |-----------------------------------------------------| + * | During reset | Rejects with SyncResetError without sending request | + * |------------------|-------------- |-----------------------------------------------------| + * | Outside of reset | Same as fetch | Blocks until sync is enabled and then same as fetch | + * |------------------|---------------|-----------------------------------------------------| + * + * @param logger for errors + * @param fetch to wrap + * @returns a wrapped fetch implementation affected by the FetchController state + */ public getControlledFetchImplementation( logger: Logger, fetch: typeof globalThis.fetch = globalThis.fetch diff --git a/frontend/sync-client/src/services/server-config.ts b/frontend/sync-client/src/services/server-config.ts index 187e1bff..309c637c 100644 --- a/frontend/sync-client/src/services/server-config.ts +++ b/frontend/sync-client/src/services/server-config.ts @@ -1,7 +1,6 @@ import { SUPPORTED_API_VERSION } from "../consts"; -import { AuthenticationError } from "../errors/authentication-error"; -import { ServerVersionMismatchError } from "../errors/server-version-mismatch-error"; -import type { Settings } from "../persistence/settings"; +import { AuthenticationError } from "./authentication-error"; +import { ServerVersionMismatchError } from "./server-version-mismatch-error"; import type { SyncService } from "./sync-service"; import type { PingResponse } from "./types/PingResponse"; @@ -15,20 +14,7 @@ export class ServerConfig { private response: Promise | undefined; private config: ServerConfigData | undefined; - public constructor( - private readonly syncService: SyncService, - settings: Settings - ) { - settings.onSettingsChanged.add((newSettings, oldSettings) => { - if ( - newSettings.token !== oldSettings.token || - newSettings.vaultName !== oldSettings.vaultName || - newSettings.remoteUri !== oldSettings.remoteUri - ) { - this.reset(); - } - }); - } + public constructor(private readonly syncService: SyncService) {} private static validateConfig(config: ServerConfigData): void { if (config.supportedApiVersion !== SUPPORTED_API_VERSION) { @@ -48,6 +34,11 @@ export class ServerConfig { } } + // warm the cache + public async initialize(): Promise { + await this.getConfig(); + } + public async checkConnection(forceUpdate = false): Promise<{ isSuccessful: boolean; message: string; @@ -55,7 +46,7 @@ export class ServerConfig { try { let { response } = this; if (!response || forceUpdate) { - response = this.startPing(); + response = this.response = this.syncService.ping(); } const result: PingResponse = await response; // it must be defined, otherwise we would have thrown above @@ -82,7 +73,7 @@ export class ServerConfig { public async getConfig(): Promise { if (!this.config) { - this.response ??= this.startPing(); + this.response ??= this.syncService.ping(); this.config = await this.response; } @@ -95,15 +86,4 @@ export class ServerConfig { this.response = undefined; this.config = undefined; } - - private async startPing(): Promise { - const pending = this.syncService.ping().catch((e: unknown) => { - if (this.response === pending) { - this.response = undefined; - } - throw e; - }); - this.response = pending; - return pending; - } } diff --git a/frontend/sync-client/src/errors/server-version-mismatch-error.ts b/frontend/sync-client/src/services/server-version-mismatch-error.ts similarity index 100% rename from frontend/sync-client/src/errors/server-version-mismatch-error.ts rename to frontend/sync-client/src/services/server-version-mismatch-error.ts diff --git a/frontend/sync-client/src/errors/sync-reset-error.ts b/frontend/sync-client/src/services/sync-reset-error.ts similarity index 100% rename from frontend/sync-client/src/errors/sync-reset-error.ts rename to frontend/sync-client/src/services/sync-reset-error.ts diff --git a/frontend/sync-client/src/services/sync-service.ts b/frontend/sync-client/src/services/sync-service.ts index 0a99fe84..8190a638 100644 --- a/frontend/sync-client/src/services/sync-service.ts +++ b/frontend/sync-client/src/services/sync-service.ts @@ -2,27 +2,25 @@ import type { DocumentId, RelativePath, VaultUpdateId -} from "../sync-operations/types"; +} from "../persistence/database"; import type { Logger } from "../tracing/logger"; import type { Settings } from "../persistence/settings"; import type { FetchController } from "./fetch-controller"; import { sleep } from "../utils/sleep"; -import { SyncResetError } from "../errors/sync-reset-error"; -import { HttpClientError } from "../errors/http-client-error"; +import { SyncResetError } from "./sync-reset-error"; import type { SerializedError } from "./types/SerializedError"; import type { DocumentVersionWithoutContent } from "./types/DocumentVersionWithoutContent"; import type { DocumentUpdateResponse } from "./types/DocumentUpdateResponse"; import type { DocumentVersion } from "./types/DocumentVersion"; import type { FetchLatestDocumentsResponse } from "./types/FetchLatestDocumentsResponse"; import type { PingResponse } from "./types/PingResponse"; +import type { DeleteDocumentVersion } from "./types/DeleteDocumentVersion"; import type { UpdateTextDocumentVersion } from "./types/UpdateTextDocumentVersion"; -import { buildVaultUrl } from "./build-vault-url"; export class SyncService { private readonly client: typeof globalThis.fetch; private readonly pingClient: typeof globalThis.fetch; - private isStopped = false; public constructor( private readonly deviceId: string, @@ -67,68 +65,28 @@ export class SyncService { return result; } - private static async throwIfNotOk( - response: Response, - operation: string - ): Promise { - if (response.ok) { - return; - } - const message = `Failed to ${operation}: ${await SyncService.errorFromResponse(response)}`; - // 429 is the only 4xx the server uses for *transient* contention - // (`WriteBusyError` → HTTP 429). Every other 4xx means the request - // is permanently rejected and shouldn't be retried. - if (response.status === 429) { - throw new Error(message); - } - if (response.status >= 400 && response.status < 500) { - throw new HttpClientError(response.status, message); - } - throw new Error(message); - } - - /** - * Signal that the service is shutting down so any in-flight - * `retryForever` exits at its next iteration instead of looping - * indefinitely after the rest of the client has stopped. Idempotent. - */ - public stop(): void { - this.isStopped = true; - } - - /** - * Re-enable the service after a `stop()`. Used when the client pauses - * and resumes syncing within the same lifecycle (e.g. user toggles - * sync off and on). - */ - public resume(): void { - this.isStopped = false; - } - public async create({ + documentId, relativePath, - lastSeenVaultUpdateId, contentBytes }: { + documentId?: DocumentId; relativePath: RelativePath; - lastSeenVaultUpdateId: VaultUpdateId; contentBytes: Uint8Array; - }): Promise { + }): Promise { return this.retryForever(async () => { const formData = new FormData(); - + if (documentId !== undefined) { + formData.append("document_id", documentId); + } formData.append("relative_path", relativePath); - formData.append( - "last_seen_vault_update_id", - lastSeenVaultUpdateId.toString() - ); formData.append( "content", new Blob([new Uint8Array(contentBytes)]) ); this.logger.debug( - `Creating document with relative path ${relativePath}` + `Creating document with id ${documentId} and relative path ${relativePath}` ); const response = await this.client(this.getUrl("/documents"), { @@ -137,10 +95,16 @@ export class SyncService { headers: this.getDefaultHeaders() }); - await SyncService.throwIfNotOk(response, "create document"); + if (!response.ok) { + throw new Error( + `Failed to create document: ${await SyncService.errorFromResponse( + response + )}` + ); + } - const result: DocumentUpdateResponse = - (await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + const result: DocumentVersionWithoutContent = + (await response.json()) as DocumentVersionWithoutContent; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion this.logger.debug(`Created document ${JSON.stringify(result)}`); @@ -156,17 +120,17 @@ export class SyncService { }: { parentVersionId: VaultUpdateId; documentId: DocumentId; - relativePath: RelativePath | undefined; + relativePath: RelativePath; content: (number | string)[]; }): Promise { return this.retryForever(async () => { this.logger.debug( - `Updating text document ${documentId} with parent version ${parentVersionId} and relative path ${relativePath ?? ""}, content [${content.join(", ")}]` + `Updating text document ${documentId} with parent version ${parentVersionId} and relative path ${relativePath}, content [${content.join(", ")}]` ); const request: UpdateTextDocumentVersion = { parentVersionId, - relativePath: relativePath ?? null, + relativePath, content }; @@ -179,7 +143,13 @@ export class SyncService { } ); - await SyncService.throwIfNotOk(response, "update document"); + if (!response.ok) { + throw new Error( + `Failed to update document: ${await SyncService.errorFromResponse( + response + )}` + ); + } const result: DocumentUpdateResponse = (await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion @@ -202,18 +172,16 @@ export class SyncService { }: { parentVersionId: VaultUpdateId; documentId: DocumentId; - relativePath: RelativePath | undefined; + relativePath: RelativePath; contentBytes: Uint8Array; }): Promise { return this.retryForever(async () => { this.logger.debug( - `Updating binary document ${documentId} with parent version ${parentVersionId} and relative path ${relativePath ?? ""}` + `Updating binary document ${documentId} with parent version ${parentVersionId} and relative path ${relativePath}` ); const formData = new FormData(); formData.append("parent_version_id", parentVersionId.toString()); - if (relativePath !== undefined) { - formData.append("relative_path", relativePath); - } + formData.append("relative_path", relativePath); formData.append( "content", new Blob([new Uint8Array(contentBytes)]) @@ -228,7 +196,13 @@ export class SyncService { } ); - await SyncService.throwIfNotOk(response, "update document"); + if (!response.ok) { + throw new Error( + `Failed to update document: ${await SyncService.errorFromResponse( + response + )}` + ); + } const result: DocumentUpdateResponse = (await response.json()) as DocumentUpdateResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion @@ -244,29 +218,44 @@ export class SyncService { } public async delete({ - documentId + documentId, + relativePath }: { documentId: DocumentId; + relativePath: RelativePath; }): Promise { return this.retryForever(async () => { - this.logger.debug(`Delete document with id ${documentId}`); + const request: DeleteDocumentVersion = { + relativePath + }; + + this.logger.debug( + `Delete document with id ${documentId} and relative path ${relativePath}` + ); - // The server identifies the document by its URL path; no body - // is needed. Sending one was a leftover of an earlier shape. const response = await this.client( this.getUrl(`/documents/${documentId}`), { method: "DELETE", - headers: this.getDefaultHeaders() + body: JSON.stringify(request), + headers: this.getDefaultHeaders({ type: "json" }) } ); - await SyncService.throwIfNotOk(response, "delete document"); + if (!response.ok) { + throw new Error( + `Failed to delete document: ${await SyncService.errorFromResponse( + response + )}` + ); + } const result: DocumentVersionWithoutContent = (await response.json()) as DocumentVersionWithoutContent; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion - this.logger.debug(`Deleted document with id ${documentId}`); + this.logger.debug( + `Deleted document ${relativePath} with id ${documentId}` + ); return result; }); @@ -287,7 +276,13 @@ export class SyncService { } ); - await SyncService.throwIfNotOk(response, "get document"); + if (!response.ok) { + throw new Error( + `Failed to get document: ${await SyncService.errorFromResponse( + response + )}` + ); + } const result: DocumentVersion = (await response.json()) as DocumentVersion; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion @@ -319,10 +314,13 @@ export class SyncService { } ); - await SyncService.throwIfNotOk( - response, - "get document version content" - ); + if (!response.ok) { + throw new Error( + `Failed to get document: ${await SyncService.errorFromResponse( + response + )}` + ); + } const result = await response.bytes(); this.logger.debug( @@ -343,13 +341,19 @@ export class SyncService { const url = new URL(this.getUrl("/documents")); if (since !== undefined) { - url.searchParams.append("since_update_id", since.toString()); + url.searchParams.append("since", since.toString()); } const response = await this.client(url.toString(), { headers: this.getDefaultHeaders() }); - await SyncService.throwIfNotOk(response, "get documents"); + if (!response.ok) { + throw new Error( + `Failed to get documents: ${await SyncService.errorFromResponse( + response + )}` + ); + } const result: FetchLatestDocumentsResponse = (await response.json()) as FetchLatestDocumentsResponse; // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion @@ -386,7 +390,10 @@ export class SyncService { } private getUrl(path: string): string { - return buildVaultUrl(this.settings, path); + const { vaultName, remoteUri } = this.settings.getSettings(); + const remoteUriWithoutTrailingSlash = remoteUri.replace(/\/+$/, ""); + const encodedVaultName = encodeURIComponent(vaultName.trim()); + return `${remoteUriWithoutTrailingSlash}/vaults/${encodedVaultName}${path}`; } private getDefaultHeaders( @@ -407,17 +414,13 @@ export class SyncService { private async retryForever(fn: () => Promise): Promise { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition while (true) { - this.throwIfStopped(); try { return await fn(); } catch (e) { - if ( - e instanceof SyncResetError || - e instanceof HttpClientError - ) { + // We must not retry errors coming from reset + if (e instanceof SyncResetError) { throw e; } - this.throwIfStopped(); const retryInterval = this.settings.getSettings().networkRetryIntervalMs; @@ -428,10 +431,4 @@ export class SyncService { } } } - - private throwIfStopped(): void { - if (this.isStopped) { - throw new SyncResetError(); - } - } } diff --git a/frontend/sync-client/src/services/types/CreateDocumentVersion.ts b/frontend/sync-client/src/services/types/CreateDocumentVersion.ts index 2d83cd99..ed921f18 100644 --- a/frontend/sync-client/src/services/types/CreateDocumentVersion.ts +++ b/frontend/sync-client/src/services/types/CreateDocumentVersion.ts @@ -1,7 +1,13 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. export interface CreateDocumentVersion { + /** + * The client can decide the document id (if it wishes to) in order + * to help with syncing. If the client does not provide a document id, + * the server will generate one. If the client provides a document id + * it must not already exist in the database. + */ + document_id: string | null; relative_path: string; - last_seen_vault_update_id: number; content: number[]; } diff --git a/frontend/history-ui/src/lib/types/CursorSpan.ts b/frontend/sync-client/src/services/types/DeleteDocumentVersion.ts similarity index 61% rename from frontend/history-ui/src/lib/types/CursorSpan.ts rename to frontend/sync-client/src/services/types/DeleteDocumentVersion.ts index 916019ce..99ecc9e7 100644 --- a/frontend/history-ui/src/lib/types/CursorSpan.ts +++ b/frontend/sync-client/src/services/types/DeleteDocumentVersion.ts @@ -1,3 +1,5 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type CursorSpan = { start: number; end: number }; +export interface DeleteDocumentVersion { + relativePath: string; +} diff --git a/frontend/sync-client/src/services/types/DocumentUpdateResponse.ts b/frontend/sync-client/src/services/types/DocumentUpdateResponse.ts index dd7eadda..7fd06c7a 100644 --- a/frontend/sync-client/src/services/types/DocumentUpdateResponse.ts +++ b/frontend/sync-client/src/services/types/DocumentUpdateResponse.ts @@ -3,7 +3,7 @@ import type { DocumentVersion } from "./DocumentVersion"; import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent"; /** - * Response to a create/update document request. + * Response to an update document request. */ export type DocumentUpdateResponse = | ({ type: "FastForwardUpdate" } & DocumentVersionWithoutContent) diff --git a/frontend/sync-client/src/services/types/DocumentVersionWithoutContent.ts b/frontend/sync-client/src/services/types/DocumentVersionWithoutContent.ts index 662b41e5..4b24e7c5 100644 --- a/frontend/sync-client/src/services/types/DocumentVersionWithoutContent.ts +++ b/frontend/sync-client/src/services/types/DocumentVersionWithoutContent.ts @@ -9,8 +9,4 @@ export interface DocumentVersionWithoutContent { userId: string; deviceId: string; contentSize: number; - /** - * True iff this is the first version of the document - */ - isNewFile: boolean; } diff --git a/frontend/sync-client/src/services/types/DocumentWithCursors.ts b/frontend/sync-client/src/services/types/DocumentWithCursors.ts index 8ed59067..dcfe6e2d 100644 --- a/frontend/sync-client/src/services/types/DocumentWithCursors.ts +++ b/frontend/sync-client/src/services/types/DocumentWithCursors.ts @@ -2,8 +2,8 @@ import type { CursorSpan } from "./CursorSpan"; export interface DocumentWithCursors { - vaultUpdateId: number | null; - documentId: string; - relativePath: string; + vault_update_id: number | null; + document_id: string; + relative_path: string; cursors: CursorSpan[]; } diff --git a/frontend/sync-client/src/services/types/FetchLatestDocumentsResponse.ts b/frontend/sync-client/src/services/types/FetchLatestDocumentsResponse.ts index 315d701a..160c9279 100644 --- a/frontend/sync-client/src/services/types/FetchLatestDocumentsResponse.ts +++ b/frontend/sync-client/src/services/types/FetchLatestDocumentsResponse.ts @@ -7,7 +7,7 @@ import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutCont export interface FetchLatestDocumentsResponse { latestDocuments: DocumentVersionWithoutContent[]; /** - * The update ID of the latest document in the response. - */ + * The update ID of the latest document in the response. + */ lastUpdateId: bigint; } diff --git a/frontend/sync-client/src/services/types/ListVaultsResponse.ts b/frontend/sync-client/src/services/types/ListVaultsResponse.ts deleted file mode 100644 index babad2d5..00000000 --- a/frontend/sync-client/src/services/types/ListVaultsResponse.ts +++ /dev/null @@ -1,11 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { VaultInfo } from "./VaultInfo"; - -/** - * Response to listing vaults accessible to the authenticated user. - */ -export interface ListVaultsResponse { - vaults: VaultInfo[]; - hasMore: boolean; - userName: string; -} diff --git a/frontend/sync-client/src/services/types/PingResponse.ts b/frontend/sync-client/src/services/types/PingResponse.ts index f96520e9..6db66354 100644 --- a/frontend/sync-client/src/services/types/PingResponse.ts +++ b/frontend/sync-client/src/services/types/PingResponse.ts @@ -5,21 +5,21 @@ */ export interface PingResponse { /** - * Semantic version of the server. - */ + * Semantic version of the server. + */ serverVersion: string; /** - * Whether the client is authenticated based on the sent Authorization - * header. - */ + * Whether the client is authenticated based on the sent Authorization + * header. + */ isAuthenticated: boolean; /** - * List of file extensions that are allowed to be merged. - */ + * List of file extensions that are allowed to be merged. + */ mergeableFileExtensions: string[]; /** - * API version ensuring backwards & forwards compatibility between the client - * and server. - */ + * API version ensuring backwards & forwards compatibility between the client + * and server. + */ supportedApiVersion: number; } diff --git a/frontend/history-ui/src/lib/types/CreateDocumentVersion.ts b/frontend/sync-client/src/services/types/UpdateDocumentVersion.ts similarity index 55% rename from frontend/history-ui/src/lib/types/CreateDocumentVersion.ts rename to frontend/sync-client/src/services/types/UpdateDocumentVersion.ts index 389d8e88..4e57a297 100644 --- a/frontend/history-ui/src/lib/types/CreateDocumentVersion.ts +++ b/frontend/sync-client/src/services/types/UpdateDocumentVersion.ts @@ -1,7 +1,7 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export type CreateDocumentVersion = { +export interface UpdateDocumentVersion { + parent_version_id: bigint; relative_path: string; - last_seen_vault_update_id: number; - content: Array; -}; + content: number[]; +} diff --git a/frontend/sync-client/src/services/types/UpdateTextDocumentVersion.ts b/frontend/sync-client/src/services/types/UpdateTextDocumentVersion.ts index 988e3b2f..46f36bd0 100644 --- a/frontend/sync-client/src/services/types/UpdateTextDocumentVersion.ts +++ b/frontend/sync-client/src/services/types/UpdateTextDocumentVersion.ts @@ -2,6 +2,6 @@ export interface UpdateTextDocumentVersion { parentVersionId: number; - relativePath: string | null; + relativePath: string; content: (number | string)[]; } diff --git a/frontend/sync-client/src/services/types/VaultHistoryResponse.ts b/frontend/sync-client/src/services/types/VaultHistoryResponse.ts deleted file mode 100644 index 35531010..00000000 --- a/frontend/sync-client/src/services/types/VaultHistoryResponse.ts +++ /dev/null @@ -1,10 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent"; - -/** - * Response to a vault history request (paginated). - */ -export interface VaultHistoryResponse { - versions: DocumentVersionWithoutContent[]; - hasMore: boolean; -} diff --git a/frontend/sync-client/src/services/types/VaultInfo.ts b/frontend/sync-client/src/services/types/VaultInfo.ts deleted file mode 100644 index 20d6811c..00000000 --- a/frontend/sync-client/src/services/types/VaultInfo.ts +++ /dev/null @@ -1,10 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -/** - * Summary of a single vault returned by the list-vaults endpoint. - */ -export interface VaultInfo { - name: string; - documentCount: number; - createdAt: string | null; -} diff --git a/frontend/sync-client/src/services/types/WebSocketVaultUpdate.ts b/frontend/sync-client/src/services/types/WebSocketVaultUpdate.ts index b4a942c8..f1ea0f80 100644 --- a/frontend/sync-client/src/services/types/WebSocketVaultUpdate.ts +++ b/frontend/sync-client/src/services/types/WebSocketVaultUpdate.ts @@ -2,5 +2,6 @@ import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent"; export interface WebSocketVaultUpdate { - document: DocumentVersionWithoutContent; + documents: DocumentVersionWithoutContent[]; + isInitialSync: boolean; } diff --git a/frontend/sync-client/src/services/websocket-manager.test.ts b/frontend/sync-client/src/services/websocket-manager.test.ts index bde18ef3..fef901e7 100644 --- a/frontend/sync-client/src/services/websocket-manager.test.ts +++ b/frontend/sync-client/src/services/websocket-manager.test.ts @@ -4,7 +4,8 @@ import assert from "node:assert"; import { WebSocketManager } from "./websocket-manager"; import type { Logger } from "../tracing/logger"; import type { Settings } from "../persistence/settings"; -import { awaitAll } from "../utils/await-all"; +// eslint-disable-next-line @typescript-eslint/no-require-imports +const WebSocket = require("ws") as typeof globalThis.WebSocket; class MockCloseEvent extends Event { public code: number; @@ -90,8 +91,10 @@ function createMockFn unknown>( describe("WebSocketManager", () => { let mockLogger: Logger = undefined as unknown as Logger; let mockSettings: Settings = undefined as unknown as Settings; + let deviceId = "test-device-123"; beforeEach(() => { + deviceId = "test-device-123"; const noop = (): void => { // Intentionally empty for mock }; @@ -113,6 +116,7 @@ describe("WebSocketManager", () => { it("cleans up promises after message handling", async () => { const manager = new WebSocketManager( + deviceId, mockLogger, mockSettings, MockWebSocket as unknown as typeof WebSocket @@ -142,6 +146,7 @@ describe("WebSocketManager", () => { it("cleans up cursor position promises", async () => { const manager = new WebSocketManager( + deviceId, mockLogger, mockSettings, MockWebSocket as unknown as typeof WebSocket @@ -171,6 +176,7 @@ describe("WebSocketManager", () => { it("logs handshake send errors", async () => { const manager = new WebSocketManager( + deviceId, mockLogger, mockSettings, MockWebSocket as unknown as typeof WebSocket @@ -199,6 +205,7 @@ describe("WebSocketManager", () => { it("completes stop with timeout protection", async () => { const manager = new WebSocketManager( + deviceId, mockLogger, mockSettings, MockWebSocket as unknown as typeof WebSocket @@ -213,6 +220,7 @@ describe("WebSocketManager", () => { it("clears old handlers on reconnection", async () => { const manager = new WebSocketManager( + deviceId, mockLogger, mockSettings, MockWebSocket as unknown as typeof WebSocket @@ -247,68 +255,9 @@ describe("WebSocketManager", () => { await manager.stop(); }); - it("handles concurrent stop() calls without stranding either caller", async () => { - // Real WebSocket.close() doesn't fire onclose synchronously, and the - // socket stays reachable across the close handshake. Model that - // here so the manager's `while (isWebSocketConnected)` loop is - // actually awaiting when the second stop() races in. Static OPEN - // is required because the manager compares readyState against - // `factory.OPEN`. - class AsyncCloseWebSocket extends MockWebSocket { - public static readonly OPEN = WebSocket.OPEN; - - public override close(code?: number, reason?: string): void { - if ( - this.readyState === WebSocket.CLOSED || - (this as { _closing?: boolean })._closing === true - ) { - return; - } - (this as { _closing?: boolean })._closing = true; - setTimeout(() => { - this.readyState = WebSocket.CLOSED; - this.onclose?.( - new MockCloseEvent("close", { - code: code ?? 1000, - reason: reason ?? "" - }) - ); - }, 5); - } - } - - const manager = new WebSocketManager( - mockLogger, - mockSettings, - AsyncCloseWebSocket as unknown as typeof WebSocket - ); - - manager.start(); - await new Promise((resolve) => setTimeout(resolve, 50)); - - const start = Date.now(); - // Two concurrent stops mimic destroy() racing onSettingsChange. - await awaitAll([manager.stop(), manager.stop()]); - const elapsed = Date.now() - start; - - // Both should resolve via the normal close path; if the second call - // had clobbered the first's resolver, the first would have been - // stranded until the 10s disconnect timeout. - assert.ok( - elapsed < 1000, - `concurrent stop() took ${elapsed}ms — expected fast resolution` - ); - const errorCalls = (mockLogger.error as unknown as { calls: unknown[] }) - .calls; - assert.strictEqual( - errorCalls.length, - 0, - "no timeout-recovery error should be logged" - ); - }); - it("tracks message handling promises", async () => { const manager = new WebSocketManager( + deviceId, mockLogger, mockSettings, MockWebSocket as unknown as typeof WebSocket diff --git a/frontend/sync-client/src/services/websocket-manager.ts b/frontend/sync-client/src/services/websocket-manager.ts index 8a4fe34c..09787bce 100644 --- a/frontend/sync-client/src/services/websocket-manager.ts +++ b/frontend/sync-client/src/services/websocket-manager.ts @@ -4,15 +4,12 @@ import type { WebSocketServerMessage } from "./types/WebSocketServerMessage"; import type { WebSocketClientMessage } from "./types/WebSocketClientMessage"; import type { CursorPositionFromClient } from "./types/CursorPositionFromClient"; import type { ClientCursors } from "./types/ClientCursors"; +import { createPromise } from "../utils/create-promise"; import type { WebSocketVaultUpdate } from "./types/WebSocketVaultUpdate"; -import { - WEBSOCKET_DISCONNECT_TIMEOUT_IN_SECONDS, - WEBSOCKET_CONNECTION_TIMEOUT_IN_SECONDS -} from "../consts"; +import { WEBSOCKET_DISCONNECT_TIMEOUT_IN_S } from "../consts"; import { removeFromArray } from "../utils/remove-from-array"; import { EventListeners } from "../utils/data-structures/event-listeners"; import { awaitAll } from "../utils/await-all"; -import { buildVaultUrl } from "./build-vault-url"; export class WebSocketManager { public readonly onWebSocketStatusChanged = new EventListeners< @@ -29,22 +26,32 @@ export class WebSocketManager { private isStopped = true; private resolveDisconnectingPromise: null | (() => unknown) = null; - private stopPromise: Promise | null = null; private reconnectTimeoutId: ReturnType | undefined; - private connectionTimeoutId: ReturnType | undefined; private readonly outstandingPromises: Promise[] = []; private webSocket: WebSocket | undefined; + private readonly webSocketFactoryImplementation: typeof globalThis.WebSocket; public constructor( + private readonly deviceId: string, private readonly logger: Logger, private readonly settings: Settings, - private readonly webSocketFactoryImplementation: typeof globalThis.WebSocket = WebSocket - ) {} - - public get hasOutstandingWork(): boolean { - return this.outstandingPromises.length > 0; + webSocketImplementation?: typeof globalThis.WebSocket + ) { + if (webSocketImplementation) { + this.webSocketFactoryImplementation = webSocketImplementation; + } else { + if ( + typeof globalThis !== "undefined" && + typeof globalThis.WebSocket === "undefined" + ) { + // eslint-disable-next-line + this.webSocketFactoryImplementation = require("ws"); // polyfill for WebSocket in Node.js + } else { + this.webSocketFactoryImplementation = WebSocket; + } + } } public get isWebSocketConnected(): boolean { @@ -60,14 +67,49 @@ export class WebSocketManager { } public async stop(): Promise { - // Concurrent callers (e.g. destroy() and onSettingsChange) must share - // the same disconnect; otherwise the second call would overwrite - // resolveDisconnectingPromise and strand the first caller's await - // until the timeout rejects. - this.stopPromise ??= this.performStop().finally(() => { - this.stopPromise = null; + const [promise, resolve] = createPromise(); + this.resolveDisconnectingPromise = resolve; + + this.isStopped = true; + + if (this.reconnectTimeoutId !== undefined) { + clearTimeout(this.reconnectTimeoutId); + this.reconnectTimeoutId = undefined; + } + + this.webSocket?.close(1000, "WebSocketManager has been stopped"); + + // eslint-disable-next-line @typescript-eslint/init-declarations + let timeoutId: ReturnType | undefined; + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout(() => { + reject( + new Error( + `Timeout waiting for WebSocket to close after ${WEBSOCKET_DISCONNECT_TIMEOUT_IN_S} seconds` + ) + ); + }, WEBSOCKET_DISCONNECT_TIMEOUT_IN_S * 1000); }); - await this.stopPromise; + + try { + while (this.isWebSocketConnected) { + await Promise.race([promise, timeoutPromise]); + } + } catch (error) { + this.logger.error( + `Error while waiting for WebSocket to close: ${String(error)}` + ); + // Force cleanup even if close didn't work + this.resolveDisconnectingPromise(); + this.resolveDisconnectingPromise = null; + } finally { + // Clear timeout to prevent unhandled rejection + if (timeoutId !== undefined) { + clearTimeout(timeoutId); + } + } + + await this.waitUntilFinished(); } public async waitUntilFinished(): Promise { @@ -120,59 +162,6 @@ export class WebSocketManager { } } - private async performStop(): Promise { - const { promise, resolve } = Promise.withResolvers(); - this.resolveDisconnectingPromise = (): void => { - resolve(undefined); - }; - - this.isStopped = true; - - if (this.reconnectTimeoutId !== undefined) { - clearTimeout(this.reconnectTimeoutId); - this.reconnectTimeoutId = undefined; - } - - if (this.connectionTimeoutId !== undefined) { - clearTimeout(this.connectionTimeoutId); - this.connectionTimeoutId = undefined; - } - - this.webSocket?.close(1000, "WebSocketManager has been stopped"); - - // eslint-disable-next-line @typescript-eslint/init-declarations - let timeoutId: ReturnType | undefined; - const timeoutPromise = new Promise((_, reject) => { - timeoutId = setTimeout(() => { - reject( - new Error( - `Timeout waiting for WebSocket to close after ${WEBSOCKET_DISCONNECT_TIMEOUT_IN_SECONDS} seconds` - ) - ); - }, WEBSOCKET_DISCONNECT_TIMEOUT_IN_SECONDS * 1000); - }); - - try { - while (this.isWebSocketConnected) { - await Promise.race([promise, timeoutPromise]); - } - } catch (error) { - this.logger.error( - `Error while waiting for WebSocket to close: ${String(error)}` - ); - // Force cleanup even if close didn't work - this.resolveDisconnectingPromise(); - this.resolveDisconnectingPromise = null; - } finally { - // Clear timeout to prevent unhandled rejection - if (timeoutId !== undefined) { - clearTimeout(timeoutId); - } - } - - await this.waitUntilFinished(); - } - private initializeWebSocket(): void { // Clean up old WebSocket handlers to prevent race conditions if (this.webSocket) { @@ -182,55 +171,26 @@ export class WebSocketManager { this.webSocket.onclose = null; this.webSocket.onmessage = null; this.webSocket.onerror = null; - this.webSocket.close( - 1000, - "Closing previous WebSocket connection" - ); + this.webSocket.close(); } catch (e) { this.logger.error( `Failed to close previous WebSocket connection: ${e}` ); } - // Abandon any outstanding handler promises from the previous - // connection. They'll still resolve in the background, but we - // no longer want `waitUntilFinished` / `stop` to block on - // post-reconnect state — and we definitely don't want their - // results applied against a now-stale socket. - this.outstandingPromises.length = 0; } - // Build the WS URL through the same vault-URL helper the HTTP client - // uses so vault-name encoding, trailing-slash stripping, and any path - // prefix in `remoteUri` stay in sync between transports. - const wsUri = new URL(buildVaultUrl(this.settings, "/ws")); - wsUri.protocol = wsUri.protocol.startsWith("https") ? "wss" : "ws"; + const wsUri = new URL(this.settings.getSettings().remoteUri); + wsUri.protocol = wsUri.protocol === "https" ? "wss" : "ws"; + wsUri.pathname = `/vaults/${this.settings.getSettings().vaultName}/ws`; this.logger.info(`Connecting to WebSocket at ${wsUri.toString()}`); - const ws = new this.webSocketFactoryImplementation(wsUri); - this.webSocket = ws; - - // Set connection timeout to handle cases where server is down and the WebSocket connection won't open. - // The callback closes the *captured* `ws` rather than `this.webSocket` so a delayed timeout cannot - // accidentally close a freshly-constructed replacement socket. (Closing the already-closed `ws` is a no-op.) - this.connectionTimeoutId = setTimeout(() => { - this.connectionTimeoutId = undefined; - this.logger.warn( - `WebSocket connection timeout after ${WEBSOCKET_CONNECTION_TIMEOUT_IN_SECONDS} seconds` - ); - // Force close to trigger onclose handler which will schedule reconnection - ws.close(1000, "Connection timeout"); - }, WEBSOCKET_CONNECTION_TIMEOUT_IN_SECONDS * 1000); - - ws.onopen = (): void => { - if (this.connectionTimeoutId !== undefined) { - clearTimeout(this.connectionTimeoutId); - this.connectionTimeoutId = undefined; - } + this.webSocket = new this.webSocketFactoryImplementation(wsUri); + this.webSocket.onopen = (): void => { // Check if we've been stopped while connecting if (this.isStopped) { - ws.close( + this.webSocket?.close( 1000, "WebSocketManager was stopped during connection" ); @@ -240,7 +200,7 @@ export class WebSocketManager { this.onWebSocketStatusChanged.trigger(true); }; - ws.onmessage = (event): void => { + this.webSocket.onmessage = (event): void => { try { // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion const message = JSON.parse( @@ -271,18 +231,7 @@ export class WebSocketManager { } }; - ws.onerror = (error): void => { - this.logger.warn( - `WebSocket error occurred: ${error instanceof ErrorEvent ? error.message : "Unknown error"}` - ); - }; - - ws.onclose = (event): void => { - if (this.connectionTimeoutId !== undefined) { - clearTimeout(this.connectionTimeoutId); - this.connectionTimeoutId = undefined; - } - + this.webSocket.onclose = (event): void => { this.logger.warn( `WebSocket closed with code ${event.code} (${event.reason == "" ? "unknown reason" : event.reason})` ); @@ -292,13 +241,10 @@ export class WebSocketManager { this.resolveDisconnectingPromise?.(); this.resolveDisconnectingPromise = null; } else { - const delay = - this.settings.getSettings().webSocketRetryIntervalMs; - this.logger.info(`Reconnecting to WebSocket in ${delay}ms...`); this.reconnectTimeoutId = setTimeout(() => { this.reconnectTimeoutId = undefined; this.initializeWebSocket(); - }, delay); + }, this.settings.getSettings().webSocketRetryIntervalMs); } }; } @@ -306,22 +252,22 @@ export class WebSocketManager { private async handleWebSocketMessage( message: WebSocketServerMessage ): Promise { - switch (message.type) { - case "vaultUpdate": - await this.onRemoteVaultUpdateReceived.triggerAsync(message); - return; - case "cursorPositions": - this.logger.debug( - `Received cursor positions for ${JSON.stringify(message.clients)}` - ); - await this.onRemoteCursorsUpdateReceived.triggerAsync( - message.clients - ); - return; - default: - this.logger.warn( - `Received unknown message type: ${JSON.stringify(message)}` - ); + if (message.type === "vaultUpdate") { + await this.onRemoteVaultUpdateReceived.triggerAsync(message); + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + } else if (message.type === "cursorPositions") { + this.logger.debug( + `Received cursor positions for ${JSON.stringify(message.clients)}` + ); + + await this.onRemoteCursorsUpdateReceived.triggerAsync( + message.clients + ); + } else { + this.logger.warn( + `Received unknown message type: ${JSON.stringify(message)}` + ); } } } diff --git a/frontend/sync-client/src/sync-client.ts b/frontend/sync-client/src/sync-client.ts index dd537296..2a272c86 100644 --- a/frontend/sync-client/src/sync-client.ts +++ b/frontend/sync-client/src/sync-client.ts @@ -2,12 +2,8 @@ import type { PersistenceProvider } from "./persistence/persistence"; import type { HistoryEntry, HistoryStats } from "./tracing/sync-history"; import { SyncHistory } from "./tracing/sync-history"; import { Logger, LogLevel, LogLine } from "./tracing/logger"; -import type { - DocumentId, - RelativePath, - StoredSyncState -} from "./sync-operations/types"; -import { SyncEventQueue } from "./sync-operations/sync-event-queue"; +import type { RelativePath, StoredDatabase } from "./persistence/database"; +import { Database } from "./persistence/database"; import * as Sentry from "@sentry/browser"; import type { SyncSettings } from "./persistence/settings"; import { DEFAULT_SETTINGS, Settings } from "./persistence/settings"; @@ -16,6 +12,7 @@ import { Syncer } from "./sync-operations/syncer"; import type { FileSystemOperations } from "./file-operations/filesystem-operations"; import { FileOperations } from "./file-operations/file-operations"; import { FetchController } from "./services/fetch-controller"; +import { UnrestrictedSyncer } from "./sync-operations/unrestricted-syncer"; import { rateLimit } from "./utils/rate-limit"; import type { NetworkConnectionStatus } from "./types/network-connection-status"; import { DocumentSyncStatus } from "./types/document-sync-status"; @@ -27,46 +24,42 @@ import type { MaybeOutdatedClientCursors } from "./types/maybe-outdated-client-c import { FileChangeNotifier } from "./sync-operations/file-change-notifier"; import { FixedSizeDocumentCache } from "./utils/data-structures/fix-sized-cache"; import { setUpTelemetry } from "./utils/set-up-telemetry"; +import { DIFF_CACHE_SIZE_MB } from "./consts"; import { ServerConfig } from "./services/server-config"; import type { EventListeners } from "./utils/data-structures/event-listeners"; -import { Lock } from "./utils/data-structures/locks"; -import { ExpectedFsEvents } from "./sync-operations/expected-fs-events"; export class SyncClient { + private hasStartedOfflineSync = false; private hasFinishedOfflineSync = false; private hasStarted = false; private hasBeenDestroyed = false; private unloadTelemetry?: () => void; private isDestroying = false; private readonly eventUnsubscribers: (() => void)[] = []; - private readonly settingsChangeLock = new Lock( - "SyncClient.onSettingsChange" - ); private constructor( - public readonly logger: Logger, private readonly history: SyncHistory, private readonly settings: Settings, - private readonly syncEventQueue: SyncEventQueue, + private readonly database: Database, private readonly syncer: Syncer, private readonly webSocketManager: WebSocketManager, + public readonly logger: Logger, private readonly fetchController: FetchController, private readonly cursorTracker: CursorTracker, private readonly fileChangeNotifier: FileChangeNotifier, private readonly contentCache: FixedSizeDocumentCache, + private readonly fileOperations: FileOperations, private readonly serverConfig: ServerConfig, - private readonly syncService: SyncService, - private readonly expectedFsEvents: ExpectedFsEvents, private readonly persistence: PersistenceProvider< Partial<{ settings: Partial; - database: Partial; + database: Partial; }> > ) {} - public get syncedDocumentCount(): number { - return this.syncEventQueue.syncedDocumentCount; + public get documentCount(): number { + return this.database.length; } public get isWebSocketConnected(): boolean { @@ -80,27 +73,6 @@ export class SyncClient { return this.history.onHistoryUpdated; } - /** - * Fires whenever a tracked document's local file moves on disk — - * watcher-driven user renames, post-create deconflicts placed by - * the reconciler, lost-rename replays in offline scan, slot - * displacements when another record claims a path. Both - * `oldPath` and `newPath` may be `undefined` (placement-pending - * state). Useful for callers that mirror disk-side path state - * — e.g. test harnesses tracking which paths are safe to mutate - * — and need a signal beyond the user-facing history. - */ - public get onDocumentPathChanged(): EventListeners< - ( - documentId: DocumentId, - oldPath: RelativePath | undefined, - newPath: RelativePath | undefined - ) => unknown - > { - this.checkIfDestroyed("onDocumentPathChanged getter"); - return this.syncEventQueue.onDocumentPathChanged; - } - public get onSettingsChanged(): EventListeners< (newSettings: SyncSettings, oldSettings: SyncSettings) => unknown > { @@ -129,13 +101,6 @@ export class SyncClient { return this.cursorTracker.onRemoteCursorsUpdated; } - public get hasPendingWork(): boolean { - return ( - this.syncEventQueue.pendingUpdateCount > 0 || - this.webSocketManager.hasOutstandingWork - ); - } - public static async create({ fs, persistence, @@ -147,8 +112,7 @@ export class SyncClient { persistence: PersistenceProvider< Partial<{ settings: Partial; - database: Partial; - deviceId: string; + database: Partial; }> >; fetch?: typeof globalThis.fetch; @@ -157,46 +121,39 @@ export class SyncClient { }): Promise { const logger = new Logger(); + const deviceId = createClientId(); + + logger.info(`Creating SyncClient with client id ${deviceId}`); + const history = new SyncHistory(logger); let state = (await persistence.load()) ?? { settings: undefined, - database: undefined, - deviceId: undefined + database: undefined }; - // Persist deviceId across destroy + init so the server's - // lost-create dedup (which scopes by device_id) can recognise - // a retry as belonging to the same client. Without this, - // every fresh `SyncClient` after a destroy would generate a - // new deviceId, the server-side query would miss, and the - // pending-but-lost create would deconflict instead of - // binding to the doc its content was already absorbed into. - let deviceId = state.deviceId; - if (deviceId === undefined) { - deviceId = createClientId(); - state = { ...state, deviceId }; - await persistence.save(state); - } - - logger.info(`Creating SyncClient with client id ${deviceId}`); - const settings = new Settings( logger, state.settings, async (data): Promise => { state = { ...state, settings: data }; + // we're not rate-limiting settings saves as (1) we need to initialise the settings to know the rate limit + // and (2) settings changes are infrequent enough that rate-limiting is not necessary await persistence.save(state); } ); - const syncEventQueue = new SyncEventQueue( - settings, + const rateLimitedSave = rateLimit( + persistence.save, + () => settings.getSettings().minimumSaveIntervalMs + ); + + const database = new Database( logger, state.database, async (data): Promise => { state = { ...state, database: data }; - await persistence.save(state); + await rateLimitedSave(state); } ); @@ -213,23 +170,32 @@ export class SyncClient { fetch ); - const serverConfig = new ServerConfig(syncService, settings); - - const expectedFsEvents = new ExpectedFsEvents(); + const serverConfig = new ServerConfig(syncService); const fileOperations = new FileOperations( logger, + database, fs, serverConfig, - expectedFsEvents, nativeLineEndings ); const contentCache = new FixedSizeDocumentCache( - 1024 * 1024 * settings.getSettings().diffCacheSizeMB + 1024 * 1024 * DIFF_CACHE_SIZE_MB + ); + const unrestrictedSyncer = new UnrestrictedSyncer( + logger, + database, + settings, + syncService, + fileOperations, + history, + contentCache, + serverConfig ); const webSocketManager = new WebSocketManager( + deviceId, logger, settings, webSocket @@ -238,38 +204,34 @@ export class SyncClient { const syncer = new Syncer( deviceId, logger, + database, settings, + syncService, webSocketManager, fileOperations, - syncService, - history, - contentCache, - serverConfig, - syncEventQueue + unrestrictedSyncer ); const fileChangeNotifier = new FileChangeNotifier(); const cursorTracker = new CursorTracker( - logger, - syncEventQueue, + database, webSocketManager, fileOperations, fileChangeNotifier ); const client = new SyncClient( - logger, history, settings, - syncEventQueue, + database, syncer, webSocketManager, + logger, fetchController, cursorTracker, fileChangeNotifier, contentCache, + fileOperations, serverConfig, - syncService, - expectedFsEvents, persistence ); @@ -323,10 +285,10 @@ export class SyncClient { } /** - * Reload settings from disk overriding current in-memory settings. - * Missing values will be filled in from DEFAULT_SETTINGS rather than - * retaining current in-memory settings. - */ + * Reload settings from disk overriding current in-memory settings. + * Missing values will be filled in from DEFAULT_SETTINGS rather than + * retaining current in-memory settings. + */ public async reloadSettings(): Promise { this.checkIfDestroyed("reloadSettings"); @@ -358,10 +320,10 @@ export class SyncClient { } /** - * Wait for the in-flight operations to finish, reset all tracking, - * and the local state but retain the settings. - * The SyncClient can be used again after calling this method. - */ + * Wait for the in-flight operations to finish, reset all tracking, + * and the local database but retain the settings. + * The SyncClient can be used again after calling this method. + */ public async reset(): Promise { this.checkIfDestroyed("reset"); @@ -370,16 +332,16 @@ export class SyncClient { ); await this.pause(); + // clear all local state this.logger.info("Resetting SyncClient's local state"); - await this.syncEventQueue.clearAllState(); - await this.syncEventQueue.save(); + this.database.reset(); + await this.database.save(); // ensure the new database reads as empty this.resetInMemoryState(); + this.hasStartedOfflineSync = false; this.hasFinishedOfflineSync = false; this.serverConfig.reset(); - if (this.settings.getSettings().isSyncEnabled) { - await this.startSyncing(); - } + await this.startSyncing(); } public getSettings(): SyncSettings { @@ -401,48 +363,40 @@ export class SyncClient { await this.settings.setSettings(value); } - public syncLocallyCreatedFile(relativePath: RelativePath): void { + public async syncLocallyCreatedFile( + relativePath: RelativePath + ): Promise { this.checkIfDestroyed("syncLocallyCreatedFile"); - this.fileChangeNotifier.notifyOfFileChange(relativePath); // this is for updating cursors - if (this.expectedFsEvents.matchCreate(relativePath)) { - return; - } - - this.syncer.syncLocallyCreatedFile(relativePath); + this.fileChangeNotifier.notifyOfFileChange(relativePath); + return this.syncer.syncLocallyCreatedFile(relativePath); } - public syncLocallyUpdatedFile({ + public async syncLocallyDeletedFile( + relativePath: RelativePath + ): Promise { + this.checkIfDestroyed("syncLocallyDeletedFile"); + + this.fileChangeNotifier.notifyOfFileChange(relativePath); + return this.syncer.syncLocallyDeletedFile(relativePath); + } + + public async syncLocallyUpdatedFile({ oldPath, relativePath }: { oldPath?: RelativePath; relativePath: RelativePath; - }): void { + }): Promise { this.checkIfDestroyed("syncLocallyUpdatedFile"); - this.fileChangeNotifier.notifyOfFileChange(relativePath); // this is for updating cursors - if (this.expectedFsEvents.matchUpdate(relativePath, oldPath)) { - return; - } - - this.syncer.syncLocallyUpdatedFile({ + this.fileChangeNotifier.notifyOfFileChange(relativePath); + return this.syncer.syncLocallyUpdatedFile({ oldPath, relativePath }); } - public syncLocallyDeletedFile(relativePath: RelativePath): void { - this.checkIfDestroyed("syncLocallyDeletedFile"); - - this.fileChangeNotifier.notifyOfFileChange(relativePath); // this is for updating cursors - if (this.expectedFsEvents.matchDelete(relativePath)) { - return; - } - - this.syncer.syncLocallyDeletedFile(relativePath); - } - public getDocumentSyncingStatus( relativePath: RelativePath ): DocumentSyncStatus { @@ -452,11 +406,16 @@ export class SyncClient { return DocumentSyncStatus.SYNCING_IS_DISABLED; } - if (!this.hasFinishedOfflineSync) { + if (!this.syncer.isFirstSyncComplete || !this.hasFinishedOfflineSync) { return DocumentSyncStatus.SYNCING; } - return this.syncEventQueue.hasPendingEventsForPath(relativePath) + const document = + this.database.getLatestDocumentByRelativePath(relativePath); + if (document === undefined) { + return DocumentSyncStatus.SYNCING; + } + return document.updates.length > 0 ? DocumentSyncStatus.SYNCING : DocumentSyncStatus.UP_TO_DATE; } @@ -470,20 +429,20 @@ export class SyncClient { } public async waitUntilFinished(): Promise { - this.checkIfDestroyed("waitUntilFinished"); - await this.waitUntilFinishedInternal(); + this.checkIfDestroyed("waitUntilIdle"); + await this.syncer.waitUntilFinished(); + await this.webSocketManager.waitUntilFinished(); + await this.database.save(); // flush all changes to disk } /** - * Completely destroy the SyncClient, cancelling all in-progress operations. - * After calling this method, the SyncClient cannot be used again. - */ + * Completely destroy the SyncClient, cancelling all in-progress operations. + * After calling this method, the SyncClient cannot be used again. + */ public async destroy(): Promise { - if (this.hasBeenDestroyed) { - throw new Error( - "SyncClient has been destroyed and can no longer be used; called from destroy" - ); - } + this.checkIfDestroyed("destroy"); + + // Prevent concurrent destroy calls if (this.isDestroying) { this.logger.warn( "destroy() called while already destroying, ignoring" @@ -492,92 +451,52 @@ export class SyncClient { } this.isDestroying = true; - // Run cleanup in `finally` so a thrown pause() — or anything else - // mid-shutdown — still leaves the client in the disposed state - // instead of bricked with subscribers/telemetry hanging on. - try { - await this.pause(); - } finally { - this.hasBeenDestroyed = true; + // cancel everything that's in progress + await this.pause(); - this.resetInMemoryState(); + this.hasBeenDestroyed = true; - this.eventUnsubscribers.forEach((unsubscribe) => { - unsubscribe(); - }); - this.eventUnsubscribers.length = 0; + this.resetInMemoryState(); - this.logger.info("SyncClient has been successfully disposed"); + // Clean up event listeners to prevent memory leaks + this.eventUnsubscribers.forEach((unsubscribe) => { + unsubscribe(); + }); + this.eventUnsubscribers.length = 0; - this.unloadTelemetry?.(); - } - } + this.logger.info("SyncClient has been successfully disposed"); - /** - * The actual drain — separated from `waitUntilFinished` so internal - * shutdown paths (`pause` / `destroy`) can wait for in-flight work - * without tripping the public `checkIfDestroyed` guard, which exists - * only to keep external callers from continuing to use a disposed - * client. - * - * Loops because a WebSocket message handler completing is what enqueues - * a `RemoteChange` into the syncer; if we awaited the syncer first and - * the WS handler second, a message arriving mid-wait would leave a fresh - * drain pending while `save()` ran. Each iteration waits for both, then - * re-checks; we exit only once both report idle in the same pass. - */ - private async waitUntilFinishedInternal(): Promise { - while ( - this.webSocketManager.hasOutstandingWork || - this.syncer.hasPendingWork - ) { - await this.webSocketManager.waitUntilFinished(); - await this.syncer.waitUntilFinished(); - } - await this.syncEventQueue.save(); + this.unloadTelemetry?.(); } private async startSyncing(): Promise { this.checkIfDestroyed("startSyncing"); this.fetchController.finishReset(); - // Undo any earlier `pause()` stop so retryForever keeps retrying. - this.syncService.resume(); - await this.serverConfig.getConfig(); - - await this.syncer.scheduleSyncForOfflineChanges(); - this.syncer.resumeDraining(); + await this.serverConfig.initialize(); this.webSocketManager.start(); + if (!this.hasStartedOfflineSync) { + this.hasStartedOfflineSync = true; + await this.syncer.scheduleSyncForOfflineChanges(); + } + this.hasFinishedOfflineSync = true; } private async pause(): Promise { - this.hasFinishedOfflineSync = false; - this.syncer.pauseDraining(); this.fetchController.startReset(); - // Signal the service so any `retryForever` loop exits at its next - // iteration instead of continuing to retry a network request while - // the rest of the client is winding down. - this.syncService.stop(); await this.webSocketManager.stop(); - await this.waitUntilFinishedInternal(); - // Clear the offline-scan gate so a subsequent `startSyncing()` - // re-runs the scan; otherwise any local changes made while sync was - // paused (offline edits, deletes, renames) wouldn't be detected, and - // an incoming remote update would silently overwrite them. - this.syncer.clearOfflineScanGate(); - // Drop any expected fs events that were registered but never matched - // (e.g. an op aborted by SyncResetError). Otherwise a real user edit - // at the same path after re-enable would be swallowed. - this.expectedFsEvents.clear(); + await this.waitUntilFinished(); } private resetInMemoryState(): void { this.history.reset(); this.contentCache.reset(); + // don't reset the logger this.cursorTracker.reset(); this.syncer.reset(); + this.fileOperations.reset(); } private async onSettingsChange( @@ -586,55 +505,36 @@ export class SyncClient { ): Promise { this.checkIfDestroyed("onSettingsChange"); - // Serialize listener invocations so back-to-back settings updates - // can't run reset()/pause()/startSyncing() concurrently. - await this.settingsChangeLock.withLock(async () => { - // The lock is FIFO, so by the time we run the client may have - // been destroyed in a queued invocation ahead of us. - if (this.hasBeenDestroyed) { - return; - } + if ( + newSettings.vaultName !== oldSettings.vaultName || + newSettings.remoteUri !== oldSettings.remoteUri + ) { + await this.reset(); + } - const connectionChanged = - newSettings.vaultName !== oldSettings.vaultName || - newSettings.remoteUri !== oldSettings.remoteUri; - - if (connectionChanged) { - // reset() pauses, clears state, then starts iff isSyncEnabled - // — so any concurrent isSyncEnabled change is already applied. - await this.reset(); - } else if ( - newSettings.isSyncEnabled !== oldSettings.isSyncEnabled - ) { - if (newSettings.isSyncEnabled) { - await this.startSyncing(); - } else { - await this.pause(); - } + if (newSettings.isSyncEnabled !== oldSettings.isSyncEnabled) { + if (newSettings.isSyncEnabled) { + await this.startSyncing(); + } else { + await this.pause(); } + } - if (newSettings.diffCacheSizeMB !== oldSettings.diffCacheSizeMB) { - this.contentCache.resize( - newSettings.diffCacheSizeMB * 1024 * 1024 - ); - } + if (newSettings.diffCacheSizeMB !== oldSettings.diffCacheSizeMB) { + this.contentCache.resize(newSettings.diffCacheSizeMB * 1024 * 1024); + } - if (newSettings.enableTelemetry !== oldSettings.enableTelemetry) { - if (newSettings.enableTelemetry) { - this.unloadTelemetry = setUpTelemetry(); - } else { - this.unloadTelemetry?.(); - } + if (newSettings.enableTelemetry !== oldSettings.enableTelemetry) { + if (newSettings.enableTelemetry) { + this.unloadTelemetry = setUpTelemetry(); + } else { + this.unloadTelemetry?.(); } - }); + } } private checkIfDestroyed(origin: string): void { - // Reject new public-API entries the moment destroy() is called, - // not after `pause()` returns. Otherwise an external caller could - // pass the guard and start mutating state while destroy() is - // tearing down the websocket / clearing caches. - if (this.hasBeenDestroyed || this.isDestroying) { + if (this.hasBeenDestroyed) { throw new Error( `SyncClient has been destroyed and can no longer be used; called from ${origin}` ); diff --git a/frontend/sync-client/src/sync-operations/cursor-tracker.ts b/frontend/sync-client/src/sync-operations/cursor-tracker.ts index c31721b1..bdd7d9b7 100644 --- a/frontend/sync-client/src/sync-operations/cursor-tracker.ts +++ b/frontend/sync-client/src/sync-operations/cursor-tracker.ts @@ -1,6 +1,5 @@ import type { FileOperations } from "../file-operations/file-operations"; -import type { RelativePath } from "./types"; -import type { SyncEventQueue } from "./sync-event-queue"; +import type { Database, RelativePath } from "../persistence/database"; import type { ClientCursors } from "../services/types/ClientCursors"; import type { CursorSpan } from "../services/types/CursorSpan"; import type { DocumentWithCursors } from "../services/types/DocumentWithCursors"; @@ -11,7 +10,6 @@ import { hash } from "../utils/hash"; import type { FileChangeNotifier } from "./file-change-notifier"; import { Lock } from "../utils/data-structures/locks"; import { EventListeners } from "../utils/data-structures/event-listeners"; -import type { Logger } from "../tracing/logger"; // Cursor positions are updated separately from documents. However, a given cursor position is only // valid within a certain version of the document it belongs to. This class tracks previous and the latest @@ -24,29 +22,22 @@ export class CursorTracker { (cursors: MaybeOutdatedClientCursors[]) => unknown >(); - private readonly updateLock: Lock; + private readonly updateLock = new Lock(); private knownRemoteCursors: (ClientCursors & { upToDateness: DocumentUpToDateness; })[] = []; - // Cache the previously sent state as a JSON string rather than as the - // array. We mutate `documentsWithCursors` in-place after the cache check - // (setting `vaultUpdateId = null` for dirty docs); storing the array would - // alias and the next call's equality check would compare against - // post-mutation state. - private lastLocalCursorStateJson = "[]"; - private lastLocalCursorStateWithoutDirtyDocumentsJson = "[]"; + private lastLocalCursorState: DocumentWithCursors[] = []; + private lastLocalCursorStateWithoutDirtyDocuments: DocumentWithCursors[] = + []; public constructor( - logger: Logger, - private readonly queue: SyncEventQueue, + private readonly database: Database, private readonly webSocketManager: WebSocketManager, private readonly fileOperations: FileOperations, private readonly fileChangeNotifier: FileChangeNotifier ) { - this.updateLock = new Lock(CursorTracker.name, logger); - this.webSocketManager.onRemoteCursorsUpdateReceived.add( async (clientCursors) => { await this.updateLock.withLock(async () => { @@ -62,7 +53,7 @@ export class CursorTracker { for (const cursor of clientCursors.filter((client) => client.documentsWithCursors.every( - (doc) => doc.vaultUpdateId != null + (doc) => doc.vault_update_id != null ) )) { updatedKnownRemoteCursors.push({ @@ -86,20 +77,14 @@ export class CursorTracker { for (const clientCursor of this.knownRemoteCursors) { if ( clientCursor.documentsWithCursors.some( - (document) => document.relativePath === relativePath + (document) => + document.relative_path === relativePath ) ) { clientCursor.upToDateness = await this.getDocumentsUpToDateness(clientCursor); } } - // Drop the local-cursor send-cache so the next call re-reads - // the file. The first cache key is the editor's input, which - // doesn't change when the file content does — without this, - // a remote update flipping the file from dirty back to clean - // would never re-send the cursor with a fresh `vaultUpdateId`. - this.lastLocalCursorStateJson = ""; - this.lastLocalCursorStateWithoutDirtyDocumentsJson = ""; }) ); } @@ -110,67 +95,70 @@ export class CursorTracker { public async sendLocalCursorsToServer( documentToCursors: Record ): Promise { - // Serialise concurrent senders so they don't interleave on the - // disk reads + state mutations and emit out-of-order cursor messages. - await this.updateLock.withLock(async () => { - const documentsWithCursors: DocumentWithCursors[] = []; + const documentsWithCursors: DocumentWithCursors[] = []; - for (const [relativePath, cursors] of Object.entries( - documentToCursors - )) { - const record = this.queue.getRecordByLocalPath(relativePath); + for (const [relativePath, cursors] of Object.entries( + documentToCursors + )) { + const record = + this.database.getLatestDocumentByRelativePath(relativePath); - if (!record) { - continue; // Let's wait for the file to be created before sending cursors - } - - documentsWithCursors.push({ - relativePath: relativePath, - documentId: record.documentId, - vaultUpdateId: record.parentVersionId, - cursors: cursors.map(({ start, end }) => ({ - start: Math.min(start, end), - end: Math.max(start, end) - })) // the client might send directional selections - }); + if (!record) { + continue; // Let's wait for the file to be created before sending cursors } - const beforeJson = JSON.stringify(documentsWithCursors); - if (this.lastLocalCursorStateJson === beforeJson) { - // Caching step to avoid reading the edited files all the time - return; - } - this.lastLocalCursorStateJson = beforeJson; - - for (const doc of documentsWithCursors) { - const readContent = await this.fileOperations.read( - doc.relativePath - ); - const record = this.queue.getRecordByLocalPath( - doc.relativePath - ); - if (record?.remoteHash !== (await hash(readContent))) { - doc.vaultUpdateId = null; - } + if (!record.metadata) { + continue; // this is a new document, no need to sync the cursors } - const afterJson = JSON.stringify(documentsWithCursors); - if ( - this.lastLocalCursorStateWithoutDirtyDocumentsJson === afterJson - ) { - return; + documentsWithCursors.push({ + relative_path: relativePath, + document_id: record.documentId, + vault_update_id: record.metadata.parentVersionId, + cursors: cursors.map(({ start, end }) => ({ + start: Math.min(start, end), + end: Math.max(start, end) + })) // the client might send directional selections + }); + } + + if ( + JSON.stringify(this.lastLocalCursorState) === + JSON.stringify(documentsWithCursors) + ) { + // Caching step to avoid reading the edited files all the time + return; + } + this.lastLocalCursorState = documentsWithCursors; + + for (const doc of documentsWithCursors) { + const readContent = await this.fileOperations.read( + doc.relative_path + ); + const record = this.database.getLatestDocumentByRelativePath( + doc.relative_path + ); + if (record?.metadata?.hash !== hash(readContent)) { + doc.vault_update_id = null; } + } - this.lastLocalCursorStateWithoutDirtyDocumentsJson = afterJson; + if ( + JSON.stringify(this.lastLocalCursorStateWithoutDirtyDocuments) === + JSON.stringify(documentsWithCursors) + ) { + return; + } - this.webSocketManager.updateLocalCursors({ documentsWithCursors }); - }); + this.lastLocalCursorStateWithoutDirtyDocuments = documentsWithCursors; + + this.webSocketManager.updateLocalCursors({ documentsWithCursors }); } public reset(): void { this.knownRemoteCursors = []; - this.lastLocalCursorStateJson = "[]"; - this.lastLocalCursorStateWithoutDirtyDocumentsJson = "[]"; + this.lastLocalCursorState = []; + this.lastLocalCursorStateWithoutDirtyDocuments = []; this.updateLock.reset(); } @@ -235,28 +223,35 @@ export class CursorTracker { private async getDocumentUpToDateness( document: DocumentWithCursors ): Promise { - const record = this.queue.getRecordByLocalPath(document.relativePath); + const record = this.database.getLatestDocumentByRelativePath( + document.relative_path + ); if (!record) { // the document of the cursor must be from the future return DocumentUpToDateness.Later; } - if (record.parentVersionId < (document.vaultUpdateId ?? 0)) { + if ( + (record.metadata?.parentVersionId ?? 0) < + (document.vault_update_id ?? 0) + ) { return DocumentUpToDateness.Later; - } else if ((document.vaultUpdateId ?? 0) < record.parentVersionId) { + } else if ( + (document.vault_update_id ?? 0) < + (record.metadata?.parentVersionId ?? 0) + ) { // the document of the cursor must be from the past return DocumentUpToDateness.Prior; } const currentContent = await this.fileOperations.read( - document.relativePath + document.relative_path ); - const currentRecord = this.queue.getRecordByLocalPath( - document.relativePath - ); - return currentRecord?.remoteHash === (await hash(currentContent)) + return this.database.getLatestDocumentByRelativePath( + document.relative_path + )?.metadata?.hash === hash(currentContent) ? DocumentUpToDateness.UpToDate : DocumentUpToDateness.Prior; } diff --git a/frontend/sync-client/src/sync-operations/expected-fs-events.ts b/frontend/sync-client/src/sync-operations/expected-fs-events.ts deleted file mode 100644 index a2c4f52f..00000000 --- a/frontend/sync-client/src/sync-operations/expected-fs-events.ts +++ /dev/null @@ -1,138 +0,0 @@ -import type { RelativePath } from "./types"; - -/** - * Counter-based registry of filesystem events the syncer is about to - * cause. The syncer's own writes/renames/deletes go through - * `FileOperations`, which calls into the host filesystem; the host then - * fires watcher events that come back through `SyncClient.syncLocallyXxx`. - * Without filtering, those echo events would be re-uploaded to the server - * and broadcast back, producing an unbounded loop. - * - * The fix: every fs call in `FileOperations` registers the event it is - * about to provoke; the matching `syncLocallyXxx` handler consumes it. - * User-initiated edits never register, so they pass through unchanged. - * - * Counts are per (kind, path) so back-to-back syncer ops on the same path - * (e.g. apply remote update then re-apply during convergence) match - * one-for-one. If the watcher never fires for a registered op (e.g. the - * fs throws before notifying), the entry is left behind; `clear()` is - * called on pause/destroy to drop those before they collide with a real - * user event later. - */ -export class ExpectedFsEvents { - private readonly creates = new Map(); - private readonly updates = new Map(); - private readonly deletes = new Map(); - // Renames are keyed by `JSON.stringify({oldPath, newPath})` so the - // delimiter cannot occur inside either path. - private readonly renames = new Map(); - - private static renameKey( - oldPath: RelativePath, - newPath: RelativePath - ): string { - return JSON.stringify({ oldPath, newPath }); - } - - public expectCreate(path: RelativePath): void { - this.bump(this.creates, path); - } - - public expectUpdate(path: RelativePath): void { - this.bump(this.updates, path); - } - - public expectDelete(path: RelativePath): void { - this.bump(this.deletes, path); - } - - public expectRename(oldPath: RelativePath, newPath: RelativePath): void { - this.bump(this.renames, ExpectedFsEvents.renameKey(oldPath, newPath)); - } - - /** - * Cancel a previously-registered expectation when the fs op that registered - * it failed before any watcher event could fire. Without this, a leaked - * expectation silently swallows the next genuine user event at the same - * path (or, for renames, the same `oldPath → newPath` pair). - * - * Floored at zero: if the watcher *did* fire (op partially completed) and - * already consumed the entry, the unexpect is a no-op. The fallback is - * acceptable — at worst we re-upload a real edit we'd otherwise filter. - */ - public unexpectCreate(path: RelativePath): void { - this.decrement(this.creates, path); - } - - public unexpectUpdate(path: RelativePath): void { - this.decrement(this.updates, path); - } - - public unexpectDelete(path: RelativePath): void { - this.decrement(this.deletes, path); - } - - public unexpectRename(oldPath: RelativePath, newPath: RelativePath): void { - this.decrement( - this.renames, - ExpectedFsEvents.renameKey(oldPath, newPath) - ); - } - - public matchCreate(path: RelativePath): boolean { - return this.consume(this.creates, path); - } - - public matchUpdate( - path: RelativePath, - oldPath: RelativePath | undefined - ): boolean { - if (oldPath !== undefined) { - return this.consume( - this.renames, - ExpectedFsEvents.renameKey(oldPath, path) - ); - } - return this.consume(this.updates, path); - } - - public matchDelete(path: RelativePath): boolean { - return this.consume(this.deletes, path); - } - - public clear(): void { - this.creates.clear(); - this.updates.clear(); - this.deletes.clear(); - this.renames.clear(); - } - - private bump(map: Map, key: RelativePath): void { - map.set(key, (map.get(key) ?? 0) + 1); - } - - private consume( - map: Map, - key: RelativePath - ): boolean { - const count = map.get(key) ?? 0; - if (count === 0) { - return false; - } - if (count === 1) { - map.delete(key); - } else { - map.set(key, count - 1); - } - return true; - } - - private decrement(map: Map, key: RelativePath): void { - const count = map.get(key) ?? 0; - if (count <= 1) { - map.delete(key); - } else { - map.set(key, count - 1); - } - } -} diff --git a/frontend/sync-client/src/sync-operations/file-change-notifier.ts b/frontend/sync-client/src/sync-operations/file-change-notifier.ts index 414c9e91..d1e49d62 100644 --- a/frontend/sync-client/src/sync-operations/file-change-notifier.ts +++ b/frontend/sync-client/src/sync-operations/file-change-notifier.ts @@ -1,4 +1,4 @@ -import type { RelativePath } from "./types"; +import type { RelativePath } from "../persistence/database"; import { EventListeners } from "../utils/data-structures/event-listeners"; export class FileChangeNotifier { diff --git a/frontend/sync-client/src/sync-operations/offline-change-detector.test.ts b/frontend/sync-client/src/sync-operations/offline-change-detector.test.ts deleted file mode 100644 index cc710e6a..00000000 --- a/frontend/sync-client/src/sync-operations/offline-change-detector.test.ts +++ /dev/null @@ -1,185 +0,0 @@ -import { describe, it } from "node:test"; -import assert from "node:assert"; -import { Logger } from "../tracing/logger"; -import { Settings } from "../persistence/settings"; -import { STORED_STATE_SCHEMA_VERSION, SyncEventQueue } from "./sync-event-queue"; -import { scheduleOfflineChanges } from "./offline-change-detector"; -import type { FileOperations } from "../file-operations/file-operations"; -import type { RelativePath } from "./types"; - -const makeQueue = async (): Promise => { - const logger = new Logger(); - const settings = new Settings(logger, {}, async () => { - /* no-op */ - }); - return new SyncEventQueue( - settings, - logger, - { schemaVersion: STORED_STATE_SCHEMA_VERSION }, - async () => { - /* no-op */ - } - ); -}; - -const makeOperations = ( - files: Record -): FileOperations => { - return { - listFilesRecursively: async () => Object.keys(files), - read: async (path: RelativePath) => { - const data = files[path]; - if (data === undefined) { - throw new Error(`File not found: ${path}`); - } - return data; - } - } as unknown as FileOperations; -}; - -describe("scheduleOfflineChanges", () => { - it("does not bind a local file to a placement-pending record whose remoteRelativePath was persisted before the doc moved on the server", async () => { - // The bug: persisted byDocId can carry a placement-pending record - // whose `remoteRelativePath` was saved before the doc was moved - // server-side. After restart, offline-scan running before WS - // catch-up would bind an unrelated local file at that stale path - // to the moved doc and push the user's content as an update — - // silently corrupting the moved doc and stranding the local file. - const queue = await makeQueue(); - - // Stale placement-pending record: server has moved this doc - // away from "stale-X.md" since this snapshot was saved. - await queue.upsertRecord({ - documentId: "MOVED-DOC", - parentVersionId: 5, - remoteRelativePath: "stale-X.md" as RelativePath, - remoteHash: "hash-from-old-state", - localPath: undefined - }); - - // User has an unrelated local file at the stale path. - const operations = makeOperations({ - "stale-X.md": new TextEncoder().encode( - "user's unrelated local content" - ) - }); - - const enqueued: { kind: string; path: string }[] = []; - await scheduleOfflineChanges( - new Logger(), - operations, - queue, - (path) => enqueued.push({ kind: "create", path }), - (args) => enqueued.push({ kind: "update", path: args.relativePath }), - (path) => enqueued.push({ kind: "delete", path }) - ); - - // The local file must become a fresh CREATE — never a hostile - // UPDATE on the moved doc. - assert.deepStrictEqual(enqueued, [ - { kind: "create", path: "stale-X.md" } - ]); - - // The placement-pending record must remain placement-pending — - // its localPath must not have been bound to the unrelated user - // file. The reconciler will place it correctly once WS catch-up - // updates `remoteRelativePath` to the doc's current location. - const record = queue.getDocumentByDocumentId("MOVED-DOC"); - assert.notStrictEqual(record, undefined); - assert.strictEqual(record?.localPath, undefined); - }); - - it("schedules an update for a local file that matches a settled record's localPath", async () => { - const queue = await makeQueue(); - await queue.upsertRecord({ - documentId: "SETTLED-DOC", - parentVersionId: 2, - remoteRelativePath: "doc.md" as RelativePath, - remoteHash: "hash", - localPath: "doc.md" as RelativePath - }); - - const operations = makeOperations({ - "doc.md": new TextEncoder().encode("content") - }); - - const enqueued: { kind: string; path: string }[] = []; - await scheduleOfflineChanges( - new Logger(), - operations, - queue, - (path) => enqueued.push({ kind: "create", path }), - (args) => enqueued.push({ kind: "update", path: args.relativePath }), - (path) => enqueued.push({ kind: "delete", path }) - ); - - assert.deepStrictEqual(enqueued, [ - { kind: "update", path: "doc.md" } - ]); - }); - - it("schedules a delete for a settled record whose local file is missing", async () => { - const queue = await makeQueue(); - await queue.upsertRecord({ - documentId: "VANISHED-DOC", - parentVersionId: 4, - remoteRelativePath: "gone.md" as RelativePath, - remoteHash: "hash", - localPath: "gone.md" as RelativePath - }); - - const operations = makeOperations({}); - - const enqueued: { kind: string; path: string }[] = []; - await scheduleOfflineChanges( - new Logger(), - operations, - queue, - (path) => enqueued.push({ kind: "create", path }), - (args) => enqueued.push({ kind: "update", path: args.relativePath }), - (path) => enqueued.push({ kind: "delete", path }) - ); - - assert.deepStrictEqual(enqueued, [ - { kind: "delete", path: "gone.md" } - ]); - }); - - it("detects an offline rename when an untracked file matches a deleted record's content hash", async () => { - const queue = await makeQueue(); - const content = new TextEncoder().encode("body"); - const contentHash = await (await import("../utils/hash")).hash(content); - - await queue.upsertRecord({ - documentId: "DOC-1", - parentVersionId: 5, - remoteRelativePath: "old.md" as RelativePath, - remoteHash: contentHash, - localPath: "old.md" as RelativePath - }); - const operations = makeOperations({ "new.md": content }); - - const enqueued: { - kind: string; - path: string; - oldPath?: string; - }[] = []; - await scheduleOfflineChanges( - new Logger(), - operations, - queue, - (path) => enqueued.push({ kind: "create", path }), - (args) => - enqueued.push({ - kind: "update", - path: args.relativePath, - oldPath: args.oldPath - }), - (path) => enqueued.push({ kind: "delete", path }) - ); - - assert.deepStrictEqual(enqueued, [ - { kind: "update", path: "new.md", oldPath: "old.md" } - ]); - }); -}); diff --git a/frontend/sync-client/src/sync-operations/offline-change-detector.ts b/frontend/sync-client/src/sync-operations/offline-change-detector.ts deleted file mode 100644 index 5b91e782..00000000 --- a/frontend/sync-client/src/sync-operations/offline-change-detector.ts +++ /dev/null @@ -1,188 +0,0 @@ -import type { DocumentRecord, RelativePath } from "./types"; -import type { Logger } from "../tracing/logger"; -import { hash } from "../utils/hash"; -import type { FileOperations } from "../file-operations/file-operations"; -import { findMatchingFile } from "../utils/find-matching-file"; -import type { SyncEventQueue } from "./sync-event-queue"; -import { removeFromArray } from "../utils/remove-from-array"; -import { FileNotFoundError } from "../errors/file-not-found-error"; - -/** - * Scans the local filesystem and the document database to determine - * which files were created, updated, moved, or deleted while the - * client was offline, then enqueues the appropriate sync events. - * - * Placement-pending records (`localPath === undefined`) are deliberately - * NOT bound to local files at the same `remoteRelativePath` here. The - * persisted byDocId snapshot can be stale — a doc's server-side path - * may have changed since the last save, so binding by stored path would - * fold an unrelated user file into a moved doc and silently corrupt it. - * Local files at those paths fall through to the LocalCreate flow below; - * the server's create_document handler dedupes by path+freshness when - * the doc really is at that path, and otherwise creates a new doc that - * the reconciler places correctly once catch-up updates the stale - * record's `remoteRelativePath`. - */ -export async function scheduleOfflineChanges( - logger: Logger, - operations: FileOperations, - queue: SyncEventQueue, - enqueueCreate: (path: RelativePath) => void, - enqueueUpdate: (args: { - oldPath?: RelativePath; - relativePath: RelativePath; - }) => void, - enqueueDelete: (path: RelativePath) => void -): Promise { - const allLocalFiles = new Set(await operations.listFilesRecursively()); - logger.info(`Scheduling sync for ${allLocalFiles.size} local files`); - // `allSettledDocuments()` skips records with `localPath === undefined` - // — those have no local file by definition and don't participate in - // the disk-vs-record diff. The reconciler will place them on its - // next pass. - const allDocuments = queue.allSettledDocuments(); - - // A doc is "possibly deleted" only if it has no local file. Including - // docs that still exist locally would queue a spurious delete alongside - // the update below. - const locallyPossiblyDeletedFiles: DocumentRecord[] = []; - for (const record of allDocuments.values()) { - // `localPath` is guaranteed non-undefined for entries in - // `allSettledDocuments()`, but narrow explicitly for the type - // checker (and so a future change to that helper doesn't - // silently break this loop). - if ( - record.localPath !== undefined && - !allLocalFiles.has(record.localPath) - ) { - locallyPossiblyDeletedFiles.push(record); - } - } - - const locallyPossibleCreatedFiles: RelativePath[] = []; - const syncedLocalFiles: RelativePath[] = []; - - for (const localFile of allLocalFiles) { - if (allDocuments.has(localFile)) { - syncedLocalFiles.push(localFile); - } else if (queue.hasPendingCreateForPath(localFile)) { - // A LocalCreate for this path is still in flight (no - // record yet — its docId is a Promise). Re-enqueueing - // would fire a second HTTP create that the server then - // deconflicts to a sibling path, leaving the same bytes - // in two docs. Skip; the in-flight create owns this slot. - continue; - } else { - locallyPossibleCreatedFiles.push(localFile); - } - } - - const renamedPaths = new Set(); - // Track paths that were in `allLocalFiles` at scan-start but have - // since disappeared. The scan awaits between `listFilesRecursively` - // and each `read`, so a concurrent delete (slow file events, real - // user activity) can vacate a slot mid-scan. Throwing would abort - // the whole scan; nothing to sync for a file that's already gone. - const disappearedPaths = new Set(); - for (const path of locallyPossibleCreatedFiles) { - let content: Uint8Array; - try { - content = await operations.read(path); - } catch (e) { - if (e instanceof FileNotFoundError) { - logger.debug( - `File ${path} disappeared before offline-scan could read it; skipping` - ); - disappearedPaths.add(path); - continue; - } - throw e; - } - const contentHash = await hash(content); - - const matchingDeletedFile = await findMatchingFile( - contentHash, - locallyPossiblyDeletedFiles - ); - if (matchingDeletedFile !== undefined) { - // localPath is guaranteed defined for records in - // locallyPossiblyDeletedFiles (we filtered above). - const oldPath = matchingDeletedFile.localPath; - if (oldPath === undefined) { - continue; - } - logger.debug( - `File ${path} might have been moved from ${oldPath} while offline, scheduling sync to move it` - ); - enqueueUpdate({ - oldPath, - relativePath: path - }); - removeFromArray(locallyPossiblyDeletedFiles, matchingDeletedFile); - renamedPaths.add(path); - } - } - - for (const path of locallyPossibleCreatedFiles) { - if (renamedPaths.has(path) || disappearedPaths.has(path)) { - continue; - } - - logger.info( - `File ${path} was created while offline, scheduling sync to create it` - ); - - enqueueCreate(path); - } - - for (const item of locallyPossiblyDeletedFiles) { - if (item.localPath === undefined) { - continue; - } - logger.info( - `File ${item.localPath} was deleted while offline, scheduling sync to delete it` - ); - enqueueDelete(item.localPath); - } - - for (const path of syncedLocalFiles) { - const record = allDocuments.get(path); - if ( - record !== undefined && - record.localPath !== undefined && - record.localPath !== record.remoteRelativePath && - !allLocalFiles.has(record.remoteRelativePath) && - queue.byLocalPath.get(record.remoteRelativePath) === undefined - ) { - // Lost local-rename recovery. The record's `localPath` - // (where the user has the file now) and - // `remoteRelativePath` (where the server still thinks it - // lives) disagree, which means a queued user-rename's - // LocalUpdate never reached the server before the queue - // was wiped (typically a sync reset). Without this - // branch the next `enqueueUpdate({ relativePath: path })` - // is a content-only update — server keeps the doc at the - // old path, the user's file at the new path orphans, and - // other clients never see the rename. Replay the rename - // by restoring the OLD localPath so the queue's enqueue - // can find the record by `oldPath`, then enqueueUpdate - // moves it back to the new path with `isUserRename`. - // Only fires when the old slot is genuinely empty - // (neither on disk nor claimed by another tracked - // record) — otherwise the rename target is occupied and - // we'd be confusing the byLocalPath index. - const oldPath = record.remoteRelativePath; - const newPath = record.localPath; - logger.info( - `Lost local rename detected: doc ${record.documentId} at ${oldPath} (server) vs ${newPath} (local); replaying rename to server` - ); - await queue.setLocalPath(record.documentId, oldPath); - enqueueUpdate({ oldPath, relativePath: newPath }); - continue; - } - logger.info( - `File ${path} may have been updated while offline, scheduling sync to update it` - ); - enqueueUpdate({ relativePath: path }); - } -} diff --git a/frontend/sync-client/src/sync-operations/reconciler.test.ts b/frontend/sync-client/src/sync-operations/reconciler.test.ts deleted file mode 100644 index 13a08363..00000000 --- a/frontend/sync-client/src/sync-operations/reconciler.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { describe, it } from "node:test"; -import assert from "node:assert"; -import { Logger, LogLevel } from "../tracing/logger"; -import { Settings } from "../persistence/settings"; -import { STORED_STATE_SCHEMA_VERSION, SyncEventQueue } from "./sync-event-queue"; -import { Reconciler } from "./reconciler"; -import { SyncResetError } from "../errors/sync-reset-error"; -import type { FileOperations } from "../file-operations/file-operations"; -import type { SyncService } from "../services/sync-service"; -import type { RelativePath } from "./types"; - -describe("Reconciler", () => { - it("does not emit an error when placement fetch is interrupted by reset", async () => { - const logger = new Logger(); - const settings = new Settings(logger, {}, async () => { - /* no-op */ - }); - const queue = new SyncEventQueue( - settings, - logger, - { schemaVersion: STORED_STATE_SCHEMA_VERSION }, - async () => { - /* no-op */ - } - ); - - await queue.upsertRecord({ - documentId: "DOC-1", - parentVersionId: 1, - remoteHash: "hash", - remoteRelativePath: "remote.md" as RelativePath, - localPath: undefined - }); - - const operations = { - exists: async () => false, - create: async () => { - assert.fail("reset-interrupted placement should not write"); - } - } as unknown as FileOperations; - - const syncService = { - getDocumentVersionContent: async () => { - throw new SyncResetError(); - } - } as unknown as SyncService; - - const reconciler = new Reconciler( - logger, - operations, - syncService, - queue, - new Map() - ); - - await reconciler.run(); - - assert.deepStrictEqual(logger.getMessages(LogLevel.ERROR), []); - assert.ok( - logger - .getMessages(LogLevel.INFO) - .some((line) => - line.message.includes( - "content fetch for DOC-1 interrupted by sync reset" - ) - ) - ); - }); -}); diff --git a/frontend/sync-client/src/sync-operations/reconciler.ts b/frontend/sync-client/src/sync-operations/reconciler.ts deleted file mode 100644 index 93505a3c..00000000 --- a/frontend/sync-client/src/sync-operations/reconciler.ts +++ /dev/null @@ -1,1020 +0,0 @@ -import type { FileOperations } from "../file-operations/file-operations"; -import { FileNotFoundError } from "../errors/file-not-found-error"; -import { FileAlreadyExistsError } from "../errors/file-already-exists-error"; -import type { Logger } from "../tracing/logger"; -import type { SyncService } from "../services/sync-service"; -import type { SyncEventQueue } from "./sync-event-queue"; -import type { DocumentId, DocumentRecord, RelativePath } from "./types"; -import { hash } from "../utils/hash"; -import { SyncResetError } from "../errors/sync-reset-error"; - -const SWAP_MARKER_DIR = ".vaultlink"; -const SWAP_MARKER_PREFIX = "swap-"; -const SWAP_MARKER_SUFFIX = ".json"; - -interface SwapLeg { - documentId: DocumentId; - from: RelativePath; - to: RelativePath; - expectedHashOnFrom: string; -} - -interface SwapMarker { - uuid: string; - legs: SwapLeg[]; -} - -interface PlannedMove { - record: DocumentRecord; - from: RelativePath; - to: RelativePath; -} - -function tryParseSwapMarker(bytes: Uint8Array): SwapMarker | undefined { - try { - // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion - return JSON.parse(new TextDecoder().decode(bytes)) as SwapMarker; - } catch { - return undefined; - } -} - -/** - * The Reconciler is the second of the sync engine's two loops. The wire - * loop (records ↔ server) updates `record.remoteRelativePath` and writes - * file content into `record.localPath`; it does not move files for path - * placement. The Reconciler (records ↔ disk) runs after every wire-loop - * step and best-effort lines disk up with `remoteRelativePath` for every - * tracked record. - * - * "Best effort" means: any per-record obstacle (slot occupied, file - * missing, etc.) is silently skipped and retried on the next pass. - * `run()` never throws — per-record errors are logged and the next - * record is processed. - * - * Three shapes of work exist: - * 1. Initial placement — `localPath === undefined`. The wire loop - * created the record with no on-disk presence (e.g. a remote create - * whose target slot was occupied at receive time). If the slot is - * free now, fetch content (from `pendingPlacementContent` if a - * handler stuffed it for us, otherwise from the server) and write. - * 2. Simple rename — `localPath !== remoteRelativePath` and no other - * tracked record wants our current slot. Plain rename. - * 3. Cycle — two or more records want each others' current slots - * (A → B, B → A; or longer rotations). Resolved by reading every - * member's bytes into memory then overwriting each target slot. - * A write-ahead marker file lets `recoverFromInterruptedSwap()` - * finish a swap that crashed mid-flight on next startup. - */ -export class Reconciler { - public constructor( - private readonly logger: Logger, - private readonly operations: FileOperations, - private readonly syncService: SyncService, - private readonly queue: SyncEventQueue, - // Bytes already in hand from a recent server response, keyed by - // docId. Wire-loop handlers populate this transiently when they - // have content for a record they just upserted with `localPath - // === undefined`; the reconciler uses it on the same pass - // instead of re-fetching from the server. Keys are deleted when - // consumed. - private readonly pendingPlacementContent: Map - ) {} - - /** - * Single best-effort pass. Walks every tracked record, places - * unplaced ones, and reorganises any whose `localPath !== - * remoteRelativePath`. Never throws — per-record failures are - * logged and the next record is processed. The Syncer is expected - * to call this after every wire-loop drain step, so any record - * skipped this pass gets another shot once the obstructing event - * is processed. - */ - public async run(): Promise { - const allRecords = this.collectAllRecords(); - - const movesNeeded: PlannedMove[] = []; - const deferredPlacements: DocumentRecord[] = []; - - for (const record of allRecords) { - if (record.localPath === record.remoteRelativePath) { - continue; - } - - // The reconciler operates on settled records. A record with a - // pending LocalUpdate or LocalDelete is mid-flight: the wire - // loop owns the user's intent (rename target, edit content, - // deletion) and the record's `remoteRelativePath` may still - // reflect the pre-rename server state. Touching disk now - // would race the wire loop — e.g. a queued user-rename - // LocalUpdate would find its source path vacated by the - // reconciler moving the file back to the stale - // `remoteRelativePath`. Skip; once the wire loop drains the - // pending events, a subsequent reconciler pass sees a - // settled record and converges. - if ( - this.queue.hasPendingLocalEventsForDocumentId(record.documentId) - ) { - continue; - } - - // The doc has been deleted server-side (HTTP DELETE acked) but - // the WebSocket receipt that would `removeDocumentById` hasn't - // arrived yet. The record looks like "needs initial placement" - // (`localPath === undefined`, since the LocalDelete enqueue - // cleared it), but placing would resurrect a doc the user - // explicitly deleted. Skip; `processRemoteDelete` will remove - // the record entirely once the WS receipt arrives. - if (this.queue.hasPendingServerDelete(record.documentId)) { - continue; - } - - if (record.localPath === undefined) { - deferredPlacements.push(record); - continue; - } - - // localPath !== undefined and !== remoteRelativePath. Plan a - // move. First defensive existence check: the file may have - // been deleted between the wire loop touching disk and this - // reconciler pass — the watcher's LocalDelete will land - // shortly and fix the record. Skip silently. - try { - if (!(await this.operations.exists(record.localPath))) { - this.logger.debug( - `Reconciler: record ${record.documentId} localPath ${record.localPath} ` + - `is missing on disk; skipping (LocalDelete will catch up)` - ); - continue; - } - } catch (e) { - this.logger.error( - `Reconciler: existence check failed for ${record.localPath}: ${String(e)}` - ); - continue; - } - - movesNeeded.push({ - record, - from: record.localPath, - to: record.remoteRelativePath - }); - } - - if (movesNeeded.length > 0) { - await this.executeMoves(movesNeeded); - } - - // Run placements *after* moves so a placement whose target slot - // was occupied by a tracked record at the start of the pass can - // still succeed once that record's move frees the slot. Without - // this ordering, a placement-pending record stalls until the - // next reconciler tick — which only fires when new events - // arrive, leaving the doc absent on disk if the queue happens - // to be quiescent at that moment. - for (const record of deferredPlacements) { - // Re-check the gating conditions: a pending event may have - // been enqueued for this doc while we were processing - // moves above, and an interleaved placement would race - // it. - if ( - this.queue.hasPendingLocalEventsForDocumentId(record.documentId) - ) { - continue; - } - if (this.queue.hasPendingServerDelete(record.documentId)) { - continue; - } - if (record.localPath !== undefined) { - continue; - } - await this.tryInitialPlacement(record); - } - } - - /** - * Read any swap-marker file left behind by a crash mid-swap and - * roll forward. Called once on startup before the Reconciler - * begins normal passes. Idempotent: with no marker, a no-op. - */ - public async recoverFromInterruptedSwap(): Promise { - let markerPaths: RelativePath[] = []; - try { - markerPaths = await this.findSwapMarkerFiles(); - } catch (e) { - this.logger.error( - `Reconciler: failed to scan for swap markers: ${String(e)}` - ); - return; - } - - for (const markerPath of markerPaths) { - try { - await this.recoverFromOneMarker(markerPath); - } catch (e) { - this.logger.error( - `Reconciler: recovery from ${markerPath} failed: ${String(e)}` - ); - } - } - } - - private collectAllRecords(): DocumentRecord[] { - // Iterate every tracked record — placement-pending ones - // (`localPath === undefined`) included. `allSettledDocuments` - // filters those out, which would render records born from a - // remote create that landed on an occupied slot (no on-disk - // file, no entry in `pendingPlacementContent` either, since the - // wire loop deliberately doesn't buffer their content) invisible - // forever. `pendingPlacementContent` is purely a cache for - // `tryInitialPlacement`'s content fetch — not a record-discovery - // channel. - const out: DocumentRecord[] = []; - for (const record of this.queue.allRecords()) { - out.push(record); - } - - // Best-effort cleanup: drop cached content for docs the queue - // no longer tracks. Previously this happened as a side effect of - // the placement-pending discovery loop; do it explicitly now. - if (this.pendingPlacementContent.size > 0) { - for (const docId of this.pendingPlacementContent.keys()) { - if (this.queue.getDocumentByDocumentId(docId) === undefined) { - this.pendingPlacementContent.delete(docId); - } - } - } - - return out; - } - - private async tryInitialPlacement(record: DocumentRecord): Promise { - const target = record.remoteRelativePath; - - if (this.queue.hasPendingCreateForPath(target)) { - this.logger.debug( - `Reconciler: cannot place ${record.documentId} at ${target} ` + - `— pending local create still claims that path; will retry next pass` - ); - return; - } - - // Slot occupancy: pre-check both the disk and our tracked - // records. Either form of occupancy means we wait — the - // occupant's own reconciliation pass (after their next wire-loop - // step) will move them off this slot. - try { - if (await this.operations.exists(target)) { - this.logger.debug( - `Reconciler: cannot place ${record.documentId} at ${target} ` + - `— slot occupied on disk; will retry next pass` - ); - return; - } - } catch (e) { - this.logger.error( - `Reconciler: existence check failed for ${target}: ${String(e)}` - ); - return; - } - if (this.queue.byLocalPath.get(target) !== undefined) { - this.logger.debug( - `Reconciler: cannot place ${record.documentId} at ${target} ` + - `— slot tracked by another record; will retry next pass` - ); - return; - } - - let content = this.pendingPlacementContent.get(record.documentId); - if (content === undefined) { - try { - content = await this.syncService.getDocumentVersionContent({ - documentId: record.documentId, - vaultUpdateId: record.parentVersionId - }); - } catch (e) { - if (e instanceof SyncResetError) { - this.logger.info( - `Reconciler: content fetch for ${record.documentId} interrupted by sync reset` - ); - return; - } - this.logger.error( - `Reconciler: failed to fetch content for ${record.documentId}: ${String(e)}` - ); - return; - } - } - - try { - await this.operations.create(target, content); - } catch (e) { - if (e instanceof FileNotFoundError) { - this.logger.debug( - `Reconciler: create at ${target} hit FileNotFound (likely parent ` + - `directory race); will retry next pass` - ); - return; - } - if (e instanceof FileAlreadyExistsError) { - this.logger.debug( - `Reconciler: create at ${target} lost TOCTOU race ` + - `(slot occupied between pre-check and write); will retry next pass` - ); - return; - } - this.logger.error( - `Reconciler: create at ${target} failed: ${String(e)}` - ); - return; - } - - try { - await this.queue.setLocalPath(record.documentId, target); - } catch (e) { - this.logger.error( - `Reconciler: setLocalPath after create failed for ${record.documentId}: ${String(e)}` - ); - return; - } - this.pendingPlacementContent.delete(record.documentId); - this.logger.debug( - `Reconciler: placed ${record.documentId} at ${target}` - ); - } - - private async executeMoves(moves: PlannedMove[]): Promise { - // Build a directed graph: each move (record currently at `from`, - // wants to go to `to`) gets an edge to whatever tracked record - // currently holds `to`. A node with no outgoing edge is a leaf - // in the DAG: its target slot is held by no tracked record. If - // the slot is held by an *untracked* file we can't safely - // displace it (no record to relocate); skip those moves and - // let the next pass retry. - const movesByDocId = new Map(); - for (const move of moves) { - movesByDocId.set(move.record.documentId, move); - } - - const skipped = new Set(); - const edges = new Map(); - - for (const move of moves) { - const occupant = this.queue.byLocalPath.get(move.to); - if (occupant === undefined) { - let occupied = false; - try { - occupied = await this.operations.exists(move.to); - } catch (e) { - this.logger.error( - `Reconciler: existence check failed for ${move.to}: ${String(e)}` - ); - skipped.add(move.record.documentId); - continue; - } - if (occupied) { - this.logger.debug( - `Reconciler: move ${move.record.documentId} -> ${move.to} blocked ` + - `by untracked file; will retry next pass` - ); - skipped.add(move.record.documentId); - continue; - } - edges.set(move.record.documentId, null); - } else if (occupant.documentId === move.record.documentId) { - // Self-loop on `to` shouldn't normally happen — we - // skipped records where localPath===remoteRelativePath - // up front. Defensive: nothing to do. - continue; - } else if (movesByDocId.has(occupant.documentId)) { - edges.set(move.record.documentId, occupant.documentId); - } else { - // Occupant is a tracked record that doesn't *want* to - // move (its localPath === its remoteRelativePath). We - // can't dislodge it without orphaning its on-disk - // file; skip and retry. - this.logger.debug( - `Reconciler: move ${move.record.documentId} -> ${move.to} blocked by ` + - `tracked record ${occupant.documentId} which is not moving; ` + - `will retry next pass` - ); - skipped.add(move.record.documentId); - } - } - - // SCC decomposition (Tarjan's algorithm) over the move graph. - const sccs = this.tarjanSccs(edges, skipped); - - // Topo-sort the DAG of SCCs (leaves first). Tarjan emits SCCs - // in reverse topological order — leaves first — which is - // already what we want. - for (const scc of sccs) { - if (scc.length === 1) { - const [docId] = scc; - if (skipped.has(docId)) { - continue; - } - const move = movesByDocId.get(docId); - if (move === undefined) { - continue; - } - // Self-loop check: if the only edge from this node - // points back to itself, treat as a 1-cycle (impossible - // given our up-front filter, but cheap defensiveness). - const target = edges.get(docId); - if (target === docId) { - await this.executeCycle([move]); - } else { - await this.executeSimpleRename(move); - } - } else { - const cycleMoves = scc - .map((id) => movesByDocId.get(id)) - .filter( - (m): m is PlannedMove => - m !== undefined && !skipped.has(m.record.documentId) - ); - if (cycleMoves.length === scc.length) { - await this.executeCycle(cycleMoves); - } else { - // A member of the cycle was skipped — the cycle - // can't be resolved as a unit. Skip the rest; next - // pass tries again with whatever's still relevant. - this.logger.debug( - `Reconciler: cycle of ${scc.length} skipped because a ` + - `member dropped out; will retry next pass` - ); - } - } - } - } - - private async executeSimpleRename(move: PlannedMove): Promise { - // Defense-in-depth: the queue's invariant says - // `record.localPath !== undefined ⇒ byLocalPath.get(record.localPath) === record`. - // If the byLocalPath index disagrees with the record we - // captured when planning, the invariant was violated somewhere - // upstream — the file at `move.from` belongs to a different - // record now and renaming it would clobber that record's - // content. Refuse the move; the next pass re-plans. - const indexed = this.queue.byLocalPath.get(move.from); - if (indexed !== move.record) { - this.logger.warn( - `Reconciler: refusing rename ${move.from} -> ${move.to} for ` + - `${move.record.documentId}: byLocalPath says ${move.from} ` + - `belongs to ${indexed?.documentId ?? ""} ` + - `(invariant violation upstream); skipping` - ); - return; - } - // The target may have been freed by an earlier move in this - // pass (a leaf we processed first). Re-check both source and - // target before committing. - try { - if (!(await this.operations.exists(move.from))) { - this.logger.debug( - `Reconciler: source ${move.from} vanished before rename; skipping` - ); - return; - } - } catch (e) { - this.logger.error( - `Reconciler: existence check failed for ${move.from}: ${String(e)}` - ); - return; - } - try { - if (await this.operations.exists(move.to)) { - if (this.queue.byLocalPath.get(move.to) !== undefined) { - // Slot got reclaimed by a tracked doc mid-pass — - // back off and retry next pass. - this.logger.debug( - `Reconciler: target ${move.to} reclaimed by another record ` + - `mid-pass; skipping` - ); - return; - } - // Untracked file appeared; same reasoning as in - // executeMoves' planning step. Defer. - this.logger.debug( - `Reconciler: target ${move.to} now occupied by untracked file; skipping` - ); - return; - } - } catch (e) { - this.logger.error( - `Reconciler: existence check failed for ${move.to}: ${String(e)}` - ); - return; - } - - try { - await this.operations.move(move.from, move.to); - } catch (e) { - if (e instanceof FileNotFoundError) { - this.logger.debug( - `Reconciler: rename ${move.from} -> ${move.to} hit FileNotFound; ` + - `will retry next pass` - ); - return; - } - if (e instanceof FileAlreadyExistsError) { - this.logger.debug( - `Reconciler: rename ${move.from} -> ${move.to} lost TOCTOU race ` + - `(target reclaimed between pre-check and rename); will retry next pass` - ); - return; - } - this.logger.error( - `Reconciler: rename ${move.from} -> ${move.to} failed: ${String(e)}` - ); - return; - } - - try { - await this.queue.setLocalPath(move.record.documentId, move.to); - } catch (e) { - this.logger.error( - `Reconciler: setLocalPath after rename failed for ${move.record.documentId}: ${String(e)}` - ); - return; - } - this.logger.debug( - `Reconciler: renamed ${move.record.documentId} from ${move.from} to ${move.to}` - ); - } - - private async executeCycle(members: PlannedMove[]): Promise { - // Defense-in-depth: same invariant check as - // `executeSimpleRename` but cycle-wide. If any member's `from` - // slot no longer matches the planned record per byLocalPath, - // abort the whole cycle — partial-cycle progress under a - // shadowed-record race is the worst case (it can shuffle bytes - // between the wrong docs). - for (const member of members) { - const indexed = this.queue.byLocalPath.get(member.from); - if (indexed !== member.record) { - this.logger.warn( - `Reconciler: refusing cycle: byLocalPath says ${member.from} ` + - `belongs to ${indexed?.documentId ?? ""} ` + - `but planned for ${member.record.documentId} ` + - `(invariant violation upstream); skipping cycle` - ); - return; - } - } - // Read every member's bytes first; we'll overwrite the target - // slots with these. All reads happen before any write, so the - // cycle is fully captured in memory before we start mutating - // disk. If any read fails the whole cycle is aborted — - // partial-cycle work is the riskiest case (it can leave docs - // pointing at the wrong content). - const contentByDocId = new Map(); - // We also need the pre-write content of each `to` slot for the - // 3-way merge in `operations.write` — passing the freshly-read - // disk bytes as `expectedContent` makes the merge resolve to a - // clean overwrite (since `expected === current` at write time). - const oldToContentByDocId = new Map(); - try { - for (const member of members) { - contentByDocId.set( - member.record.documentId, - await this.operations.read(member.from) - ); - } - // The `to` of each member is guaranteed to be the `from` of - // some other member (it's a cycle). We've already read all - // those `from`s, so reuse those reads. - const fromToDocId = new Map(); - for (const member of members) { - fromToDocId.set(member.from, member.record.documentId); - } - for (const member of members) { - const sourceDocId = fromToDocId.get(member.to); - if (sourceDocId === undefined) { - throw new Error( - `Reconciler: cycle ${member.record.documentId} -> ${member.to} ` + - `has no member at ${member.to}; graph is not a true cycle` - ); - } - const oldBytes = contentByDocId.get(sourceDocId); - if (oldBytes === undefined) { - throw new Error( - `Reconciler: missing pre-read content for ${sourceDocId}` - ); - } - oldToContentByDocId.set(member.record.documentId, oldBytes); - } - } catch (e) { - this.logger.error( - `Reconciler: cycle pre-read failed: ${String(e)}; aborting cycle` - ); - return; - } - - // Write-ahead marker so a crash mid-swap can be repaired on - // next start. Recovery decides what's been written by hashing - // each `from` slot — anything still matching `expectedHashOnFrom` - // hasn't been overwritten yet. - const legs: SwapLeg[] = []; - try { - for (const member of members) { - const memberContent = contentByDocId.get( - member.record.documentId - ); - if (memberContent === undefined) { - throw new Error( - `Reconciler: cycle member ${member.record.documentId} missing content` - ); - } - legs.push({ - documentId: member.record.documentId, - from: member.from, - to: member.to, - expectedHashOnFrom: await hash(memberContent) - }); - } - } catch (e) { - this.logger.error( - `Reconciler: cycle hashing failed: ${String(e)}; aborting cycle` - ); - return; - } - - const markerUuid = crypto.randomUUID(); - const markerPath = this.markerPathFor(markerUuid); - const markerBytes = new TextEncoder().encode( - JSON.stringify({ uuid: markerUuid, legs } satisfies SwapMarker) - ); - try { - // The marker path embeds a fresh uuid, so a FileAlreadyExistsError - // is statistically impossible here. - await this.operations.create(markerPath, markerBytes); - } catch (e) { - this.logger.error( - `Reconciler: failed to write swap marker ${markerPath}: ${String(e)}; ` + - `aborting cycle` - ); - return; - } - - // Now apply the writes. Each leg overwrites the bytes at `to` - // with the bytes that were at the cycle predecessor's `from`. - // We pass the freshly-read pre-write content as - // `expectedContent` so the 3-way merge inside `operations.write` - // becomes a clean overwrite (no concurrent edits to merge with). - // `operations.write` registers `expectUpdate` itself, so the - // watcher swallows each leg's modify event. - const writtenLegs: SwapLeg[] = []; - for (const leg of legs) { - const newBytes = contentByDocId.get(leg.documentId); - const oldBytes = oldToContentByDocId.get(leg.documentId); - if (newBytes === undefined || oldBytes === undefined) { - this.logger.error( - `Reconciler: cycle leg ${leg.from} -> ${leg.to} missing ` + - `content; aborting cycle` - ); - return; - } - try { - await this.operations.write(leg.to, oldBytes, newBytes); - writtenLegs.push(leg); - } catch (e) { - this.logger.error( - `Reconciler: cycle leg ${leg.from} -> ${leg.to} write failed: ` + - `${String(e)}; cycle is now in a half-applied state — recovery ` + - `marker ${markerPath} will roll forward on next start` - ); - // Don't delete the marker — it's load-bearing for - // recovery. The records' localPath assignments are - // intentionally NOT updated for the failed leg or any - // subsequent leg, so the next reconciler pass will - // observe the same situation and re-plan. - return; - } - } - - // Re-key records to their new localPaths. We do this AFTER - // all writes succeeded; if a setLocalPath fails partway the - // marker is still on disk and recovery covers it. - for (const leg of writtenLegs) { - try { - await this.queue.setLocalPath(leg.documentId, leg.to); - } catch (e) { - this.logger.error( - `Reconciler: setLocalPath after cycle write failed for ` + - `${leg.documentId}: ${String(e)}` - ); - } - } - - try { - await this.operations.delete(markerPath); - } catch (e) { - this.logger.warn( - `Reconciler: failed to delete swap marker ${markerPath}: ${String(e)}; ` + - `next start's recovery will see it but find every leg already applied` - ); - } - this.logger.debug( - `Reconciler: completed cycle of ${members.length} members` - ); - } - - private async findSwapMarkerFiles(): Promise { - let entries: RelativePath[] = []; - try { - entries = - await this.operations.listFilesRecursively(SWAP_MARKER_DIR); - } catch (e) { - if (e instanceof FileNotFoundError) { - return []; - } - throw e; - } - return entries.filter((p) => { - const name = p.split("/").pop() ?? ""; - return ( - name.startsWith(SWAP_MARKER_PREFIX) && - name.endsWith(SWAP_MARKER_SUFFIX) - ); - }); - } - - private async recoverFromOneMarker( - markerPath: RelativePath - ): Promise { - const markerBytes = await this.operations.read(markerPath); - const marker = this.parseSwapMarker(markerBytes); - if (marker === undefined) { - this.logger.error( - `Reconciler: corrupt swap marker ${markerPath}; deleting` - ); - try { - await this.operations.delete(markerPath); - } catch (deleteErr) { - this.logger.error( - `Reconciler: failed to delete corrupt marker ${markerPath}: ${String(deleteErr)}` - ); - } - return; - } - - this.logger.info( - `Reconciler: recovering from interrupted swap ${marker.uuid} ` + - `with ${marker.legs.length} legs` - ); - - // Recovery rules per leg: - // - hash(from) === expectedHashOnFrom — the swap was - // interrupted BEFORE this leg overwrote `to`. We need to - // write the source bytes to `to` AND update the record. - // - hash(from) differs (or `from` is missing) — this leg - // already ran (someone else's bytes are now at `from`, - // which means the cycle predecessor's leg ran too). Mark - // as already-applied for record bookkeeping. - for (const leg of marker.legs) { - let needsApply = false; - try { - if (await this.operations.exists(leg.from)) { - const fromBytes = await this.operations.read(leg.from); - const fromHash = await hash(fromBytes); - needsApply = fromHash === leg.expectedHashOnFrom; - } - } catch (e) { - this.logger.error( - `Reconciler: hash check during recovery for ${leg.from} failed: ` + - `${String(e)}; skipping leg` - ); - continue; - } - - if (needsApply) { - try { - const sourceBytes = await this.operations.read(leg.from); - // We don't know what (if anything) is at `to`. If - // it exists we want to overwrite. operations.write - // refuses if the file doesn't exist, so: - if (await this.operations.exists(leg.to)) { - const currentToBytes = await this.operations.read( - leg.to - ); - await this.operations.write( - leg.to, - currentToBytes, - sourceBytes - ); - } else { - await this.operations.create(leg.to, sourceBytes); - } - } catch (e) { - this.logger.error( - `Reconciler: applying recovery leg ${leg.from} -> ${leg.to} ` + - `failed: ${String(e)}` - ); - continue; - } - } - - // Whether we just applied or it was already applied, - // update the record so its localPath matches the - // post-swap state. - try { - const record = this.queue.getDocumentByDocumentId( - leg.documentId - ); - if (record !== undefined) { - await this.queue.setLocalPath(leg.documentId, leg.to); - } - } catch (e) { - this.logger.error( - `Reconciler: setLocalPath during recovery for ${leg.documentId} ` + - `failed: ${String(e)}` - ); - } - } - - try { - await this.operations.delete(markerPath); - } catch (e) { - this.logger.error( - `Reconciler: failed to delete swap marker ${markerPath} after recovery: ` + - String(e) - ); - } - } - - private markerPathFor(uuid: string): RelativePath { - return `${SWAP_MARKER_DIR}/${SWAP_MARKER_PREFIX}${uuid}${SWAP_MARKER_SUFFIX}`; - } - - /** - * SCC decomposition over the move graph, returning components in - * leaves-first order (so the caller can process leaves before - * cycles, freeing target slots progressively). - * - * Exploits the fact that this is a *functional graph*: each node - * has at most one outgoing edge (the doc whose slot we want). So - * every non-trivial SCC is a single simple cycle; any non-cycle - * node is its own singleton component. To detect cycles, walk - * from each unvisited node following edges and mark the path; if - * we hit a node on the current path, the segment from that node - * to the current frontier is a cycle. If we hit a visited node - * not on the current path (or a null), we just chain leaves. - * - * Skipped nodes are treated as having no outgoing edge (their - * targets are blocked). - */ - private tarjanSccs( - edges: Map, - skipped: Set - ): DocumentId[][] { - const allNodes = new Set(); - for (const id of edges.keys()) { - allNodes.add(id); - } - for (const id of skipped) { - allNodes.add(id); - } - - const visited = new Set(); - const componentOf = new Map(); - const sccs: DocumentId[][] = []; - - const edgeOf = (node: DocumentId): DocumentId | null => { - if (skipped.has(node)) { - return null; - } - return edges.get(node) ?? null; - }; - - for (const root of allNodes) { - if (visited.has(root)) { - continue; - } - - // Walk forward marking the path until we hit a visited node - // or a null. `pathIndex` lets us detect "did we land back on - // our own path". - const path: DocumentId[] = []; - const pathIndex = new Map(); - let cursor: DocumentId | null = root; - - while ( - cursor !== null && - !visited.has(cursor) && - !pathIndex.has(cursor) - ) { - pathIndex.set(cursor, path.length); - path.push(cursor); - cursor = edgeOf(cursor); - } - - // We stopped because either (a) cursor is null, (b) cursor - // is already visited (chain merges into an earlier-explored - // subgraph — every node on `path` is its own singleton - // component), or (c) cursor is on `path` itself — the - // suffix of `path` from `pathIndex.get(cursor)` onward is a - // cycle; the prefix is a tail of singletons. - let cycleStart = path.length; - if (cursor !== null) { - const idx = pathIndex.get(cursor); - if (idx !== undefined) { - cycleStart = idx; - } - } - - // Singletons in `path[0..cycleStart)`. Emit them in - // leaves-first order: the deepest (closest to the cycle or - // chain-end) is the leaf in the DAG of SCCs, so we emit - // from the END of the prefix backward to get topo order - // (children before parents). - for (let i = cycleStart - 1; i >= 0; i--) { - const node = path[i]; - visited.add(node); - const componentId = sccs.length; - componentOf.set(node, componentId); - sccs.push([node]); - } - // Cycle (if any). - if (cycleStart < path.length) { - const cycleNodes = path.slice(cycleStart); - const componentId = sccs.length; - for (const node of cycleNodes) { - visited.add(node); - componentOf.set(node, componentId); - } - sccs.push(cycleNodes); - } - } - - // The order produced above is mostly leaves-first per chain, - // but chains explored later may include singletons that merge - // into earlier-emitted components. Re-sort by (component points - // to anything? if so, target's component must come first). With - // a functional graph this is equivalent to emitting any node - // before the node it points to. Do a final stable topo sort. - const componentTarget = new Map(); - for (let cid = 0; cid < sccs.length; cid++) { - // Pick a representative; in a functional-graph SCC, every - // node's edge points either inside the SCC (cycle) or to - // exactly one other SCC (singleton chain). For singletons - // the representative's edge gives us the parent component. - const [rep] = sccs[cid]; - const edge = edgeOf(rep); - if (edge === null) { - componentTarget.set(cid, null); - } else { - const targetCid = componentOf.get(edge); - if (targetCid === undefined || targetCid === cid) { - componentTarget.set(cid, null); - } else { - componentTarget.set(cid, targetCid); - } - } - } - - // Topo-sort: emit a component only after its target has been - // emitted. - const emitted = new Set(); - const ordered: DocumentId[][] = []; - const tryEmit = (cid: number, stack: Set): void => { - if (emitted.has(cid)) { - return; - } - if (stack.has(cid)) { - return; - } // shouldn't happen given functional-graph SCC contraction - stack.add(cid); - const target = componentTarget.get(cid) ?? null; - if (target !== null) { - tryEmit(target, stack); - } - stack.delete(cid); - if (!emitted.has(cid)) { - emitted.add(cid); - ordered.push(sccs[cid]); - } - }; - for (let cid = 0; cid < sccs.length; cid++) { - tryEmit(cid, new Set()); - } - - return ordered; - } - - private parseSwapMarker(bytes: Uint8Array): SwapMarker | undefined { - // Marker files are written by us (`writeSwapMarker`) and only - // consumed here on startup recovery; the shape is closed. Treat - // a parse failure as a corrupt marker. - const parsed = tryParseSwapMarker(bytes); - if ( - parsed === undefined || - typeof parsed.uuid !== "string" || - !Array.isArray(parsed.legs) - ) { - return undefined; - } - return parsed; - } -} diff --git a/frontend/sync-client/src/sync-operations/sync-event-queue.test.ts b/frontend/sync-client/src/sync-operations/sync-event-queue.test.ts deleted file mode 100644 index d2676011..00000000 --- a/frontend/sync-client/src/sync-operations/sync-event-queue.test.ts +++ /dev/null @@ -1,907 +0,0 @@ -import { describe, it } from "node:test"; -import assert from "node:assert"; -import { - STORED_STATE_SCHEMA_VERSION, - SyncEventQueue -} from "./sync-event-queue"; -import { Settings } from "../persistence/settings"; -import { Logger } from "../tracing/logger"; -import type { DocumentVersionWithoutContent } from "../services/types/DocumentVersionWithoutContent"; -import { SyncEventType } from "./types"; -import type { DocumentRecord, RelativePath, StoredSyncState } from "./types"; - -interface QueueHarness { - queue: SyncEventQueue; - settings: Settings; - saveCalls: StoredSyncState[]; -} - -function createHarness( - options: { - ignorePatterns?: string[]; - initialState?: Partial; - omitSchemaVersion?: boolean; - } = {} -): QueueHarness { - const logger = new Logger(); - const settings = new Settings( - logger, - { ignorePatterns: options.ignorePatterns ?? [] }, - async () => { - /* no-op */ - } - ); - - const saveCalls: StoredSyncState[] = []; - const initialState: Partial | undefined = - options.initialState === undefined && options.omitSchemaVersion !== true - ? { schemaVersion: STORED_STATE_SCHEMA_VERSION } - : options.initialState; - - const queue = new SyncEventQueue( - settings, - logger, - initialState, - async (data) => { - saveCalls.push(data); - } - ); - return { queue, settings, saveCalls }; -} - -function createQueue(ignorePatterns: string[] = []): SyncEventQueue { - return createHarness({ ignorePatterns }).queue; -} - -function fakeRemoteVersion( - documentId: string, - overrides: Partial = {} -): DocumentVersionWithoutContent { - return { - vaultUpdateId: 1, - documentId, - relativePath: `${documentId}.md`, - updatedDate: "2026-01-01", - isDeleted: false, - userId: "user", - deviceId: "device", - contentSize: 100, - isNewFile: true, - ...overrides - }; -} - -function fakeRecord( - documentId: string, - overrides: Partial = {} -): DocumentRecord { - const path = `${documentId.toLowerCase()}.md`; - return { - documentId, - parentVersionId: 1, - remoteHash: `hash-${documentId}`, - remoteRelativePath: path, - localPath: path, - ...overrides - }; -} - -describe("SyncEventQueue", () => { - it("returns enqueued events in FIFO order with no coalescing", async () => { - const queue = createQueue(); - await queue.upsertRecord(fakeRecord("A")); - - await queue.enqueue({ type: SyncEventType.LocalCreate, path: "b.md" }); - await queue.enqueue({ type: SyncEventType.LocalCreate, path: "c.md" }); - await queue.enqueue({ type: SyncEventType.LocalDelete, path: "a.md" }); - - const first = await queue.next(); - assert.strictEqual(first?.type, SyncEventType.LocalCreate); - - const second = await queue.next(); - assert.strictEqual(second?.type, SyncEventType.LocalCreate); - - const third = await queue.next(); - assert.strictEqual(third?.type, SyncEventType.LocalDelete); - assert.strictEqual(third.documentId, "A"); - - assert.strictEqual(await queue.next(), undefined); - }); - - it("create events are returned FIFO", async () => { - const queue = createQueue(); - await queue.enqueue({ type: SyncEventType.LocalCreate, path: "a.md" }); - await queue.enqueue({ type: SyncEventType.LocalCreate, path: "b.md" }); - - const first = await queue.next(); - assert.strictEqual(first?.type, SyncEventType.LocalCreate); - assert.strictEqual(first.path, "a.md"); - - const second = await queue.next(); - assert.strictEqual(second?.type, SyncEventType.LocalCreate); - assert.strictEqual(second.path, "b.md"); - }); - - it("delete resolves documentId from path", async () => { - const queue = createQueue(); - await queue.upsertRecord(fakeRecord("A")); - - await queue.enqueue({ type: SyncEventType.LocalDelete, path: "a.md" }); - - const event = await queue.next(); - assert.strictEqual(event?.type, SyncEventType.LocalDelete); - assert.strictEqual(event.documentId, "A"); - }); - - it("delete for unknown path is silently ignored", async () => { - const queue = createQueue(); - await queue.enqueue({ - type: SyncEventType.LocalDelete, - path: "unknown.md" - }); - assert.strictEqual(queue.pendingUpdateCount, 0); - }); - - it("delete clears the localPath of the affected record", async () => { - const queue = createQueue(); - await queue.upsertRecord(fakeRecord("A")); - - await queue.enqueue({ type: SyncEventType.LocalDelete, path: "a.md" }); - - const record = queue.getDocumentByDocumentId("A"); - assert.ok(record !== undefined); - assert.strictEqual(record.localPath, undefined); - assert.strictEqual( - queue.getRecordByLocalPath("a.md" as RelativePath), - undefined - ); - }); - - it("document store CRUD operations work correctly", async () => { - const queue = createQueue(); - - assert.strictEqual( - queue.getRecordByLocalPath("a.md" as RelativePath), - undefined - ); - assert.strictEqual(queue.syncedDocumentCount, 0); - - await queue.upsertRecord(fakeRecord("A")); - assert.strictEqual(queue.syncedDocumentCount, 1); - - const settled = queue.getRecordByLocalPath("a.md" as RelativePath); - assert.strictEqual(settled?.documentId, "A"); - assert.strictEqual(settled.localPath, "a.md"); - assert.strictEqual(settled.remoteRelativePath, "a.md"); - - const found = queue.getDocumentByDocumentId("A"); - assert.strictEqual(found?.localPath, "a.md"); - assert.strictEqual(found.documentId, "A"); - - await queue.removeDocumentById("A"); - assert.strictEqual(queue.syncedDocumentCount, 0); - assert.strictEqual( - queue.getRecordByLocalPath("a.md" as RelativePath), - undefined - ); - assert.strictEqual(queue.getDocumentByDocumentId("A"), undefined); - }); - - it("LocalUpdate with oldPath moves the document on disk", async () => { - const queue = createQueue(); - await queue.upsertRecord(fakeRecord("A")); - - await queue.enqueue({ - type: SyncEventType.LocalUpdate, - path: "b.md", - oldPath: "a.md" - }); - - assert.strictEqual( - queue.getRecordByLocalPath("a.md" as RelativePath), - undefined - ); - const moved = queue.getRecordByLocalPath("b.md" as RelativePath); - assert.strictEqual(moved?.documentId, "A"); - assert.strictEqual(moved.localPath, "b.md"); - - // The doc's remoteRelativePath is owned by the wire loop, not the - // watcher path — a local rename does not move the server-side path. - assert.strictEqual(moved.remoteRelativePath, "a.md"); - }); - - it("LocalUpdate rename onto a tracked slot enqueues a delete for the displaced doc", async () => { - const queue = createQueue(); - await queue.upsertRecord(fakeRecord("A")); - await queue.upsertRecord(fakeRecord("B")); - - // User renames a.md onto b.md, clobbering b.md on disk. - await queue.enqueue({ - type: SyncEventType.LocalUpdate, - path: "b.md", - oldPath: "a.md" - }); - - // Doc A now lives at b.md. - const aRecord = queue.getDocumentByDocumentId("A"); - assert.strictEqual(aRecord?.localPath, "b.md"); - const slot = queue.getRecordByLocalPath("b.md" as RelativePath); - assert.strictEqual(slot?.documentId, "A"); - - // Doc B has no local file anymore (its bytes were overwritten). - const bRecord = queue.getDocumentByDocumentId("B"); - assert.strictEqual(bRecord?.localPath, undefined); - - // Two events should be queued: the LocalDelete for B, then the - // LocalUpdate for A (push order in `enqueue`). - assert.strictEqual(queue.pendingUpdateCount, 2); - - const first = await queue.next(); - assert.strictEqual(first?.type, SyncEventType.LocalDelete); - assert.strictEqual(first.documentId, "B"); - assert.strictEqual(first.path, "b.md"); - - const second = await queue.next(); - assert.strictEqual(second?.type, SyncEventType.LocalUpdate); - assert.strictEqual(second.documentId, "A"); - assert.strictEqual(second.path, "b.md"); - assert.strictEqual(second.isUserRename, true); - }); - - it("settled record owns a path over a stale pending create", async () => { - const queue = createQueue(); - await queue.upsertRecord(fakeRecord("A", { localPath: "b.md" })); - - await queue.enqueue({ type: SyncEventType.LocalCreate, path: "b.md" }); - await queue.enqueue({ - type: SyncEventType.LocalUpdate, - path: "c.md", - oldPath: "b.md" - }); - - const aRecord = queue.getDocumentByDocumentId("A"); - assert.strictEqual(aRecord?.localPath, "c.md"); - assert.strictEqual( - queue.getRecordByLocalPath("b.md" as RelativePath), - undefined - ); - assert.strictEqual( - queue.getRecordByLocalPath("c.md" as RelativePath)?.documentId, - "A" - ); - - const create = await queue.next(); - assert.strictEqual(create?.type, SyncEventType.LocalCreate); - assert.strictEqual(create.path, "b.md"); - - const update = await queue.next(); - assert.strictEqual(update?.type, SyncEventType.LocalUpdate); - assert.strictEqual(update.documentId, "A"); - assert.strictEqual(update.path, "c.md"); - }); - - it("byLocalPath stays consistent across upsertRecord, setLocalPath, and rename", async () => { - const queue = createQueue(); - - await queue.upsertRecord(fakeRecord("A")); - assert.strictEqual(queue.byLocalPath.size, 1); - assert.strictEqual( - queue.byLocalPath.get("a.md" as RelativePath)?.documentId, - "A" - ); - - // upsertRecord on an existing record with a non-undefined - // localPath does NOT rewrite localPath. The watcher path and the - // reconciler are the only authorities on localPath of an - // already-placed record; letting the wire loop re-key here would - // race a user rename that landed during an HTTP roundtrip. - await queue.upsertRecord( - fakeRecord("A", { localPath: "renamed.md" as RelativePath }) - ); - assert.strictEqual(queue.byLocalPath.size, 1); - assert.strictEqual( - queue.byLocalPath.get("a.md" as RelativePath)?.documentId, - "A" - ); - assert.strictEqual( - queue.byLocalPath.get("renamed.md" as RelativePath), - undefined - ); - assert.strictEqual(queue.getDocumentByDocumentId("A")?.localPath, "a.md"); - - // setLocalPath does re-key — it's the explicit path-mutation API. - await queue.setLocalPath("A", "later.md" as RelativePath); - assert.strictEqual(queue.byLocalPath.size, 1); - assert.strictEqual( - queue.byLocalPath.get("a.md" as RelativePath), - undefined - ); - assert.strictEqual( - queue.byLocalPath.get("later.md" as RelativePath)?.documentId, - "A" - ); - - // setLocalPath to undefined should drop the entry. - await queue.setLocalPath("A", undefined); - assert.strictEqual(queue.byLocalPath.size, 0); - assert.strictEqual( - queue.byLocalPath.get("later.md" as RelativePath), - undefined - ); - - // The record is still tracked by docId. - assert.strictEqual( - queue.getDocumentByDocumentId("A")?.localPath, - undefined - ); - }); - - it("upsertRecord installs localPath only when the existing record has none (placement-pending → placed)", async () => { - const queue = createQueue(); - - // Same-docId-collapse shape: a placement-pending record (created - // earlier by a remote-create handler when the slot was occupied) - // gets resolved by a LocalCreate that returns the same docId. - // The watcher hasn't touched localPath since the record is - // placement-pending, so installing the now-known path is correct. - await queue.upsertRecord(fakeRecord("A", { localPath: undefined })); - assert.strictEqual(queue.byLocalPath.size, 0); - - await queue.upsertRecord( - fakeRecord("A", { localPath: "fresh.md" as RelativePath }) - ); - assert.strictEqual(queue.byLocalPath.size, 1); - assert.strictEqual( - queue.byLocalPath.get("fresh.md" as RelativePath)?.documentId, - "A" - ); - assert.strictEqual( - queue.getDocumentByDocumentId("A")?.localPath, - "fresh.md" - ); - }); - - it("upsertRecord ignores stale localPath from the wire loop after a watcher rename", async () => { - const queue = createQueue(); - await queue.upsertRecord(fakeRecord("A")); - - // Watcher renames a.md -> renamed.md while the wire loop is - // mid-roundtrip. The wire loop captured an earlier snapshot of - // localPath and now tries to write it back through upsertRecord. - await queue.enqueue({ - type: SyncEventType.LocalUpdate, - path: "renamed.md", - oldPath: "a.md" - }); - assert.strictEqual( - queue.getDocumentByDocumentId("A")?.localPath, - "renamed.md" - ); - - await queue.upsertRecord( - fakeRecord("A", { - parentVersionId: 2, - remoteRelativePath: "a.md", - remoteHash: "hash-A-v2", - localPath: "a.md" as RelativePath - }) - ); - - // The watcher's rename wins: localPath stays at renamed.md. - const record = queue.getDocumentByDocumentId("A"); - assert.strictEqual(record?.localPath, "renamed.md"); - assert.strictEqual(record.parentVersionId, 2); - assert.strictEqual(record.remoteRelativePath, "a.md"); - assert.strictEqual(record.remoteHash, "hash-A-v2"); - assert.strictEqual( - queue.byLocalPath.get("renamed.md" as RelativePath)?.documentId, - "A" - ); - assert.strictEqual( - queue.byLocalPath.get("a.md" as RelativePath), - undefined - ); - }); - - it("create can be re-enqueued after being dequeued", async () => { - const queue = createQueue(); - await queue.enqueue({ type: SyncEventType.LocalCreate, path: "a.md" }); - await queue.next(); - - await queue.enqueue({ type: SyncEventType.LocalCreate, path: "a.md" }); - assert.strictEqual(queue.pendingUpdateCount, 1); - }); - - it("silently ignores create events matching ignore patterns", async () => { - const queue = createQueue(["*.tmp", ".hidden/**"]); - - await queue.enqueue({ - type: SyncEventType.LocalCreate, - path: "scratch.tmp" - }); - await queue.enqueue({ - type: SyncEventType.LocalCreate, - path: ".hidden/secret.md" - }); - assert.strictEqual(queue.pendingUpdateCount, 0); - - await queue.enqueue({ - type: SyncEventType.LocalCreate, - path: "notes-new.md" - }); - assert.strictEqual(queue.pendingUpdateCount, 1); - - await queue.enqueue({ - type: SyncEventType.RemoteChange, - remoteVersion: fakeRemoteVersion("N") - }); - assert.strictEqual(queue.pendingUpdateCount, 2); - }); - - it("addInternalIgnorePattern hides paths from enqueue and survives settings reload", async () => { - const harness = createHarness({ ignorePatterns: ["*.tmp"] }); - const { queue, settings } = harness; - - queue.addInternalIgnorePattern(".vaultlink/**"); - - await queue.enqueue({ - type: SyncEventType.LocalCreate, - path: ".vaultlink/swap" - }); - assert.strictEqual(queue.pendingUpdateCount, 0); - - // User-pattern matching still works alongside the internal pattern. - await queue.enqueue({ - type: SyncEventType.LocalCreate, - path: "scratch.tmp" - }); - assert.strictEqual(queue.pendingUpdateCount, 0); - - // Settings reload must not forget the internal pattern. - await settings.setSettings({ ignorePatterns: ["*.bak"] }); - - await queue.enqueue({ - type: SyncEventType.LocalCreate, - path: ".vaultlink/another" - }); - assert.strictEqual(queue.pendingUpdateCount, 0); - - // The new user pattern took effect. - await queue.enqueue({ - type: SyncEventType.LocalCreate, - path: "old.bak" - }); - assert.strictEqual(queue.pendingUpdateCount, 0); - - // And paths outside both pattern sets still pass through. - await queue.enqueue({ - type: SyncEventType.LocalCreate, - path: "notes.md" - }); - assert.strictEqual(queue.pendingUpdateCount, 1); - }); - - it("clearPending removes events but keeps documents", async () => { - const queue = createQueue(); - await queue.upsertRecord(fakeRecord("A")); - await queue.enqueue({ type: SyncEventType.LocalCreate, path: "b.md" }); - await queue.enqueue({ type: SyncEventType.LocalCreate, path: "c.md" }); - - assert.strictEqual(queue.pendingUpdateCount, 2); - - queue.clearPending(); - - assert.strictEqual(queue.pendingUpdateCount, 0); - assert.strictEqual(queue.syncedDocumentCount, 1); - assert.strictEqual( - queue.getRecordByLocalPath("a.md" as RelativePath)?.documentId, - "A" - ); - }); - - it("allSettledDocuments returns all tracked documents that have a localPath", async () => { - const queue = createQueue(); - await queue.upsertRecord(fakeRecord("A")); - await queue.upsertRecord(fakeRecord("B")); - // A doc with no local file (e.g. a remote create whose slot was - // occupied) should not appear in the localPath-keyed view. - await queue.upsertRecord(fakeRecord("C", { localPath: undefined })); - - const docs = queue.allSettledDocuments(); - assert.strictEqual(docs.size, 2); - const paths = Array.from(docs.keys()).sort(); - assert.deepStrictEqual(paths, ["a.md", "b.md"]); - }); - - it("loads initial state from persistence", () => { - const harness = createHarness({ - initialState: { - schemaVersion: STORED_STATE_SCHEMA_VERSION, - documents: [ - fakeRecord("A", { parentVersionId: 5 }), - fakeRecord("B", { parentVersionId: 3 }) - ], - lastSeenUpdateId: 4 - } - }); - const { queue } = harness; - - assert.strictEqual(queue.syncedDocumentCount, 2); - assert.strictEqual( - queue.getRecordByLocalPath("a.md" as RelativePath)?.documentId, - "A" - ); - assert.strictEqual( - queue.getRecordByLocalPath("b.md" as RelativePath)?.documentId, - "B" - ); - assert.strictEqual(queue.lastSeenUpdateId, 4); - }); - - it("constructor with mismatched schema version wipes state and saves the new version", () => { - const harness = createHarness({ - initialState: { - schemaVersion: 0, - documents: [fakeRecord("A"), fakeRecord("B")], - lastSeenUpdateId: 7 - } - }); - - // Persisted documents and watermark were discarded. - assert.strictEqual(harness.queue.syncedDocumentCount, 0); - assert.strictEqual(harness.queue.lastSeenUpdateId, 0); - - // The constructor scheduled a save (don't await — fire-and-forget), - // but we synchronously enqueued it so it should have landed by now. - // The recorded save uses the current schema version. - assert.ok(harness.saveCalls.length >= 1); - const last = harness.saveCalls[harness.saveCalls.length - 1]; - assert.strictEqual(last.schemaVersion, STORED_STATE_SCHEMA_VERSION); - assert.deepStrictEqual(last.documents, []); - assert.strictEqual(last.lastSeenUpdateId, 0); - }); - - it("constructor with missing schema version also wipes state", () => { - const harness = createHarness({ - initialState: { - documents: [fakeRecord("A")], - lastSeenUpdateId: 3 - } - }); - - assert.strictEqual(harness.queue.syncedDocumentCount, 0); - assert.strictEqual(harness.queue.lastSeenUpdateId, 0); - assert.ok(harness.saveCalls.length >= 1); - assert.strictEqual( - harness.saveCalls[harness.saveCalls.length - 1].schemaVersion, - STORED_STATE_SCHEMA_VERSION - ); - }); - - it("resolveCreate settles the document and resolves the create promise", async () => { - const queue = createQueue(); - - await queue.enqueue({ type: SyncEventType.LocalCreate, path: "a.md" }); - - const event = await queue.next(); // dequeue the create - assert.ok(event?.type === SyncEventType.LocalCreate); - const createPromise = event.resolvers.promise; - - await queue.resolveCreate( - event, - fakeRecord("DOC-1", { - parentVersionId: 5, - localPath: "a.md" as RelativePath, - remoteRelativePath: "a.md" as RelativePath - }) - ); - - // Document is now settled - assert.strictEqual( - queue.getRecordByLocalPath("a.md" as RelativePath)?.documentId, - "DOC-1" - ); - - // Promise was resolved - assert.strictEqual(await createPromise, "DOC-1"); - }); - - it("delete collapses a pending create that has not started processing", async () => { - const queue = createQueue(); - - await queue.enqueue({ type: SyncEventType.LocalCreate, path: "a.md" }); - const create = queue.peekFront(); - assert.ok(create?.type === SyncEventType.LocalCreate); - - await queue.enqueue({ type: SyncEventType.LocalDelete, path: "a.md" }); - - assert.strictEqual(queue.pendingUpdateCount, 0); - assert.strictEqual(await queue.next(), undefined); - await assert.rejects(create.resolvers.promise, /cancelled/); - }); - - it("resolveCreate does not claim a localPath after an in-flight pending create was deleted", async () => { - const queue = createQueue(); - - await queue.enqueue({ type: SyncEventType.LocalCreate, path: "a.md" }); - const create = queue.peekFront(); - assert.ok(create?.type === SyncEventType.LocalCreate); - create.isProcessing = true; - - await queue.enqueue({ type: SyncEventType.LocalDelete, path: "a.md" }); - - await queue.resolveCreate( - create, - fakeRecord("DOC-1", { - localPath: "a.md" as RelativePath, - remoteRelativePath: "a.md" as RelativePath - }) - ); - - assert.strictEqual( - queue.getDocumentByDocumentId("DOC-1")?.localPath, - undefined - ); - assert.strictEqual( - queue.getRecordByLocalPath("a.md" as RelativePath), - undefined - ); - - const deleteEvent = await queue.next(); - assert.strictEqual(deleteEvent?.type, SyncEventType.LocalDelete); - assert.strictEqual(deleteEvent.documentId, "DOC-1"); - }); - - it("resolveCreate only clears localPath for a pending delete of that path", async () => { - const queue = createQueue(); - - await queue.enqueue({ - type: SyncEventType.LocalCreate, - path: "old.md" - }); - const create = queue.peekFront(); - assert.ok(create?.type === SyncEventType.LocalCreate); - create.isProcessing = true; - - await queue.enqueue({ - type: SyncEventType.LocalDelete, - path: "old.md" - }); - - await queue.resolveCreate( - create, - fakeRecord("DOC-1", { - localPath: "new.md" as RelativePath, - remoteRelativePath: "new.md" as RelativePath - }) - ); - - assert.strictEqual( - queue.getDocumentByDocumentId("DOC-1")?.localPath, - "new.md" - ); - assert.strictEqual( - queue.getRecordByLocalPath("new.md" as RelativePath)?.documentId, - "DOC-1" - ); - - const deleteEvent = await queue.next(); - assert.strictEqual(deleteEvent?.type, SyncEventType.LocalDelete); - assert.strictEqual(deleteEvent.documentId, "DOC-1"); - assert.strictEqual(deleteEvent.path, "old.md"); - }); - - it("pending create owns a same-path delete over a stale deleting record", async () => { - const queue = createQueue(); - await queue.upsertRecord( - fakeRecord("OLD", { localPath: "a.md" as RelativePath }) - ); - queue.markServerDeletePending("OLD"); - - await queue.enqueue({ type: SyncEventType.LocalCreate, path: "a.md" }); - const create = queue.peekFront(); - assert.ok(create?.type === SyncEventType.LocalCreate); - create.isProcessing = true; - - await queue.enqueue({ type: SyncEventType.LocalDelete, path: "a.md" }); - - assert.strictEqual( - queue.getDocumentByDocumentId("OLD")?.localPath, - undefined - ); - assert.strictEqual( - queue.getRecordByLocalPath("a.md" as RelativePath), - undefined - ); - - const createEvent = await queue.next(); - assert.strictEqual(createEvent, create); - - const deleteEvent = await queue.next(); - assert.strictEqual(deleteEvent?.type, SyncEventType.LocalDelete); - assert.strictEqual(deleteEvent.documentId, create.resolvers.promise); - }); - - it("rename of a queued create drains same-path deletes first", async () => { - const queue = createQueue(); - await queue.upsertRecord( - fakeRecord("OLD", { localPath: "target.md" as RelativePath }) - ); - - await queue.enqueue({ - type: SyncEventType.LocalCreate, - path: "source.md" - }); - const create = queue.peekFront(); - assert.ok(create?.type === SyncEventType.LocalCreate); - - await queue.enqueue({ - type: SyncEventType.LocalDelete, - path: "target.md" - }); - await queue.enqueue({ - type: SyncEventType.LocalUpdate, - oldPath: "source.md", - path: "target.md" - }); - - const deleteEvent = await queue.next(); - assert.strictEqual(deleteEvent?.type, SyncEventType.LocalDelete); - assert.strictEqual(deleteEvent.documentId, "OLD"); - assert.strictEqual(deleteEvent.path, "target.md"); - - const createEvent = await queue.next(); - assert.strictEqual(createEvent, create); - assert.strictEqual(createEvent.path, "target.md"); - - const updateEvent = await queue.next(); - assert.strictEqual(updateEvent?.type, SyncEventType.LocalUpdate); - assert.strictEqual(updateEvent.documentId, create.resolvers.promise); - assert.strictEqual(updateEvent.path, "target.md"); - }); - - it("findLatestCreateForPath returns the pending create", async () => { - const queue = createQueue(); - - await queue.enqueue({ type: SyncEventType.LocalCreate, path: "a.md" }); - await queue.enqueue({ type: SyncEventType.LocalCreate, path: "b.md" }); - - const found = queue.findLatestCreateForPath("a.md" as RelativePath); - assert.ok(found !== undefined); - assert.strictEqual(found.path, "a.md"); - - const missing = queue.findLatestCreateForPath("c.md" as RelativePath); - assert.strictEqual(missing, undefined); - }); - - it("hasPendingEventsForPath reflects pending events", async () => { - const queue = createQueue(); - await queue.upsertRecord(fakeRecord("A")); - - assert.strictEqual( - queue.hasPendingEventsForPath("a.md" as RelativePath), - false - ); - - await queue.enqueue({ type: SyncEventType.LocalDelete, path: "a.md" }); - // After a delete the localPath is cleared; an unknown path is treated - // as "must be pending creation", so this still returns true. - assert.strictEqual( - queue.hasPendingEventsForPath("a.md" as RelativePath), - true - ); - }); - - it("setLocalPath displaces a previous holder of the same path", async () => { - const queue = createQueue(); - await queue.upsertRecord(fakeRecord("A")); - await queue.upsertRecord( - fakeRecord("B", { localPath: "b.md" as RelativePath }) - ); - - // Move B onto a.md — the slot already held by A. The invariant - // requires A's localPath to be cleared (placement-pending), - // and byLocalPath["a.md"] === B. - await queue.setLocalPath("B", "a.md" as RelativePath); - - const a = queue.getDocumentByDocumentId("A"); - const b = queue.getDocumentByDocumentId("B"); - assert.strictEqual(a?.localPath, undefined); - assert.strictEqual(b?.localPath, "a.md"); - assert.strictEqual( - queue.getRecordByLocalPath("a.md" as RelativePath)?.documentId, - "B" - ); - // B's old slot is now empty — nothing else moved into it. - assert.strictEqual( - queue.getRecordByLocalPath("b.md" as RelativePath), - undefined - ); - }); - - it("upsertRecord displaces a previous holder of the same path", async () => { - const queue = createQueue(); - await queue.upsertRecord(fakeRecord("A")); - - // A new record (different docId) claims a.md. The prior holder - // (A) must be displaced — its localPath cleared, and - // byLocalPath["a.md"] now points at the new record. - await queue.upsertRecord( - fakeRecord("B", { localPath: "a.md" as RelativePath }) - ); - - const a = queue.getDocumentByDocumentId("A"); - const b = queue.getDocumentByDocumentId("B"); - assert.strictEqual(a?.localPath, undefined); - assert.strictEqual(b?.localPath, "a.md"); - assert.strictEqual( - queue.getRecordByLocalPath("a.md" as RelativePath)?.documentId, - "B" - ); - }); - - it("the localPath/byLocalPath invariant holds across rename + recreate cycles", async () => { - // Construct the exact same-path create cycle that produces the - // bug-D race: docA at P, then docB created at P (via - // upsertRecord), and finally a setLocalPath that would move a - // third doc onto P. The invariant must hold at every step: - // exactly one record has localPath===P at any given time, and - // byLocalPath.get(P) returns it. - const queue = createQueue(); - - const path = "p.md" as RelativePath; - - await queue.upsertRecord( - fakeRecord("A", { localPath: path, remoteRelativePath: path }) - ); - - // Sanity: A holds the slot. - assert.strictEqual(queue.getRecordByLocalPath(path)?.documentId, "A"); - assert.strictEqual(queue.getDocumentByDocumentId("A")?.localPath, path); - - // docB created at P via upsertRecord (e.g. a remote create - // that races A's local file onto the same slot). A must be - // displaced. - await queue.upsertRecord( - fakeRecord("B", { localPath: path, remoteRelativePath: path }) - ); - assert.strictEqual( - queue.getDocumentByDocumentId("A")?.localPath, - undefined - ); - assert.strictEqual(queue.getDocumentByDocumentId("B")?.localPath, path); - assert.strictEqual(queue.getRecordByLocalPath(path)?.documentId, "B"); - - // Now setLocalPath moves a third doc C onto P. B must in turn - // be displaced; the invariant still holds. - await queue.upsertRecord( - fakeRecord("C", { localPath: "c.md" as RelativePath }) - ); - await queue.setLocalPath("C", path); - assert.strictEqual( - queue.getDocumentByDocumentId("B")?.localPath, - undefined - ); - assert.strictEqual(queue.getDocumentByDocumentId("C")?.localPath, path); - assert.strictEqual(queue.getRecordByLocalPath(path)?.documentId, "C"); - - // Across the whole cycle exactly one record holds the slot. - const holders = Array.from(queue.allRecords()).filter( - (r) => r.localPath === path - ); - assert.strictEqual(holders.length, 1); - assert.strictEqual(holders[0].documentId, "C"); - }); - - it("clearAllState clears everything", async () => { - const queue = createQueue(); - await queue.upsertRecord(fakeRecord("A")); - await queue.enqueue({ type: SyncEventType.LocalCreate, path: "b.md" }); - - await queue.clearAllState(); - - assert.strictEqual(queue.syncedDocumentCount, 0); - assert.strictEqual(queue.pendingUpdateCount, 0); - assert.strictEqual(queue.byLocalPath.size, 0); - }); -}); diff --git a/frontend/sync-client/src/sync-operations/sync-event-queue.ts b/frontend/sync-client/src/sync-operations/sync-event-queue.ts deleted file mode 100644 index 75f675d0..00000000 --- a/frontend/sync-client/src/sync-operations/sync-event-queue.ts +++ /dev/null @@ -1,1000 +0,0 @@ -import type { Settings } from "../persistence/settings"; -import type { Logger } from "../tracing/logger"; -import { globsToRegexes } from "../utils/globs-to-regexes"; -import { removeFromArray } from "../utils/remove-from-array"; -import { EventListeners } from "../utils/data-structures/event-listeners"; -import { - SyncEventType, - type DocumentId, - type DocumentRecord, - type FileSyncEvent, - type RelativePath, - type StoredSyncState, - type SyncEvent, - type VaultUpdateId -} from "./types"; -import { MinCovered } from "../utils/data-structures/min-covered"; - -export const STORED_STATE_SCHEMA_VERSION = 2; - -export class SyncEventQueue { - // Fires synchronously whenever the events array length changes (push, pop, - // remove, bulk-clear). The Syncer mirrors this into its public count - // listener; without this hook, listeners only saw deltas at consume time - // and missed the "queue grew" / "queue cleared on reset" transitions. - public readonly onPendingUpdateCountChanged = new EventListeners< - (count: number) => unknown - >(); - - // Fires whenever a record's `localPath` transitions to a different - // value. Subscribers see every disk-side path change — watcher- - // driven user renames, post-create deconflicts placed by the - // reconciler, lost-rename replays in offline-scan, displacements - // when another record claims a slot. Useful for callers that - // mirror disk-side state (e.g. test harnesses that maintain a - // "do-not-touch" list keyed by current path). Both `oldPath` and - // `newPath` may be `undefined` (placement-pending state). - public readonly onDocumentPathChanged = new EventListeners< - ( - documentId: DocumentId, - oldPath: RelativePath | undefined, - newPath: RelativePath | undefined - ) => unknown - >(); - - private readonly _lastSeenUpdateId: MinCovered; - - // Primary index of every settled document, keyed by docId. The wire loop - // (records ↔ server) updates `remoteRelativePath` here as the server - // assigns/relocates a doc; the Reconciler (records ↔ disk) updates - // `localPath` here as it places files on disk. - private readonly byDocId = new Map(); - - // Derived index from `localPath -> record`. Maintained alongside every - // mutation that touches `localPath` so callers (the watcher path through - // `enqueue`, the Reconciler) get O(1) lookups by disk location. Only - // contains records whose `localPath !== undefined`. - private readonly _byLocalPath = new Map(); - - // All outstanding operations in order of occurrence, - // can include multiple generations of the same document, - // e.g.: a create, delete, create sequence for the same path. - // - // The paths within the events must always correspond to the latest - // path on disk, so the path of each event may be updated multiple - // times. - // - // It maps pending changes onto the local filesystem. - private readonly events: SyncEvent[] = []; - - // file creations for paths matching any of these patterns are ignored - // because the user explicitly told us to ignore them. - private userIgnorePatterns: RegExp[]; - - // Hard-coded ignores that callers (e.g. the Syncer for `.vaultlink/**` - // swap-marker files) pin via `addInternalIgnorePattern`. Folded into - // `userIgnorePatterns` so the existing match path doesn't need to know - // about two arrays. Stored separately so a later `onSettingsChanged` - // event that re-derives `userIgnorePatterns` from settings doesn't - // forget the internal patterns. - private readonly internalIgnorePatterns: RegExp[] = []; - - // DocIds whose HTTP DELETE has been acked by the server but whose - // WebSocket-receipt-driven `removeDocumentById` hasn't run yet (the - // record is still in `byDocId` because the wire loop keeps it around to - // recognise late remote updates as "file is missing"). The Reconciler - // and the remote-update wire-loop handlers consult this set to skip any - // work that would resurrect the doc — without it, a placement-pending - // record (`localPath === undefined` after the LocalDelete enqueue) would - // be re-fetched from the server and written back to disk, or a late - // RemoteChange for the same doc would stash the pre-delete bytes into - // `pendingPlacementContent` for the Reconciler to "place". - // - // Cleared as a side effect of `removeDocumentById`. Also cleared on - // `clearAllState` / schema-version-mismatch reset. - private readonly _pendingServerDeletes = new Set(); - - public constructor( - private readonly settings: Settings, - private readonly logger: Logger, - initialState: Partial | undefined, - private readonly saveData: (data: StoredSyncState) => Promise - ) { - this.userIgnorePatterns = globsToRegexes( - this.settings.getSettings().ignorePatterns, - this.logger - ); - - this.settings.onSettingsChanged.add((newSettings) => { - this.userIgnorePatterns = [ - ...globsToRegexes(newSettings.ignorePatterns, this.logger), - ...this.internalIgnorePatterns - ]; - }); - - initialState ??= {}; - - const persistedSchemaVersion = initialState.schemaVersion; - if (persistedSchemaVersion !== STORED_STATE_SCHEMA_VERSION) { - this.logger.info( - `Persisted state schema version is ${persistedSchemaVersion ?? "unset"}, expected ${STORED_STATE_SCHEMA_VERSION}; discarding persisted documents and watermark so the offline scan re-derives state from disk` - ); - initialState = {}; - // Schedule a save so the new schema version sticks even if the user - // never makes a change. Don't await here (constructor is sync); the - // first real save in `save()` will pin it down anyway. - void this.saveData({ - schemaVersion: STORED_STATE_SCHEMA_VERSION, - documents: [], - lastSeenUpdateId: 0 - }); - } - - if (initialState.documents !== undefined) { - for (const record of initialState.documents) { - this.byDocId.set(record.documentId, record); - if (record.localPath !== undefined) { - // Defensive: if two persisted records share the same - // localPath (shouldn't happen given the invariant - // enforced at every mutation point, but persisted - // state from older buggy versions could violate it), - // displace the prior holder so we don't end up with - // a shadowed record on load. - const displaced = this._byLocalPath.get(record.localPath); - if (displaced !== undefined && displaced !== record) { - displaced.localPath = undefined; - this.logger.warn( - `Persisted state had two records sharing localPath ` + - `${record.localPath} (${displaced.documentId} and ` + - `${record.documentId}); clearing the prior holder's ` + - `localPath so the reconciler re-places it` - ); - } - this._byLocalPath.set(record.localPath, record); - } - } - } - this._lastSeenUpdateId = new MinCovered( - initialState.lastSeenUpdateId ?? 0 - ); - - this.logger.debug( - `Loaded ${this.byDocId.size} documents and lastSeenUpdateId=${this._lastSeenUpdateId.min} from storage` - ); - } - - public get pendingUpdateCount(): number { - return this.events.length; - } - - public get syncedDocumentCount(): number { - return this.byDocId.size; - } - - /** - * Read-only view of the `localPath -> record` index. Use for O(1) lookups - * by disk location; the index is maintained by every mutation that - * touches `localPath` (`upsertRecord`, `setLocalPath`, the rename branch - * of `enqueue`, `removeDocumentById`). - */ - public get byLocalPath(): ReadonlyMap { - return this._byLocalPath; - } - - public get lastSeenUpdateId(): VaultUpdateId { - return this._lastSeenUpdateId.min; - } - - public set lastSeenUpdateId(id: VaultUpdateId) { - this._lastSeenUpdateId.add(id); - } - - /** - * Watermark to send with our own `POST /documents` requests. - * - * The contiguous-prefix `lastSeenUpdateId` lags behind reality whenever - * there are gaps in the vuid stream we've observed: if the server has - * committed vuids 1..N from various clients but we've only processed - * a non-contiguous subset, `min` stays at the last hole. The server's - * create handler reads this watermark to decide whether to merge a - * new POST into an existing doc at the same path: - * - * creation_vault_update_id > last_seen_vault_update_id → merge - * - * That check is meant to fire only for docs the client genuinely - * couldn't have known about. But on a same-device "rename a - * pending-create away then create something else at that path" race, - * the second POST went out with `last_seen = min` while we already - * held a record for the first create at vuid=N — and the server - * happily merged the second create into our own doc, aliasing two - * physically distinct local files onto a single docId. - * - * The fix is path-scoped: if we already track a doc whose - * `remoteRelativePath` matches the path we're about to POST, the - * server's existing doc at that path is exactly the one we'd alias - * into. Bumping `last_seen` to that record's `parentVersionId` - * forces the server's `creation_vuid > last_seen` check to fail and - * fall through to the deconflict path. For paths we don't yet - * track, we send the regular `min` watermark — so a legitimate - * cross-device merge (two clients independently creating the same - * path) still fires when neither side holds a record for the - * collision target. - */ - public lastSeenUpdateIdForCreate( - requestPath: RelativePath - ): VaultUpdateId { - let watermark = this._lastSeenUpdateId.min; - for (const record of this.byDocId.values()) { - if ( - record.remoteRelativePath === requestPath && - record.parentVersionId > watermark - ) { - watermark = record.parentVersionId; - } - } - return watermark; - } - - /** - * Pin an additional ignore pattern that survives setting reloads. Used - * by the Syncer to hide internal scratch paths (e.g. `.vaultlink/**` - * swap markers written by the Reconciler) from the watcher-driven - * enqueue path. The pattern is compiled with the same `globsToRegexes` - * used for user-configurable ignores; matching uses the existing - * userIgnorePatterns array so there's only one match path. - */ - public addInternalIgnorePattern(pattern: string): void { - const compiled = globsToRegexes([pattern], this.logger); - this.internalIgnorePatterns.push(...compiled); - this.userIgnorePatterns.push(...compiled); - } - - public async enqueue(input: FileSyncEvent): Promise { - const path = - input.type === SyncEventType.RemoteChange - ? input.remoteVersion.relativePath - : input.path; - - if (this.userIgnorePatterns.some((pattern) => pattern.test(path))) { - this.logger.info( - `Ignoring ${input.type} for ${path} as it matches ignore patterns` - ); - return; - } - - if (input.type === SyncEventType.RemoteChange) { - this.events.push(input); - this.notifyPendingUpdateCountChanged(); - return; - } - - if (input.type === SyncEventType.LocalCreate) { - this.events.push({ - type: SyncEventType.LocalCreate, - path, - isProcessing: false, - resolvers: Promise.withResolvers() - }); - this.notifyPendingUpdateCountChanged(); - return; - } - - const lookupPath = - input.type === SyncEventType.LocalUpdate && - input.oldPath !== undefined - ? input.oldPath - : path; - const record = this._byLocalPath.get(lookupPath); - - // If a settled record and a pending create both claim this path, the - // settled record owns the current disk slot, unless the record is - // already being deleted. A deleting record can briefly remain in the - // localPath index when a create/delete pair was queued while the - // create was pending; it must not steal the next same-path create's - // delete/update. - const pendingCreate = this.findLatestCreateForPath(lookupPath); - const pendingDocumentId: Promise | undefined = - pendingCreate?.resolvers.promise; - - const recordIsDeleting = - record !== undefined && - (this.hasPendingLocalDeleteForDocumentId(record.documentId) || - this.hasPendingServerDelete(record.documentId)); - const recordOwnsLookupPath = - record !== undefined && - !(recordIsDeleting && pendingDocumentId !== undefined); - - const documentId: DocumentId | undefined = recordOwnsLookupPath - ? record.documentId - : undefined; - - const effectiveDocumentId: - | Promise - | DocumentId - | undefined = documentId ?? pendingDocumentId; - if (effectiveDocumentId === undefined) { - // we can get here when deleting a local document after a remote update - return; - } - - if (input.type === SyncEventType.LocalDelete) { - if ( - documentId === undefined && - pendingCreate !== undefined && - !pendingCreate.isProcessing - ) { - this.cancelPendingCreate(pendingCreate); - if (recordIsDeleting && record !== undefined) { - // A stale deleting record was still claiming this path. - // The not-yet-started create/delete pair collapsed to - // nothing, and the disk file is gone, so clear the stale - // claim too. - await this.setLocalPath(record.documentId, undefined); - } - return; - } - - // Push BEFORE awaiting `setLocalPath` (and its inner `save()`). - // See the comment below on the synchronicity contract with - // `ensureDraining()`. - this.events.push({ - type: SyncEventType.LocalDelete, - documentId: effectiveDocumentId, - path: lookupPath - }); - this.notifyPendingUpdateCountChanged(); - if (recordOwnsLookupPath && record !== undefined) { - // The file is gone from disk; clear the doc's localPath so the - // Reconciler doesn't try to operate on a vacated slot. - await this.setLocalPath(record.documentId, undefined); - } else if (recordIsDeleting && record !== undefined) { - // A stale deleting record was still claiming this path while a - // newer pending create owned the actual disk file. Drop the - // stale claim now that the file is gone. - await this.setLocalPath(record.documentId, undefined); - } - return; - } - - const isUserRename = input.oldPath !== undefined; - let needsSave = false; - if (input.oldPath !== undefined) { - if (!recordOwnsLookupPath && pendingDocumentId !== undefined) { - this.updatePendingCreatePath(input.oldPath, path); - } else { - if (record === undefined || !recordOwnsLookupPath) { - throw new Error( - "Unreachable: record must be defined for non-pending update" - ); - } - // The user renamed `oldPath` onto `path`. If `path` was - // already tracked by a *different* doc (the OS rename - // overwrote that file), that doc effectively no longer - // exists locally — its content was clobbered. Without - // explicitly recording the loss the doc would silently - // drop out of the byLocalPath index below and we'd skip - // notifying the server, leaving a phantom on the remote - // that other agents still see. Enqueue a LocalDelete for - // it so the server learns about the deletion. - const displacedRecord = this._byLocalPath.get(path); - if ( - displacedRecord !== undefined && - displacedRecord.documentId !== record.documentId - ) { - this.events.push({ - type: SyncEventType.LocalDelete, - documentId: displacedRecord.documentId, - // Snapshot the path; once we move `record` onto - // `path` below the displaced doc will no longer - // resolve via `byLocalPath`. - path - }); - // Drop the displaced doc's localPath: its file on - // disk is gone (overwritten by the rename). - // Mutate synchronously so the byLocalPath index is - // correct before we move `record` onto the same - // slot below; the persist runs in the trailing - // `save()` so we don't await before pushing the - // LocalUpdate (synchronicity contract). - this.mutateLocalPathInPlace(displacedRecord, undefined); - needsSave = true; - } - // Move record's localPath onto the new slot. We mutate - // the record in place rather than re-creating it so any - // held reference (drain handlers, queued events) sees - // the new path on its next read. - this.mutateLocalPathInPlace(record, path); - // Retarget any queued LocalUpdates for this doc onto - // the new path. The queue's invariant — and what - // `skipIfOversized` and the watcher dedup checks bake - // in — is that `event.path` always points at the doc's - // current disk location. - for (const e of this.events) { - if ( - e.type === SyncEventType.LocalUpdate && - e.documentId === record.documentId - ) { - e.path = path; - } - } - needsSave = true; - } - } - - // Push BEFORE awaiting `save()`. The synchronicity contract is: - // `Syncer.ensureDraining()` runs immediately after each `enqueue`, - // and the drain only sees what's in `events[]`. Pushing after an - // await would let the drain start, see an empty queue, exit, and - // leave the event stranded. - this.events.push({ - type: SyncEventType.LocalUpdate, - documentId: effectiveDocumentId, - path, - originalPath: path, - isUserRename - }); - this.notifyPendingUpdateCountChanged(); - - if (needsSave) { - await this.save(); - } - } - - public async next(): Promise { - const event = this.events.shift(); - if (event !== undefined) { - this.notifyPendingUpdateCountChanged(); - } - return event; - } - - /** - * Return the next event without removing it. Drain uses this so the - * event stays visible in the queue while it is being processed — - * critical for `findLatestCreateForPath` to update an in-flight - * `LocalCreate`'s local read path when a rename arrives mid-process. - */ - public peekFront(): SyncEvent | undefined { - return this.events[0]; - } - - /** - * Remove a specific event after `peekFront`-based processing is done. - * Idempotent — safe to call when the event was already taken out by - * `resolveCreate` (which clears a same-path pending create that a - * remote-create handler just absorbed). - */ - public consumeEvent(event: SyncEvent): void { - if (removeFromArray(this.events, event)) { - this.notifyPendingUpdateCountChanged(); - } - } - - /** - * Call once a create has been acknowledged by the server. - * - * Queued `LocalUpdate` / `LocalDelete` events that were pushed while - * this create was still in-flight carry the create's `resolvers.promise` - * as their `documentId` (see the `pendingDocumentId` branch of - * `enqueue`). We must rewrite those references to the resolved string - * id *before* calling `upsertRecord`, otherwise its event-rewrite loop - * (which compares `e.documentId === record.documentId`) would silently - * skip them — leaving their `event.path` pointing at the pre-rename - * slot and causing the next drain step's `getFileSize(event.path)` to - * throw `FileNotFoundError`, dropping the user's intent. - */ - public async resolveCreate( - event: Extract, - record: DocumentRecord - ): Promise { - if (removeFromArray(this.events, event)) { - this.notifyPendingUpdateCountChanged(); - } - this.replacePendingDocumentId( - event.resolvers.promise, - record.documentId - ); - const localPath = this.hasPendingLocalDeleteForDocumentId( - record.documentId, - record.localPath - ) - ? undefined - : record.localPath; - await this.upsertRecord({ ...record, localPath }); - event.resolvers.resolve(record.documentId); - } - - /** - * Swap a pending create's `Promise` reference for the - * resolved string id across every queued `LocalUpdate` / `LocalDelete`. - * Call this whenever a create resolves (regular ack OR - * displacement-merge into an existing doc) — see `resolveCreate` for - * the failure mode if it's skipped. - */ - public replacePendingDocumentId( - promise: Promise, - documentId: DocumentId - ): void { - for (const e of this.events) { - if ( - (e.type === SyncEventType.LocalUpdate || - e.type === SyncEventType.LocalDelete) && - e.documentId === promise - ) { - e.documentId = documentId; - } - } - } - - /** - * Insert or merge a document record by `documentId`. When a record with - * the same docId already exists it is mutated in place so any held - * references (drain handlers, queued events) keep seeing the up-to-date - * fields on their next read — this stays load-bearing for the Syncer's - * drain handlers, which await across HTTP roundtrips. - * - * For an existing record this updates the wire fields - * (`parentVersionId`, `remoteHash`, `remoteRelativePath`) and, only - * when the existing record has no local file yet - * (`localPath === undefined`), installs the supplied `localPath`. A - * non-undefined existing localPath is owned by the watcher path and - * the Reconciler — overwriting it from the wire loop would race a - * user rename that landed during an HTTP roundtrip and silently - * resurrect a stale slot. - */ - public async upsertRecord(record: DocumentRecord): Promise { - const existing = this.byDocId.get(record.documentId); - if (existing === undefined) { - const target: DocumentRecord = { ...record }; - this.byDocId.set(record.documentId, target); - if (target.localPath !== undefined) { - // Route through `mutateLocalPathInPlace` so the - // localPath/byLocalPath invariant is upheld: if another - // record already holds this slot, displace it (clear - // its localPath) before installing `target`. Otherwise - // we'd leave the displaced record shadowed (its - // `localPath` still points at a slot that no longer - // belongs to it), which the Reconciler would then - // "rescue" by reading/renaming the file at that path - // — but that file belongs to `target` now, causing - // data loss. - target.localPath = undefined; - this.mutateLocalPathInPlace(target, record.localPath); - } - } else { - existing.parentVersionId = record.parentVersionId; - existing.remoteHash = record.remoteHash; - existing.remoteRelativePath = record.remoteRelativePath; - if ( - existing.localPath === undefined && - record.localPath !== undefined - ) { - return this.setLocalPath(record.documentId, record.localPath); - } - } - return this.save(); - } - - /** - * Update the `localPath` of an already-tracked record (by docId) and - * re-key the `byLocalPath` index. Called by both the watcher path - * (through `enqueue`) and the Reconciler. - * - * Pass `undefined` to mark the doc as "no local file" — the Reconciler - * will place a file later (e.g. a remote create whose - * `remoteRelativePath` slot is occupied at receive time). - */ - public async setLocalPath( - documentId: DocumentId, - newLocalPath: RelativePath | undefined - ): Promise { - const record = this.byDocId.get(documentId); - if (record === undefined) { - return; - } - this.mutateLocalPathInPlace(record, newLocalPath); - return this.save(); - } - - public async removeDocumentById(documentId: DocumentId): Promise { - const record = this.byDocId.get(documentId); - if (record === undefined) { - // Still clear any deletion-pending mark and purge stale - // RemoteChange events so a never-tracked doc doesn't accumulate - // entries. - this._pendingServerDeletes.delete(documentId); - this.purgeRemoteChangesForDocumentId(documentId); - return; - } - if ( - record.localPath !== undefined && - this._byLocalPath.get(record.localPath) === record - ) { - this._byLocalPath.delete(record.localPath); - } - this.byDocId.delete(documentId); - this._pendingServerDeletes.delete(documentId); - // Drop any pending RemoteChange events for this doc. A common case: - // a catch-up RemoteChange for the doc was deferred indefinitely - // while the user's LocalDelete (and any LocalUpdate behind it) sat - // in the queue ahead of it. Once those drain and the doc is - // removed, a still-pending RemoteChange for an earlier version - // would be processed by `processRemoteCreateForNewDocument` (the - // doc is now untracked, and catch-up's `isNewFile=true` semantics - // qualify it as a fresh create), resurrecting the doc on disk - // with stale bytes that disagree with every other agent. - this.purgeRemoteChangesForDocumentId(documentId); - return this.save(); - } - - /** - * Mark a doc as "HTTP DELETE has been acked by the server but the - * WebSocket receipt that would call `removeDocumentById` hasn't arrived - * yet". The Reconciler and remote-update wire-loop handlers consult - * `hasPendingServerDelete` to skip any work that would resurrect the - * doc. Cleared automatically by `removeDocumentById`. - */ - public markServerDeletePending(documentId: DocumentId): void { - this._pendingServerDeletes.add(documentId); - } - - public hasPendingServerDelete(documentId: DocumentId): boolean { - return this._pendingServerDeletes.has(documentId); - } - - public getDocumentByDocumentId( - target: DocumentId - ): DocumentRecord | undefined { - return this.byDocId.get(target); - } - - public getDocumentByDocumentIdOrFail(target: DocumentId): DocumentRecord { - const result = this.getDocumentByDocumentId(target); - if (!result) { - throw new Error(`No document found with id ${target}`); - } - return result; - } - - public getRecordByLocalPath( - path: RelativePath - ): DocumentRecord | undefined { - return this._byLocalPath.get(path); - } - - public async save(): Promise { - return this.saveData({ - schemaVersion: STORED_STATE_SCHEMA_VERSION, - documents: Array.from(this.byDocId.values()), - lastSeenUpdateId: this.lastSeenUpdateId - }); - } - - public allSettledDocuments(): Map { - const result = new Map(); - for (const record of this.byDocId.values()) { - if (record.localPath !== undefined) { - result.set(record.localPath, record); - } - } - return result; - } - - /** - * Every tracked record, regardless of whether it has been placed on - * disk yet. The Reconciler uses this to find records whose - * `localPath === undefined` (e.g. a remote create that landed when - * its target slot was occupied) and try to place them once the - * obstruction clears. `allSettledDocuments` filters those out, so - * relying on it would render placement-pending records invisible - * forever. - */ - public allRecords(): Iterable { - return this.byDocId.values(); - } - - public hasPendingEventsForPath(path: RelativePath): boolean { - const record = this._byLocalPath.get(path); - if (record === undefined) { - return true; // if we don't know about this path, it must be pending creation - } - const docId = record.documentId; - return this.events.some( - (e) => - (e.type === SyncEventType.LocalCreate && e.path === path) || - (e.type === SyncEventType.LocalUpdate && - e.documentId === docId) || - (e.type === SyncEventType.LocalDelete && - e.documentId === docId) || - (e.type === SyncEventType.RemoteChange && - // we care about the local path not the remote - this.getDocumentByDocumentId(e.remoteVersion.documentId) - ?.localPath === path) - ); - } - - public hasPendingLocalEventsForDocumentId(documentId: DocumentId): boolean { - return this.events.some( - (e) => - (e.type === SyncEventType.LocalUpdate && - e.documentId === documentId) || - (e.type === SyncEventType.LocalDelete && - e.documentId === documentId) - ); - } - - public hasPendingLocalDeleteForDocumentId( - documentId: DocumentId, - path?: RelativePath - ): boolean { - return this.events.some( - (e) => - e.type === SyncEventType.LocalDelete && - e.documentId === documentId && - (path === undefined || e.path === path) - ); - } - - public async clearAllState(): Promise { - this.clearPending(); - this.byDocId.clear(); - this._byLocalPath.clear(); - this._pendingServerDeletes.clear(); - this._lastSeenUpdateId.reset(); - await this.save(); - } - - public clearPending(): void { - const hadEvents = this.events.length > 0; - this.rejectAllPendingCreates(); - this.events.length = 0; - if (hadEvents) { - this.notifyPendingUpdateCountChanged(); - } - } - - public findLatestCreateForPath( - path: RelativePath - ): Extract | undefined { - for (let i = this.events.length - 1; i >= 0; i--) { - const e = this.events[i]; - if (e.type === SyncEventType.LocalCreate && e.path === path) { - return e; - } - } - return undefined; - } - - public hasPendingCreateForPath(path: RelativePath): boolean { - return this.events.some( - (e) => e.type === SyncEventType.LocalCreate && e.path === path - ); - } - - public updatePendingCreatePath( - oldPath: RelativePath, - newPath: RelativePath - ): void { - const createEvent = this.findLatestCreateForPath(oldPath); - if (createEvent === undefined) { - return; - } - - const { promise } = createEvent.resolvers; - createEvent.path = newPath; - if (!createEvent.isProcessing) { - this.moveBlockingDeletesBeforeCreate(createEvent, newPath); - this.moveBlockingRenamesBeforeCreate(createEvent, newPath); - } - - for (const e of this.events) { - if ( - e.type === SyncEventType.LocalUpdate && - e.documentId === promise - ) { - e.path = newPath; - } - } - } - - private moveBlockingDeletesBeforeCreate( - createEvent: Extract, - path: RelativePath - ): void { - const { promise } = createEvent.resolvers; - let createIndex = this.events.indexOf(createEvent); - if (createIndex < 0) { - return; - } - - for (let i = createIndex + 1; i < this.events.length; ) { - const event = this.events[i]; - if ( - event.type === SyncEventType.LocalDelete && - event.path === path && - event.documentId !== promise - ) { - this.events.splice(i, 1); - this.events.splice(createIndex, 0, event); - createIndex++; - continue; - } - i++; - } - } - - /** - * The `path` argument is the create's just-retargeted target. Any - * other tracked doc whose server-side path is still `path` (its - * watcher-driven local rename hasn't reached the server yet) needs - * its pending LocalUpdate to drain *before* this create — otherwise - * the create's HTTP request hits the server while the doc is still - * at `path` and triggers a same-path same-docId merge that - * silently consumes the user's "new doc" intent into the - * already-tracked doc. The pending LocalUpdate is the rename that - * moves the existing doc off `path` server-side; running it first - * frees the slot. Skipped when the create has already been sent — - * at that point the merge has already happened or hasn't, and - * reordering the queue can't unwind it. - */ - private moveBlockingRenamesBeforeCreate( - createEvent: Extract, - path: RelativePath - ): void { - const blockingDocIds = new Set(); - for (const record of this.byDocId.values()) { - if ( - record.remoteRelativePath === path && - record.localPath !== path - ) { - blockingDocIds.add(record.documentId); - } - } - if (blockingDocIds.size === 0) { - return; - } - - let createIndex = this.events.indexOf(createEvent); - if (createIndex < 0) { - return; - } - - for (let i = createIndex + 1; i < this.events.length; ) { - const event = this.events[i]; - if ( - event.type === SyncEventType.LocalUpdate && - typeof event.documentId === "string" && - blockingDocIds.has(event.documentId) - ) { - this.events.splice(i, 1); - this.events.splice(createIndex, 0, event); - createIndex++; - continue; - } - i++; - } - } - - /** - * Synchronous half of `setLocalPath`: mutate `record.localPath` and - * re-key `_byLocalPath` without persisting. Used by `enqueue`'s - * rename branch where the synchronicity contract requires we push - * the LocalUpdate event before awaiting the save. - * - * Enforces the invariant - * `record.localPath !== undefined ⇒ byLocalPath.get(record.localPath) === record`. - * If `newLocalPath` is currently held by a different record, that - * record is *displaced*: its `localPath` is cleared so it enters - * placement-pending state, and the Reconciler's next pass will - * re-place it via `tryInitialPlacement`. Without this displacement - * the prior holder would remain shadowed (its `localPath === P` - * but `byLocalPath[P]` points elsewhere) and the Reconciler could - * later try to "rescue" the shadowed record by reading/renaming - * the file at `P` — which belongs to the new owner now — causing - * data loss. This is the architectural fix for bug D - * (`Files from agent-1 missing in agent-0` after a same-path - * create cycle). - */ - private mutateLocalPathInPlace( - record: DocumentRecord, - newLocalPath: RelativePath | undefined - ): void { - const previousLocalPath = record.localPath; - if ( - previousLocalPath !== undefined && - this._byLocalPath.get(previousLocalPath) === record - ) { - this._byLocalPath.delete(previousLocalPath); - } - record.localPath = newLocalPath; - let displacedRecord: DocumentRecord | undefined; - let displacedOldPath: RelativePath | undefined; - if (newLocalPath !== undefined) { - const displaced = this._byLocalPath.get(newLocalPath); - if (displaced !== undefined && displaced !== record) { - // Invariant: `byLocalPath[displaced.localPath] === displaced`. - // We're about to overwrite that slot, so clear the - // displaced record's localPath; the reconciler will - // re-place it via tryInitialPlacement on the next pass. - displacedOldPath = displaced.localPath; - displaced.localPath = undefined; - displacedRecord = displaced; - } - this._byLocalPath.set(newLocalPath, record); - } - if (previousLocalPath !== newLocalPath) { - this.onDocumentPathChanged.trigger( - record.documentId, - previousLocalPath, - newLocalPath - ); - } - if (displacedRecord !== undefined) { - this.onDocumentPathChanged.trigger( - displacedRecord.documentId, - displacedOldPath, - undefined - ); - } - } - - private notifyPendingUpdateCountChanged(): void { - this.onPendingUpdateCountChanged.trigger(this.events.length); - } - - private rejectAllPendingCreates(): void { - for (const event of this.events) { - if (event.type === SyncEventType.LocalCreate) { - event.resolvers.promise.catch(() => { - /* suppressed — consumer may not be listening */ - }); - event.resolvers.reject(new Error("Create was cancelled")); - } - } - } - - private cancelPendingCreate( - createEvent: Extract - ): void { - const { promise } = createEvent.resolvers; - const toRemove = this.events.filter( - (event) => - event === createEvent || - ((event.type === SyncEventType.LocalUpdate || - event.type === SyncEventType.LocalDelete) && - event.documentId === promise) - ); - - for (const event of toRemove) { - removeFromArray(this.events, event); - } - - createEvent.resolvers.promise.catch(() => { - /* suppressed — the create/delete pair collapsed locally */ - }); - createEvent.resolvers.reject(new Error("Create was cancelled")); - - if (toRemove.length > 0) { - this.notifyPendingUpdateCountChanged(); - } - } - - private purgeRemoteChangesForDocumentId(documentId: DocumentId): void { - const toRemove = this.events.filter( - (e) => - e.type === SyncEventType.RemoteChange && - e.remoteVersion.documentId === documentId - ); - for (const event of toRemove) { - if (event.type === SyncEventType.RemoteChange) { - // Advance the watermark for the dropped event so the gap - // doesn't leave the catch-up replay this id forever. - this._lastSeenUpdateId.add(event.remoteVersion.vaultUpdateId); - } - removeFromArray(this.events, event); - } - if (toRemove.length > 0) { - this.notifyPendingUpdateCountChanged(); - } - } -} diff --git a/frontend/sync-client/src/sync-operations/syncer.ts b/frontend/sync-client/src/sync-operations/syncer.ts index 4e908600..71dedd85 100644 --- a/frontend/sync-client/src/sync-operations/syncer.ts +++ b/frontend/sync-client/src/sync-operations/syncer.ts @@ -1,191 +1,238 @@ -// Two-loop sync engine. The wire loop (this file) keeps records in step -// with the server: HTTP/WS handlers update record fields and write -// content to the file at `record.localPath`. They never move files for -// path placement. The Reconciler (reconciler.ts) handles record↔disk -// path reconciliation, running after every wire-loop drained event. -import { - SyncEventType, - type DocumentId, - type DocumentRecord, - type SyncEvent, - type RelativePath, - type VaultUpdateId -} from "./types"; +import type { + Database, + DocumentId, + DocumentRecord, + RelativePath +} from "../persistence/database"; +import type { SyncService } from "../services/sync-service"; import type { Logger } from "../tracing/logger"; +import PQueue from "p-queue"; import { hash } from "../utils/hash"; +import { v4 as uuidv4 } from "uuid"; import type { Settings } from "../persistence/settings"; import type { FileOperations } from "../file-operations/file-operations"; -import { FileAlreadyExistsError } from "../errors/file-already-exists-error"; -import { scheduleOfflineChanges } from "./offline-change-detector"; -import { SyncResetError } from "../errors/sync-reset-error"; +import { findMatchingFile } from "../utils/find-matching-file"; +import type { UnrestrictedSyncer } from "./unrestricted-syncer"; +import { createPromise } from "../utils/create-promise"; +import { SyncResetError } from "../services/sync-reset-error"; +import { Locks } from "../utils/data-structures/locks"; import type { DocumentVersionWithoutContent } from "../services/types/DocumentVersionWithoutContent"; import type { WebSocketVaultUpdate } from "../services/types/WebSocketVaultUpdate"; import type { WebSocketManager } from "../services/websocket-manager"; import type { WebSocketClientMessage } from "../services/types/WebSocketClientMessage"; +import { awaitAll } from "../utils/await-all"; import { EventListeners } from "../utils/data-structures/event-listeners"; -import type { SyncEventQueue } from "./sync-event-queue"; -import type { SyncService } from "../services/sync-service"; -import { FileNotFoundError } from "../errors/file-not-found-error"; -import { HttpClientError } from "../errors/http-client-error"; -import type { SyncHistory } from "../tracing/sync-history"; -import { - SyncStatus, - SyncType, - type HistoryEntry -} from "../tracing/sync-history"; -import { isBinary } from "../utils/is-binary"; -import { isFileTypeMergable } from "../utils/is-file-type-mergable"; -import { diff } from "reconcile-text"; -import type { ServerConfig } from "../services/server-config"; -import type { FixedSizeDocumentCache } from "../utils/data-structures/fix-sized-cache"; -import { base64ToBytes } from "byte-base64"; -import type { DocumentUpdateResponse } from "../services/types/DocumentUpdateResponse"; -import { Reconciler } from "./reconciler"; - -// Internal ignore pattern pinned on the queue at construction time so -// the watcher's enqueue path doesn't pick up Reconciler swap markers. -const VAULTLINK_INTERNAL_DIR_IGNORE = ".vaultlink/**"; export class Syncer { public readonly onRemainingOperationsCountChanged = new EventListeners< (remainingOperations: number) => unknown >(); - private readonly queue: SyncEventQueue; - private readonly reconciler: Reconciler; - // Bytes the wire loop received for a doc whose `localPath` is not yet - // set (e.g. a remote create whose target slot was occupied). Shared - // with the Reconciler, which consumes (and deletes the entry) when it - // places the file. Keeping the bytes here avoids a redundant - // server fetch on the very next reconciler pass. - private readonly pendingPlacementContent = new Map< - DocumentId, - Uint8Array - >(); + private readonly remoteDocumentsLock: Locks; + // FIFO to limit the number of concurrent sync operations + private readonly syncQueue: PQueue; + + private _isFirstSyncComplete = false; private runningScheduleSyncForOfflineChanges: Promise | undefined; - private drainPromise: Promise | undefined; - private drainRequestedWhileRunning = false; - private isDrainingPaused = false; - private isScanning = false; private previousRemainingOperationsCount = 0; public constructor( private readonly deviceId: string, private readonly logger: Logger, + private readonly database: Database, private readonly settings: Settings, + private readonly syncService: SyncService, private readonly webSocketManager: WebSocketManager, private readonly operations: FileOperations, - private readonly syncService: SyncService, - private readonly history: SyncHistory, - private readonly contentCache: FixedSizeDocumentCache, - private readonly serverConfig: ServerConfig, - queue: SyncEventQueue + private readonly internalSyncer: UnrestrictedSyncer ) { - this.queue = queue; + this.syncQueue = new PQueue({ + concurrency: settings.getSettings().syncConcurrency + }); - // Hide the Reconciler's swap-marker scratch directory from the - // watcher's enqueue path. Without this, the marker file the - // Reconciler writes during a cycle swap would race onto the - // queue as a LocalCreate, and the queue would push that to the - // server. - this.queue.addInternalIgnorePattern(VAULTLINK_INTERNAL_DIR_IGNORE); + this.remoteDocumentsLock = new Locks(this.logger); - this.reconciler = new Reconciler( - this.logger, - this.operations, - this.syncService, - this.queue, - this.pendingPlacementContent - ); + settings.onSettingsChanged.add((newSettings, oldSettings) => { + if (newSettings.syncConcurrency !== oldSettings.syncConcurrency) { + this.syncQueue.concurrency = newSettings.syncConcurrency; + } + }); - // Fire-and-forget: any swap marker left behind by a crash gets - // rolled forward before the first wire-loop event runs. Errors - // are logged inside the reconciler. - void this.reconciler.recoverFromInterruptedSwap(); + this.syncQueue.on("active", () => { + if (this.previousRemainingOperationsCount !== this.syncQueue.size) { + this.previousRemainingOperationsCount = this.syncQueue.size; + this.onRemainingOperationsCountChanged.trigger( + this.syncQueue.size + ); + } + }); this.webSocketManager.onWebSocketStatusChanged.add((isConnected) => { if (isConnected) { + // The JS WebSocket API doesn't support setting headers, so we have to send the token as a message this.sendHandshakeMessage(); } }); this.webSocketManager.onRemoteVaultUpdateReceived.add( this.syncRemotelyUpdatedFile.bind(this) ); - // Funnel every queue mutation (enqueue, consume, clearPending) through - // the public count notifier so listeners see grow/shrink transitions - // immediately rather than only when a drain consumes an event. - this.queue.onPendingUpdateCountChanged.add(() => { - this.notifyRemainingOperationsChanged(); - }); } - /** - * True while the syncer has *active* work the caller should wait on: a - * running offline scan or an in-flight drain. Pending queue events alone - * don't count — `pause()` and `SyncResetError` exit drain early without - * clearing the queue, and nothing will pick those events back up until - * sync is re-enabled. Treating queued-but-stuck events as pending work - * would deadlock `waitUntilFinishedInternal` (the awaits inside its loop - * are no-ops once the active work has settled). - * - * The contract that makes "in-flight only" sufficient: every codepath - * that enqueues an event ends in `ensureDraining()` (the local-sync - * methods, `syncRemotelyUpdatedFile`, and the tail of - * `internalScheduleSyncForOfflineChanges`). So if a WebSocket handler - * lands new work mid-await, the next loop iteration sees `drainPromise` - * set and waits on it. - * - * Uses `isScanning` rather than `runningScheduleSyncForOfflineChanges` - * because the latter is a "have we already scanned this session" latch - * that stays set after the scan resolves. - */ - public get hasPendingWork(): boolean { - return this.isScanning || this.drainPromise !== undefined; + public get isFirstSyncComplete(): boolean { + return this._isFirstSyncComplete; } - public syncLocallyCreatedFile(relativePath: RelativePath): void { - void this.queue.enqueue({ - type: SyncEventType.LocalCreate, - path: relativePath - }); - this.ensureDraining(); + public async syncLocallyCreatedFile( + relativePath: RelativePath + ): Promise { + if ( + this.database.getLatestDocumentByRelativePath(relativePath) + ?.isDeleted === false + ) { + this.logger.debug( + `Document ${relativePath} already exists in the database, skipping` + ); + return; + } + + const [promise, resolve, reject] = createPromise(); + + const id = uuidv4(); + const document = this.database.createNewPendingDocument( + id, + relativePath, + promise + ); + + try { + await this.syncQueue.add(async () => + this.internalSyncer.unrestrictedSyncLocallyCreatedFile(document) + ); + + resolve(); + } catch (e) { + reject(e); + } finally { + this.database.removeDocumentPromise(promise); + } } - public syncLocallyUpdatedFile({ + public async syncLocallyDeletedFile( + relativePath: RelativePath + ): Promise { + if ( + this.database.getLatestDocumentByRelativePath(relativePath) + ?.isDeleted === true + ) { + // This is must be a consequence of us deleting a file because of a remote update + // which triggered a local delete, so we don't need to do anything here. + this.logger.debug( + `Document ${relativePath} has already been markes as deleted, skipping` + ); + return; + } + + // We have to have a record of the delete in case there's an in-flight update for the same + // document which finishes after the delete has succeeded and would introduce a phantom metadata record. + this.database.delete(relativePath); + + const [promise, resolve, reject] = createPromise(); + + const document = await this.database.getResolvedDocumentByRelativePath( + relativePath, + promise + ); + + try { + await this.syncQueue.add(async () => + this.internalSyncer.unrestrictedSyncLocallyDeletedFile(document) + ); + + resolve(); + + this.database.removeDocument(document); + } catch (e) { + reject(e); + } finally { + this.database.removeDocumentPromise(promise); + } + } + + public async syncLocallyUpdatedFile({ oldPath, relativePath }: { oldPath?: RelativePath; relativePath: RelativePath; - }): void { - void this.queue.enqueue({ - type: SyncEventType.LocalUpdate, - path: relativePath, - oldPath - }); - this.ensureDraining(); - } + }): Promise { + if (oldPath !== undefined) { + // We might have moved the document in the database before calling this method, + // in that case, we mustn't move it again. + if ( + this.database.getLatestDocumentByRelativePath(relativePath) === + undefined || + this.database.getLatestDocumentByRelativePath(relativePath) + ?.isDeleted === true + ) { + if (oldPath === relativePath) { + throw new Error( + `Old path and new path are the same: ${oldPath}` + ); + } - public syncLocallyDeletedFile(relativePath: RelativePath): void { - void this.queue.enqueue({ - type: SyncEventType.LocalDelete, - path: relativePath - }); - this.ensureDraining(); - } + this.database.move(oldPath, relativePath); + } + } - public async syncRemotelyUpdatedFile( - message: WebSocketVaultUpdate - ): Promise { - await this.scheduleSyncForOfflineChanges(); + let document = + this.database.getLatestDocumentByRelativePath(relativePath); - void this.queue.enqueue({ - type: SyncEventType.RemoteChange, - remoteVersion: message.document - }); + if ( + oldPath !== undefined && + document?.metadata?.remoteRelativePath === relativePath + ) { + this.logger.debug( + `Document ${relativePath} has been moved as a result of a remote update, skipping sync` + ); + return; + } - this.ensureDraining(); + if (document === undefined) { + this.logger.debug( + `Cannot find document ${relativePath} in the database, skipping` + ); + return; + } + + if (document.isDeleted) { + this.logger.debug( + `Document ${relativePath} has been deleted locally, skipping` + ); + return; + } + + const [promise, resolve, reject] = createPromise(); + + document = await this.database.getResolvedDocumentByRelativePath( + relativePath, + promise + ); + + try { + await this.syncQueue.add(async () => + this.internalSyncer.unrestrictedSyncLocallyUpdatedFile({ + oldPath, + document + }) + ); + + resolve(); + } catch (e) { + reject(e); + } finally { + this.database.removeDocumentPromise(promise); + } } public async scheduleSyncForOfflineChanges(): Promise { @@ -198,7 +245,7 @@ export class Syncer { this.runningScheduleSyncForOfflineChanges = this.internalScheduleSyncForOfflineChanges(); await this.runningScheduleSyncForOfflineChanges; - this.logger.info(`All local changes have been queued`); + this.logger.info(`All local changes have been applied remotely`); } catch (e) { if (e instanceof SyncResetError) { this.logger.info( @@ -210,49 +257,47 @@ export class Syncer { `Not all local changes have been applied remotely: ${e}` ); throw e; + } finally { + this.runningScheduleSyncForOfflineChanges = undefined; } } public async waitUntilFinished(): Promise { await this.runningScheduleSyncForOfflineChanges; - // A drain that finishes can be immediately followed by a new one - // (e.g. a remote event arriving), so re-check after each await. - while (this.drainPromise !== undefined) { - await this.drainPromise; + await this.syncQueue.onIdle(); // Wait for queue to be empty and running tasks to finish + } + + public async syncRemotelyUpdatedFile( + message: WebSocketVaultUpdate + ): Promise { + try { + const handlerPromise = awaitAll( + message.documents.map(async (document) => + this.internalSyncRemotelyUpdatedFile(document) + ) + ); + + await handlerPromise; + + if (message.isInitialSync && message.documents.length > 0) { + this.database.setLastSeenUpdateId( + message.documents + .map((document) => document.vaultUpdateId) + .reduce((a, b) => Math.max(a, b)) + ); + } + + this._isFirstSyncComplete = true; + } catch (e) { + this.logger.error(`Failed to sync remotely updated file: ${e}`); } } public reset(): void { - this.queue.clearPending(); - this.clearOfflineScanGate(); - this.previousRemainingOperationsCount = 0; - } - - /** - * Reset the "have we already scanned this session" gate so a later - * `scheduleSyncForOfflineChanges()` actually performs a fresh scan - * instead of returning the previous (resolved) promise. Called when - * sync is paused so the next start picks up any offline edits made - * while sync was off. - */ - public clearOfflineScanGate(): void { - const current = this.runningScheduleSyncForOfflineChanges; - if (current !== undefined) { - void current.finally(() => { - if (this.runningScheduleSyncForOfflineChanges === current) { - this.runningScheduleSyncForOfflineChanges = undefined; - } - }); - } - } - - public pauseDraining(): void { - this.isDrainingPaused = true; - } - - public resumeDraining(): void { - this.isDrainingPaused = false; - this.ensureDraining(); + this._isFirstSyncComplete = false; + this.syncQueue.clear(); + this.remoteDocumentsLock.reset(); + this.runningScheduleSyncForOfflineChanges = undefined; } private sendHandshakeMessage(): void { @@ -260,994 +305,218 @@ export class Syncer { type: "handshake", deviceId: this.deviceId, token: this.settings.getSettings().token, - lastSeenVaultUpdateId: this.queue.lastSeenUpdateId + lastSeenVaultUpdateId: this.database.getLastSeenUpdateId() }; this.webSocketManager.sendHandshakeMessage(message); } - private async internalScheduleSyncForOfflineChanges(): Promise { - this.isScanning = true; - try { - this.queue.clearPending(); // can't have conflicts between the offline scan and ongoing operations created during the preceeding pause - - await scheduleOfflineChanges( - this.logger, - this.operations, - this.queue, - (path) => { - this.syncLocallyCreatedFile(path); - }, - (args) => { - this.syncLocallyUpdatedFile(args); - }, - (path) => { - this.syncLocallyDeletedFile(path); - } - ); - } finally { - this.isScanning = false; - } - - this.ensureDraining(); - } - - private ensureDraining(): void { - if (this.drainPromise !== undefined) { - this.drainRequestedWhileRunning = true; - return; - } - if (this.isScanning) { - return; - } - if (this.isDrainingPaused) { - return; - } - this.drainPromise = this.drain().finally(() => { - this.drainPromise = undefined; - const shouldRestart = - this.drainRequestedWhileRunning && - this.queue.pendingUpdateCount > 0 && - !this.isScanning && - !this.isDrainingPaused && - this.settings.getSettings().isSyncEnabled; - this.drainRequestedWhileRunning = false; - if (shouldRestart) { - this.ensureDraining(); - } - }); - } - - private async drain(): Promise { - // Peek then remove-after-processing (instead of shift-then-process): - // the event must remain reachable through `findLatestCreateForPath` - // while it is in flight, so a rename event arriving mid-process can - // call `updatePendingCreatePath` to retarget this create's local path. - for (;;) { - if ( - this.isDrainingPaused || - !this.settings.getSettings().isSyncEnabled - ) { - this.logger.debug( - "Drain pausing because sync is disabled; events stay queued" - ); - return; - } - const event = this.queue.peekFront(); - - if (event === undefined) { - break; - } - - try { - await this.processEvent(event); - } catch (e) { - if (e instanceof SyncResetError) { - this.logger.info("Drain interrupted by sync reset"); - return; - } - this.logger.error( - `Failed to process sync event ${event.type}: ${e}` - ); - } - this.queue.consumeEvent(event); - // Reconciler runs after every wire-loop step; any record whose - // localPath drifted from remoteRelativePath gets a chance to - // converge before the next event. Best-effort — per-record - // failures are logged and retried on the next pass. - await this.reconciler.run(); - this.notifyRemainingOperationsChanged(); - } - } - - private async processEvent(event: SyncEvent): Promise { - try { - if (event.type === SyncEventType.LocalCreate) { - event.isProcessing = true; - } - - if (await this.skipIfOversized(event)) { - return; - } - - switch (event.type) { - case SyncEventType.LocalCreate: - await this.processCreate(event); - break; - case SyncEventType.LocalDelete: - await this.processDelete(event); - break; - case SyncEventType.LocalUpdate: - await this.processLocalUpdate(event); - break; - case SyncEventType.RemoteChange: - await this.processRemoteChange(event); - break; - } - } catch (e) { - // If a LocalCreate fails terminally, queued LocalDelete / - // LocalUpdate events whose `documentId` is this Create's - // `resolvers.promise` would `await` it forever — reject the - // resolver so they fail-fast with the same error class and - // hit their matching skip/log branch below. - // - // Only do this for terminal errors. `SyncResetError` is - // transient: drain returns without consuming the event, so - // the next drain retries the same Create. Rejecting the - // resolver now would permanently poison it, and the eventual - // `resolveCreate(...resolve)` after the retry succeeds is a - // no-op on an already-settled promise — leaving every - // dependent event stuck failing on `await event.documentId`. - if ( - event.type === SyncEventType.LocalCreate && - !(e instanceof SyncResetError) - ) { - event.resolvers.promise.catch(() => { - /* suppressed */ - }); - event.resolvers.reject(e); - } - - if (e instanceof FileNotFoundError) { - this.logger.info( - `Skipping sync event '${event.type}' because the file no longer exists` - ); - return; - } - if (e instanceof HttpClientError) { - this.logger.error( - `Server rejected ${event.type} request: ${e.message}` - ); - return; - } - throw e; - } - } - - private async skipIfOversized(event: SyncEvent): Promise { - let sizeInBytes = 0; - let relativePath: RelativePath = ""; - - switch (event.type) { - case SyncEventType.LocalDelete: - return false; - case SyncEventType.LocalCreate: - case SyncEventType.LocalUpdate: - sizeInBytes = await this.operations.getFileSize(event.path); - relativePath = event.path; - break; - case SyncEventType.RemoteChange: - if (event.remoteVersion.isDeleted) { - return false; - } - sizeInBytes = event.remoteVersion.contentSize; - ({ relativePath } = event.remoteVersion); - break; - } - - const oversizedEntry = this.getHistoryEntryForSkippedOversizedFile( - sizeInBytes, - relativePath - ); - if (oversizedEntry === undefined) { - return false; - } - - this.history.addHistoryEntry(oversizedEntry); - - if (event.type === SyncEventType.LocalCreate) { - event.resolvers.promise.catch(() => { - /* suppressed */ - }); - event.resolvers.reject(new Error("Create was cancelled")); - } - - // Advance the cursor so the server doesn't replay this update on every - // reconnect — the skip is permanent for this version. - if (event.type === SyncEventType.RemoteChange) { - this.queue.lastSeenUpdateId = event.remoteVersion.vaultUpdateId; - } - - return true; - } - - private getHistoryEntryForSkippedOversizedFile( - sizeInBytes: number, - relativePath: RelativePath - ): HistoryEntry | undefined { - const sizeInMB = Math.round(sizeInBytes / 1024 / 1024); - const { maxFileSizeMB } = this.settings.getSettings(); - if (sizeInMB > maxFileSizeMB) { - return { - status: SyncStatus.SKIPPED, - details: { - type: SyncType.SKIPPED as const, - relativePath - }, - message: `File size of ${sizeInMB} MB exceeds the maximum file size limit of ${maxFileSizeMB} MB`, - timestamp: new Date() - }; - } - } - - private async processCreate( - event: Extract + private async internalSyncRemotelyUpdatedFile( + remoteVersion: DocumentVersionWithoutContent ): Promise { - const requestPath = event.path; - const contentBytes = await this.operations.read(requestPath); - const contentHash = await hash(contentBytes); - - // Use the path the pending create has when it reaches the wire loop. - // `updatePendingCreatePath` mutates queued creates when a not-yet-sent - // local file is renamed, so a renamed-away generation does not create - // a server document at a path that a newer local file has reused. - // - // `lastSeenUpdateIdForCreate(requestPath)` (rather than the contiguous - // `lastSeenUpdateId`) blocks the server from path-merging this POST - // into a doc we already track at the same path. Without that, a - // same-device rename race can alias two physically distinct local - // files onto one docId. See `SyncEventQueue.lastSeenUpdateIdForCreate`. - const response = await this.syncService.create({ - relativePath: requestPath, - lastSeenVaultUpdateId: - this.queue.lastSeenUpdateIdForCreate(requestPath), - contentBytes - }); - - // Same-docId collapse. While our LocalCreate sat in the queue, a - // RemoteCreate may have arrived for this same path. The wire-loop's - // `processRemoteCreateForNewDocument` would have built a record with - // `localPath === undefined` carrying the same docId the server is - // about to return us. `upsertRecord` keys by docId and merges in - // place, so the record we pass below collapses into that existing - // one — its claim is dropped and `localPath` becomes `event.path`. - // The reconciler will reconcile if `response.relativePath` differs. - let remoteHash = contentHash; - if (response.type === "MergingUpdate") { - const responseBytes = base64ToBytes(response.contentBase64); - // Read `event.path` live for both the write target and the - // cache key. A user rename arriving between HTTP-send and - // HTTP-response rewrites `event.path` via - // `updatePendingCreatePath`; the merge write must land on - // the current slot so the queued LocalUpdate that follows - // sees the merged bytes. - await this.operations.write( - event.path, - contentBytes, - responseBytes - ); - remoteHash = await hash(responseBytes); - await this.updateCache( - response.vaultUpdateId, - responseBytes, - event.path - ); - } else { - await this.updateCache( - response.vaultUpdateId, - contentBytes, - event.path - ); - } - - // Drop any stashed bytes for this docId — the file is on disk at - // event.path, so the reconciler shouldn't try to fetch & write - // its content. (The reconciler's job for this record is now just - // path placement, if needed.) - this.pendingPlacementContent.delete(response.documentId); - - // Snapshot `event.path` only after the write has settled. The - // write itself can drive synchronous watcher callbacks (e.g. - // an atomic-update fileSystemOperations that fires a "file - // changed" event back into the queue), and the test harness's - // user-facing renames also race here. Either path mutates - // `event.path` via `updatePendingCreatePath`; reading it once - // up front would lock in a stale slot and leave - // `record.localPath` pointing at a vacated path with no - // LocalRename ever materializing. - const localPath = event.path; - - await this.queue.resolveCreate(event, { - documentId: response.documentId, - parentVersionId: response.vaultUpdateId, - remoteRelativePath: response.relativePath, - remoteHash, - localPath - }); - - this.queue.lastSeenUpdateId = response.vaultUpdateId; - this.history.addHistoryEntry({ - status: SyncStatus.SUCCESS, - details: { type: SyncType.CREATE, relativePath: localPath }, - message: - response.type === "MergingUpdate" - ? "Created file and merged with existing remote version" - : "Successfully created file on the server", - author: response.userId, - timestamp: new Date(response.updatedDate) - }); - } - - private async processDelete( - event: Extract - ): Promise { - const documentId = await event.documentId; - const record = this.queue.getDocumentByDocumentId(documentId); - if ( - record?.localPath !== undefined && - record.localPath !== event.path - ) { - this.logger.debug( - `Skipping local-delete for ${documentId} at ${event.path}: ` + - `record now owns ${record.localPath}` - ); - return; - } - - // The disk file is already gone when a LocalDelete reaches the wire - // loop. This is redundant for settled records deleted through - // `enqueue`, but load-bearing for creates that were deleted while the - // create request was still pending: their record only exists after the - // create ack resolves. - await this.queue.setLocalPath(documentId, undefined); - - const response = await this.syncService.delete({ - documentId - }); - - // Don't remove the doc from the queue or advance lastSeenUpdateId - // here. The server broadcasts the delete back to us over the - // WebSocket; that receipt drives `processRemoteDelete`'s cleanup - // and history entry. Keeping the entry in the map until then lets - // late remote updates be recognised as "file is missing" and - // skipped, instead of resurrecting the doc. - // - // Mark the doc as deletion-pending so the Reconciler doesn't - // resurrect it during the gap between HTTP-ack and WS-receipt. - // Without this, the LocalDelete enqueue's `setLocalPath(undefined)` - // leaves the record looking like a "needs initial placement" case - // to the Reconciler — which would then fetch the pre-delete bytes - // from the server and write them to disk. The mark also blocks - // any late RemoteChange from stashing pre-delete bytes into - // `pendingPlacementContent` (see processRemoteUpdate). The mark is - // cleared automatically by `removeDocumentById`. We also drop any - // already-stashed content for this doc since it cannot be placed. - this.queue.markServerDeletePending(documentId); - this.pendingPlacementContent.delete(documentId); - this.history.addHistoryEntry({ - status: SyncStatus.SUCCESS, - details: { - type: SyncType.DELETE, - relativePath: event.path - }, - message: "Successfully deleted file on the server", - author: response.userId, - timestamp: new Date(response.updatedDate) - }); - } - - private async processLocalUpdate( - event: Extract - ): Promise { - const documentId = await event.documentId; - - const record = this.queue.getDocumentByDocumentId(documentId); - if (record === undefined) { - // The doc was deleted between this event being queued and - // drained — skip silently. Common when a LocalDelete drains - // ahead of a LocalUpdate that was already in the queue. - this.logger.debug( - `Skipping local-update for ${documentId} — doc no longer tracked (deleted)` - ); - return; - } - // The record may exist with no local file (e.g. a pending-delete - // raced ahead and nulled out localPath). Nothing to upload from. - if (record.localPath === undefined) { - this.logger.debug( - `Skipping local-update for ${documentId} — record has no local file` - ); - return; - } - const contentBytes = await this.operations.read(record.localPath); - const contentHash = await hash(contentBytes); - - // For a user-driven rename the user's intent is `event.originalPath` - // — that's the rename target. For a content-only edit the user is - // agnostic to the path; sending one would be wrong if a remote - // rename processed first, because the server would interpret the - // user's (now-stale) path as a rename back. So content-only PUTs - // omit the path and the server keeps the doc at its current - // server-known location. - const renameTarget = event.isUserRename - ? event.originalPath - : undefined; - - const hashChanged = contentHash !== record.remoteHash; - const pathChanged = - renameTarget !== undefined && - record.remoteRelativePath !== renameTarget; - - if (!hashChanged && !pathChanged) { - this.logger.debug( - `File hash of ${record.localPath} matches last synced version; no need to sync` - ); - return; - } - - const response = await this.sendUpdate({ - record, - relativePath: renameTarget, - contentBytes - }); - - if (response.isDeleted) { - await this.processRemoteDelete(record.localPath, { - ...response, - contentSize: 0, - isNewFile: false - }); - return; - } - - // Read `record.localPath` live via a fresh queue lookup: the - // queue's enqueue rename branch mutates the same record object - // in place across our await on `sendUpdate`, and a displaced-doc - // cleanup can null it out. The fresh lookup also re-widens the - // type back to `string | undefined` (the earlier guard narrowed - // it pre-await). The reconciler handles any further path - // placement after we write. - const livePath = - this.queue.getDocumentByDocumentId(documentId)?.localPath; - let remoteHash = contentHash; - if (response.type === "MergingUpdate") { - const responseBytes = base64ToBytes(response.contentBase64); - if (livePath !== undefined) { - await this.operations.write( - livePath, - contentBytes, - responseBytes - ); - } - remoteHash = await hash(responseBytes); - await this.updateCache( - response.vaultUpdateId, - responseBytes, - livePath ?? response.relativePath - ); - } else { - await this.updateCache( - response.vaultUpdateId, - contentBytes, - livePath ?? response.relativePath - ); - } - - await this.queue.upsertRecord({ - documentId: response.documentId, - parentVersionId: response.vaultUpdateId, - remoteRelativePath: response.relativePath, - remoteHash, - // localPath is owned by the watcher and the reconciler. Pass - // the value we observed pre-await purely as a hint for the - // placement-pending → placed transition; `upsertRecord` ignores - // it when an existing localPath is already set, so a watcher - // rename that landed during the HTTP roundtrip is preserved. - localPath: livePath - }); - this.queue.lastSeenUpdateId = response.vaultUpdateId; - - this.history.addHistoryEntry({ - status: SyncStatus.SUCCESS, - details: { - type: SyncType.UPDATE, - relativePath: livePath ?? response.relativePath - }, - message: - response.type === "MergingUpdate" - ? "Updated file and merged with remote changes" - : "Successfully updated file on the server", - author: response.userId, - timestamp: new Date(response.updatedDate) - }); - } - - private async processRemoteChange( - event: Extract - ): Promise { - const { remoteVersion } = event; - const trackedRecord = this.queue.getDocumentByDocumentId( + let document = this.database.getDocumentByDocumentId( remoteVersion.documentId ); - if (remoteVersion.isDeleted) { - if (trackedRecord === undefined) { - // The doc isn't tracked locally — either we never had - // it (joined the vault after the delete) or a previous - // delete already cleaned it up. Just advance - // `lastSeenUpdateId` so we don't replay this on the - // next reconnect. - this.queue.lastSeenUpdateId = remoteVersion.vaultUpdateId; - return; - } - return this.processRemoteDelete( - trackedRecord.localPath, - remoteVersion - ); - } - - if ( - (trackedRecord?.parentVersionId ?? 0) >= remoteVersion.vaultUpdateId - ) { - this.queue.lastSeenUpdateId = remoteVersion.vaultUpdateId; - this.logger.debug( - `Document ${remoteVersion.relativePath} is already up-to-date or has newer local changes; skipping remote update` - ); - return; - } - - // Server-side delete is in flight: our HTTP DELETE has been acked - // but the WebSocket receipt that would `removeDocumentById` hasn't - // arrived yet. Any remote update we apply here would resurrect the - // doc — either by writing the pre-delete bytes to disk - // (`processRemoteUpdate` with localPath set) or by stashing them - // for the Reconciler (`processRemoteUpdate` with localPath - // undefined; reconciler is also gated, but stashing leaves - // `pendingPlacementContent` lingering which a same-docId - // re-creation could later misuse). Advance the watermark and - // discard; the eventual delete-receipt will clean up the record. - if ( - trackedRecord !== undefined && - this.queue.hasPendingServerDelete(trackedRecord.documentId) - ) { - this.queue.lastSeenUpdateId = remoteVersion.vaultUpdateId; - this.logger.debug( - `Discarding remote update for ${remoteVersion.documentId}: ` + - `local HTTP DELETE has been acked; awaiting WS receipt` - ); - return; - } - - if (trackedRecord !== undefined) { - // The doc is tracked, but the disk slot can be stale. One - // concrete race: a remote create quick-writes a file, a - // watcher rename/delete lands before the record is fully - // settled, and the record is left claiming a path that no - // longer exists. If no queued local operation owns that - // disappearance, clear the localPath and let - // processRemoteUpdate stash/place the active server version. - if (trackedRecord.localPath !== undefined) { - const fileExists = await this.operations.exists( - trackedRecord.localPath - ); - if ( - !fileExists && - !this.queue.hasPendingLocalEventsForDocumentId( + if (document === undefined) { + // Let's avoid the same documents getting created in parallel multiple times. + // There might be multiple tasks waiting for the lock + return this.remoteDocumentsLock.withLock( + remoteVersion.documentId, + async () => { + document = this.database.getDocumentByDocumentId( remoteVersion.documentId - ) + ); + + // We're either the first one to get the lock, so we have to create the document in `unrestrictedSyncRemotelyUpdatedFile` + if (document === undefined) { + await this.syncQueue.add(async () => + this.internalSyncer.unrestrictedSyncRemotelyUpdatedFile( + remoteVersion + ) + ); + } else { + const [promise, resolve, reject] = createPromise(); + + document = + await this.database.getResolvedDocumentByRelativePath( + document.relativePath, + promise + ); + + try { + await this.syncQueue.add(async () => + this.internalSyncer.unrestrictedSyncRemotelyUpdatedFile( + remoteVersion, + document + ) + ); + + resolve(); + } catch (e) { + reject(e); + } finally { + this.database.removeDocumentPromise(promise); + } + } + + this.database.addSeenUpdateId(remoteVersion.vaultUpdateId); + } + ); + } + + // We're either the first one to get the lock, so we have to create the document in `unrestrictedSyncRemotelyUpdatedFile` + const [promise, resolve, reject] = createPromise(); + + document = await this.database.getResolvedDocumentByRelativePath( + document.relativePath, + promise + ); + + try { + await this.syncQueue.add(async () => + this.internalSyncer.unrestrictedSyncRemotelyUpdatedFile( + remoteVersion, + document + ) + ); + + resolve(); + } catch (e) { + reject(e); + } finally { + this.database.removeDocumentPromise(promise); + } + + this.database.addSeenUpdateId(remoteVersion.vaultUpdateId); + } + + private async internalScheduleSyncForOfflineChanges(): Promise { + await this.createFakeDocumentsFromRemoteState(); + + const allLocalFiles = await this.operations.listFilesRecursively(); + this.logger.info( + `Scheduling sync for ${allLocalFiles.length} local files` + ); + + let locallyPossiblyDeletedFiles: DocumentRecord[] = []; + + for (const document of this.database.resolvedDocuments) { + if ( + !document.isDeleted && + !(await this.operations.exists(document.relativePath)) + ) { + locallyPossiblyDeletedFiles.push(document); + } + } + + await awaitAll( + allLocalFiles.map(async (relativePath) => { + if ( + this.database.getLatestDocumentByRelativePath(relativePath) + ?.metadata !== undefined ) { this.logger.debug( - `Remote update for ${remoteVersion.documentId}: ` + - `local file at ${trackedRecord.localPath} is missing; ` + - `clearing localPath for placement` + `Document ${relativePath} might have been updated locally, scheduling sync to validate and update it` ); - await this.queue.setLocalPath( - trackedRecord.documentId, - undefined - ); - } - } - return this.processRemoteUpdate(trackedRecord, remoteVersion); - } - if (!remoteVersion.isNewFile) { - this.queue.lastSeenUpdateId = remoteVersion.vaultUpdateId; - this.logger.debug( - `Ignoring stale RemoteChange for untracked, non-new document ${remoteVersion.documentId}` - ); - return; - } - - return this.processRemoteCreateForNewDocument(remoteVersion); - } - - private async processRemoteDelete( - localPath: RelativePath | undefined, - remoteVersion: DocumentVersionWithoutContent - ): Promise { - if (localPath !== undefined) { - // Verify the record still owns this disk slot before deleting. - // A same-path recreate (LocalCreate at this path resolving - // after we sent the server-delete for this doc) installs a - // new doc into byLocalPath but doesn't clear the old record's - // stale `localPath` field. When the WS broadcast for the old - // doc's deletion arrives, naively deleting at `localPath` - // would clobber the new doc's file. Skip the disk delete - // when the slot now belongs to a different doc; the queue - // record cleanup below still runs. - const currentOwner = this.queue.byLocalPath.get(localPath); - if ( - currentOwner === undefined || - currentOwner.documentId === remoteVersion.documentId - ) { - await this.operations.delete(localPath); - } else { - this.logger.debug( - `Skipping disk delete for ${remoteVersion.documentId} at ${localPath}: ` + - `slot is now owned by ${currentOwner.documentId}` - ); - } - } - await this.queue.removeDocumentById(remoteVersion.documentId); - - this.queue.lastSeenUpdateId = remoteVersion.vaultUpdateId; - - this.history.addHistoryEntry({ - status: SyncStatus.SUCCESS, - details: { - type: SyncType.DELETE, - relativePath: localPath ?? remoteVersion.relativePath - }, - message: - "Successfully deleted file which had been deleted remotely", - author: remoteVersion.userId, - timestamp: new Date(remoteVersion.updatedDate) - }); - } - - private async processRemoteUpdate( - record: DocumentRecord, - remoteVersion: DocumentVersionWithoutContent - ): Promise { - if ( - this.queue.hasPendingLocalEventsForDocumentId( - remoteVersion.documentId - ) - ) { - // The user has queued local edits for this doc. Apply them - // first — they'll round-trip to the server, get merged - // there, and broadcast back. If we processed this remote - // update now, `FileOperations.write` would receive - // `expected = current = the disk content (which already - // includes the user's pending edits)`, so the 3-way merge - // baseline collapses to "no local change vs base" and - // returns `theirs`, silently dropping the user's bytes. - // Re-enqueueing (rather than just deferring with a flag) - // is correct because by the time the queued local events - // drain, this remote update may be stale: our - // `parentVersionId` advances past `remoteVersion.vaultUpdateId`, - // and the next pass's standard "stale" check at the top of - // `processRemoteChange` will discard it. - // - // Broader concern (out of scope here): the 3-way merge - // baseline in `FileOperations.write` is the most-recent - // disk read at every callsite, not the previous server - // version. That's correct for the post-server-merge writes - // in `processCreate` / `processLocalUpdate` (we're - // applying the server's merged result to our potentially - // newer disk state), but fundamentally wrong as a base for - // a true 3-way merge. The defer gate above sidesteps the - // only call pattern where it actually loses data today. - void this.syncRemotelyUpdatedFile({ document: remoteVersion }); - return; - } - - const remoteContent = await this.syncService.getDocumentVersionContent({ - documentId: remoteVersion.documentId, - vaultUpdateId: remoteVersion.vaultUpdateId - }); - - // `record.localPath` may be undefined — the record was created on - // a previous remote-create whose target slot was occupied at - // receive time. In that case stash the bytes for the reconciler - // to write when it places the file; we still update the wire - // fields so the catch-up doesn't replay this version. - // - // The slot may also have been shadowed: the record still claims - // `localPath = P`, but `byLocalPath[P]` now points at a different - // doc (a same-path recreate installed a new owner without - // clearing this record's stale field — same race shape as the - // processRemoteDelete fix above). Writing to a shadowed slot - // would clobber the new owner's bytes. Clear the stale claim now - // so the reconciler treats this record as placement-pending; the - // closing `upsertRecord` no longer touches an existing record's - // localPath, so the clear has to happen explicitly here. - const claimedPath = record.localPath; - const livePath = - claimedPath !== undefined && - this.queue.byLocalPath.get(claimedPath)?.documentId === - record.documentId - ? claimedPath - : undefined; - if (claimedPath !== undefined && livePath === undefined) { - this.logger.debug( - `Remote update for ${record.documentId} at claimed ${claimedPath} ` + - `but slot is shadowed; clearing stale claim and deferring to reconciler` - ); - await this.queue.setLocalPath(record.documentId, undefined); - } - if (livePath !== undefined) { - const currentContent = await this.operations.read(livePath); - // Re-check the entry-time gate immediately before the disk - // mutation. The `await`s on `getDocumentVersionContent` and - // `read` open a TOCTOU window during which a LocalUpdate - // for this doc could have been enqueued by the watcher. If - // we proceeded, `operations.write` would receive - // `expected = current = disk-content-already-with-user-bytes`, - // collapsing the 3-way merge baseline and silently - // overwriting the user's pending edits with `theirs`. - // Re-enqueueing the RemoteChange is the same fix shape as - // the entry-time gate above; the next pass either applies - // it or discards it as stale via the standard check at the - // top of `processRemoteChange`. - if ( - this.queue.hasPendingLocalEventsForDocumentId( - remoteVersion.documentId - ) - ) { - void this.syncRemotelyUpdatedFile({ document: remoteVersion }); - return; - } - // Re-check shadowing as well: the same TOCTOU window - // (between `getDocumentVersionContent` and `read`, plus - // `read` itself) could see a same-path recreate steal the - // slot. If we lost ownership, fall through to the - // pendingPlacementContent stash by re-entering the - // RemoteChange — the next pass observes the updated - // byLocalPath and routes correctly. - if ( - this.queue.byLocalPath.get(livePath)?.documentId !== - record.documentId - ) { - void this.syncRemotelyUpdatedFile({ document: remoteVersion }); - return; - } - await this.operations.write( - livePath, - currentContent, - remoteContent - ); - await this.updateCache( - remoteVersion.vaultUpdateId, - remoteContent, - livePath - ); - } else { - this.pendingPlacementContent.set( - remoteVersion.documentId, - remoteContent - ); - await this.updateCache( - remoteVersion.vaultUpdateId, - remoteContent, - remoteVersion.relativePath - ); - } - - await this.queue.upsertRecord({ - documentId: record.documentId, - parentVersionId: remoteVersion.vaultUpdateId, - remoteRelativePath: remoteVersion.relativePath, - remoteHash: await hash(remoteContent), - localPath: livePath - }); - this.queue.lastSeenUpdateId = remoteVersion.vaultUpdateId; - - this.history.addHistoryEntry({ - status: SyncStatus.SUCCESS, - details: { - type: SyncType.UPDATE, - relativePath: livePath ?? remoteVersion.relativePath - }, - message: "Successfully applied remote update", - author: remoteVersion.userId, - timestamp: new Date(remoteVersion.updatedDate) - }); - } - - private async processRemoteCreateForNewDocument( - remoteVersion: DocumentVersionWithoutContent - ): Promise { - // Quick-write optimization: if the target slot is free right now - // (no disk file, no tracked record), fetch and write inline. The - // catch-up replay leans on this — without it, a freshly-joined - // client would upsert every doc with `localPath = undefined` - // and rely on the reconciler to fetch each one back. - // - // If the slot is occupied, defer: leave `localPath = undefined` - // and let the reconciler place once the slot frees. Per the - // design, no buffering at receive time — the reconciler will - // fetch on demand. - const target = remoteVersion.relativePath; - const slotFree = await this.canPlaceRemoteCreateAt(target); - - let localPath: RelativePath | undefined = undefined; - let remoteHash: string | undefined = undefined; - if (slotFree) { - const remoteContent = - await this.syncService.getDocumentVersionContent({ - documentId: remoteVersion.documentId, - vaultUpdateId: remoteVersion.vaultUpdateId - }); - if (!(await this.canPlaceRemoteCreateAt(target))) { - this.logger.debug( - `Quick-write for ${remoteVersion.documentId} at ${target} ` + - `became blocked while fetching content; deferring to reconciler` - ); - } else { - try { - remoteHash = await hash(remoteContent); - await this.queue.upsertRecord({ - documentId: remoteVersion.documentId, - parentVersionId: remoteVersion.vaultUpdateId, - remoteRelativePath: remoteVersion.relativePath, - remoteHash, - localPath: target + return this.syncLocallyUpdatedFile({ + relativePath }); - const result = await this.operations.create( - target, - remoteContent - ); - const liveRecord = this.queue.getDocumentByDocumentId( - remoteVersion.documentId - ); - localPath = - liveRecord === undefined - ? result.actualPath - : liveRecord.localPath; - await this.updateCache( - remoteVersion.vaultUpdateId, - remoteContent, - localPath ?? remoteVersion.relativePath - ); - } catch (e) { - await this.queue.setLocalPath( - remoteVersion.documentId, - undefined - ); - if (!(e instanceof FileAlreadyExistsError)) { - throw e; - } - // TOCTOU: the slot was free at the pre-check but - // something landed there between then and now. Fall - // through to the no-localPath branch and let the - // reconciler retry placement once the slot frees. - this.logger.debug( - `Quick-write for ${remoteVersion.documentId} at ${target} ` + - `lost a TOCTOU race; deferring to reconciler` - ); - localPath = undefined; } - } - } - if ( - this.queue.getDocumentByDocumentId(remoteVersion.documentId) === - undefined - ) { - await this.queue.upsertRecord({ - documentId: remoteVersion.documentId, - parentVersionId: remoteVersion.vaultUpdateId, - remoteRelativePath: remoteVersion.relativePath, - // `remoteHash` is undefined when we deferred fetching content. - // Consumers (`processLocalUpdate`'s fast-skip, - // `findMatchingFile`'s offline-rename detection) treat - // undefined as "no comparison possible" and fall through to a - // real upload / no-match. The hash gets populated the next - // time we observe a real version (a remote update, or a - // local edit that triggers an upload). - remoteHash, - localPath - }); - } + // Perhaps the file has been moved; let's check by looking at the deleted files + const contentHash = await this.syncQueue.add(async () => { + const contentBytes = + await this.operations.read(relativePath); // this can throw FileNotFoundError + return hash(contentBytes); + }); - this.queue.lastSeenUpdateId = remoteVersion.vaultUpdateId; + if (contentHash == undefined) { + // The file was deleted before we had a chance to read it, no need to sync it here + return; + } - if (localPath !== undefined) { - this.history.addHistoryEntry({ - status: SyncStatus.SUCCESS, - details: { - type: SyncType.CREATE, - relativePath: localPath - }, - message: - "Successfully downloaded remote file which hadn't existed locally", - author: remoteVersion.userId, - timestamp: new Date(remoteVersion.updatedDate) - }); - } - } + const originalFile = findMatchingFile( + contentHash, + locallyPossiblyDeletedFiles + ); + if (originalFile !== undefined) { + // `originalFile` hasn't been deleted but it got moved instead + /* eslint-disable no-restricted-syntax -- Comparing by property, not direct equality */ + locallyPossiblyDeletedFiles = + locallyPossiblyDeletedFiles.filter( + (item) => + item.relativePath !== originalFile.relativePath + ); + /* eslint-enable no-restricted-syntax */ - private async canPlaceRemoteCreateAt( - target: RelativePath - ): Promise { - return ( - !this.queue.hasPendingCreateForPath(target) && - !(await this.operations.exists(target)) && - this.queue.getRecordByLocalPath(target) === undefined + this.logger.debug( + `Document '${originalFile.relativePath}' was not found under its current path in the database but was found under a different path (${relativePath}), scheduling sync to move it` + ); + + // We're outside of the pqueue, so we need to call the public wrapper + return this.syncLocallyUpdatedFile({ + oldPath: originalFile.relativePath, + relativePath + }); + } + + this.logger.debug( + `Document ${relativePath} not found in database, scheduling sync to create it` + ); + // We're outside of the pqueue, so we need to call the public wrapper + return this.syncLocallyCreatedFile(relativePath); + }) + ); + + // this has to happen strictly after the previous awaitAll, as that one + // might have removed some of the documents from the list + await awaitAll( + locallyPossiblyDeletedFiles.map(async ({ relativePath }) => { + this.logger.debug( + `Document ${relativePath} has been deleted locally, scheduling sync to delete it` + ); + + // We're outside of the pqueue, so we need to call the public wrapper + return this.syncLocallyDeletedFile(relativePath); + }) ); } - private async sendUpdate({ - record, - relativePath, - contentBytes - }: { - record: DocumentRecord; - // `undefined` for content-only edits; the server keeps the doc's - // current path. A string is sent only on a user-driven rename. - relativePath: RelativePath | undefined; - contentBytes: Uint8Array; - }): Promise { - const isText = - !isBinary(contentBytes) && - isFileTypeMergable( - relativePath ?? record.remoteRelativePath, - (await this.serverConfig.getConfig()).mergeableFileExtensions - ); + /** + * Create fake documents in the database for all files that are present locally + * and also exist remotely. This will stop the subequent syncs from duplicating + * the documents by creating the same documents from multiple clients. + */ + private async createFakeDocumentsFromRemoteState(): Promise { + if (this.database.getHasInitialSyncCompleted()) { + return; + } - const cachedVersion = this.contentCache.get(record.parentVersionId); + const [allLocalFiles, remote] = await awaitAll([ + this.operations.listFilesRecursively(), + this.syncQueue.add(async () => this.syncService.getAll()) + ]); - if (isText && cachedVersion !== undefined) { - return this.syncService.putText({ - documentId: record.documentId, - parentVersionId: record.parentVersionId, - relativePath, - content: diff( - new TextDecoder().decode(cachedVersion), - new TextDecoder().decode(contentBytes) + if (remote !== undefined) { + remote.latestDocuments + .filter( + (remoteDocument) => + allLocalFiles.includes(remoteDocument.relativePath) && + !remoteDocument.isDeleted && + this.database.getDocumentByDocumentId( + remoteDocument.documentId + ) === undefined ) - }); + .forEach((remoteDocument) => { + this.database.createNewEmptyDocument( + remoteDocument.documentId, + remoteDocument.vaultUpdateId, + remoteDocument.relativePath + ); + }); } - return this.syncService.putBinary({ - documentId: record.documentId, - parentVersionId: record.parentVersionId, - relativePath, - contentBytes - }); - } - - private async updateCache( - updateId: VaultUpdateId, - contentBytes: Uint8Array, - filePath: RelativePath - ): Promise { - if ( - isFileTypeMergable( - filePath, - (await this.serverConfig.getConfig()).mergeableFileExtensions - ) && - !isBinary(contentBytes) - ) { - this.contentCache.put(updateId, contentBytes); - } - } - - private notifyRemainingOperationsChanged(): void { - const currentCount = this.queue.pendingUpdateCount; - if (this.previousRemainingOperationsCount !== currentCount) { - this.previousRemainingOperationsCount = currentCount; - this.onRemainingOperationsCountChanged.trigger(currentCount); - } + this.database.setHasInitialSyncCompleted(true); } } diff --git a/frontend/sync-client/src/sync-operations/types.ts b/frontend/sync-client/src/sync-operations/types.ts deleted file mode 100644 index 80a64cd7..00000000 --- a/frontend/sync-client/src/sync-operations/types.ts +++ /dev/null @@ -1,74 +0,0 @@ -import type { DocumentVersionWithoutContent } from "../services/types/DocumentVersionWithoutContent"; - -export type VaultUpdateId = number; -export type DocumentId = string; -export type RelativePath = string; - -export interface DocumentRecord { - documentId: DocumentId; - parentVersionId: VaultUpdateId; - // Hash of the last server version this client has observed for the doc. - // `undefined` means we have a record but haven't actually seen content - // yet — typically a remote-create whose target slot was occupied at - // receive time, where we deliberately defer the fetch to the reconciler. - // Consumers should treat undefined as "no comparison possible" (the - // fast-skip in `processLocalUpdate` falls through to a real upload). - remoteHash: string | undefined; - remoteRelativePath: RelativePath; - // Where the doc's file currently lives on disk. `undefined` means the doc - // has no local file yet — happens for a remote create whose - // `remoteRelativePath` slot was occupied at receive time. The reconciler - // will place the file once the slot frees, fetching content from the - // server on demand. - localPath: RelativePath | undefined; -} - -export interface StoredSyncState { - schemaVersion: number; - documents: DocumentRecord[] | undefined; - lastSeenUpdateId: VaultUpdateId | undefined; -} - -export enum SyncEventType { - LocalCreate = "local-create", - LocalUpdate = "local-update", // includes both content and path changes - LocalDelete = "local-delete", - RemoteChange = "remote-change" // includes every type of create/update/delete coming from the server -} - -export type FileSyncEvent = - | { type: SyncEventType.LocalCreate; path: RelativePath } - | { - type: SyncEventType.LocalUpdate; - path: RelativePath; - oldPath?: RelativePath; // oldPath is undefined for content changes - } - | { type: SyncEventType.LocalDelete; path: RelativePath } - | { - type: SyncEventType.RemoteChange; - remoteVersion: DocumentVersionWithoutContent; - }; - -export type SyncEvent = - | { - type: SyncEventType.LocalCreate; - path: RelativePath; // current path on disk; mutated in place by `updatePendingCreatePath` when the user renames mid-flight - isProcessing: boolean; // true once the wire loop has started this create; deletes after that must wait for the server ack - resolvers: PromiseWithResolvers; - } - | { - type: SyncEventType.LocalUpdate; - documentId: DocumentId | Promise; // if it's a promise, the promise is fulfilled once the document's create event is processed - path: RelativePath; // current path on disk - originalPath: RelativePath; // original path on disk when the event was queued - isUserRename: boolean; // true iff this event was queued because the user renamed the file - } - | { - type: SyncEventType.LocalDelete; - documentId: DocumentId | Promise; // if it's a promise, the promise is fulfilled once the document's create event is processed - path: RelativePath; // only used for showing on the UI - } - | { - type: SyncEventType.RemoteChange; - remoteVersion: DocumentVersionWithoutContent; - }; diff --git a/frontend/sync-client/src/sync-operations/unrestricted-syncer.ts b/frontend/sync-client/src/sync-operations/unrestricted-syncer.ts new file mode 100644 index 00000000..e3964d30 --- /dev/null +++ b/frontend/sync-client/src/sync-operations/unrestricted-syncer.ts @@ -0,0 +1,596 @@ +import type { + Database, + DocumentRecord, + RelativePath +} from "../persistence/database"; + +import { diff } from "reconcile-text"; +import type { SyncService } from "../services/sync-service"; +import type { Logger } from "../tracing/logger"; +import type { + CommonHistoryEntry, + SyncCreateDetails, + SyncDeleteDetails, + SyncDetails, + SyncHistory, + SyncMovedDetails, + SyncUpdateDetails +} from "../tracing/sync-history"; +import { SyncStatus, SyncType } from "../tracing/sync-history"; +import { EMPTY_HASH, hash } from "../utils/hash"; + +import { base64ToBytes } from "byte-base64"; +import type { Settings } from "../persistence/settings"; +import type { FileOperations } from "../file-operations/file-operations"; +import { createPromise } from "../utils/create-promise"; +import { FileNotFoundError } from "../file-operations/file-not-found-error"; +import { SyncResetError } from "../services/sync-reset-error"; +import { globsToRegexes } from "../utils/globs-to-regexes"; +import type { DocumentVersion } from "../services/types/DocumentVersion"; +import type { DocumentUpdateResponse } from "../services/types/DocumentUpdateResponse"; +import type { DocumentVersionWithoutContent } from "../services/types/DocumentVersionWithoutContent"; +import type { FixedSizeDocumentCache } from "../utils/data-structures/fix-sized-cache"; +import { isFileTypeMergable } from "../utils/is-file-type-mergable"; +import { isBinary } from "../utils/is-binary"; +import type { ServerConfig } from "../services/server-config"; + +export class UnrestrictedSyncer { + private ignorePatterns: RegExp[]; + + public constructor( + private readonly logger: Logger, + private readonly database: Database, + private readonly settings: Settings, + private readonly syncService: SyncService, + private readonly operations: FileOperations, + private readonly history: SyncHistory, + private readonly contentCache: FixedSizeDocumentCache, + private readonly serverConfig: ServerConfig + ) { + this.ignorePatterns = globsToRegexes( + this.settings.getSettings().ignorePatterns, + this.logger + ); + + this.settings.onSettingsChanged.add((newSettings) => { + this.ignorePatterns = globsToRegexes( + newSettings.ignorePatterns, + this.logger + ); + }); + } + + public async unrestrictedSyncLocallyCreatedFile( + document: DocumentRecord + ): Promise { + const updateDetails: SyncCreateDetails = { + type: SyncType.CREATE, + relativePath: document.relativePath + }; + + return this.executeSync(updateDetails, async () => { + const originalRelativePath = document.relativePath; + if (document.isDeleted) { + this.logger.debug( + `Document ${originalRelativePath} has been already deleted, no need to create it` + ); + return; + } + + const contentBytes = + await this.operations.read(originalRelativePath); // this can throw FileNotFoundError + const contentHash = hash(contentBytes); + + const response = await this.syncService.create({ + documentId: document.documentId, + relativePath: originalRelativePath, + contentBytes + }); + + // In case a document with the same name (but different ID) had existed remotely that we haven't known about + if (response.relativePath != originalRelativePath) { + this.logger.debug( + `Document ${originalRelativePath} has been created remotely at a different path: ${response.relativePath}, moving it locally` + ); + await this.operations.move( + document.relativePath, + response.relativePath + ); // this can throw FileNotFoundError + } + + this.database.updateDocumentMetadata( + { + parentVersionId: response.vaultUpdateId, + hash: contentHash, + remoteRelativePath: response.relativePath + }, + document + ); + + this.database.addSeenUpdateId(response.vaultUpdateId); + await this.updateCache( + response.vaultUpdateId, + contentBytes, + response.relativePath + ); + + this.history.addHistoryEntry({ + status: SyncStatus.SUCCESS, + details: updateDetails, + message: `Successfully uploaded locally created file` + }); + }); + } + + public async unrestrictedSyncLocallyDeletedFile( + document: DocumentRecord + ): Promise { + const updateDetails: SyncDeleteDetails = { + type: SyncType.DELETE, + relativePath: document.relativePath + }; + + await this.executeSync(updateDetails, async () => { + const response = await this.syncService.delete({ + documentId: document.documentId, + relativePath: document.relativePath + }); + + this.database.updateDocumentMetadata( + { + parentVersionId: response.vaultUpdateId, + hash: EMPTY_HASH, + remoteRelativePath: document.relativePath + }, + document + ); + + this.database.addSeenUpdateId(response.vaultUpdateId); + + this.history.addHistoryEntry({ + status: SyncStatus.SUCCESS, + details: updateDetails, + message: `Successfully deleted locally deleted file on the server`, + author: response.userId + }); + }); + } + + public async unrestrictedSyncLocallyUpdatedFile({ + oldPath, + document, + // We use the same code path for both local and remote updates. We need to force the update + // if there are no local changes but we know that the remote version is newer. + force = false + }: { + oldPath?: RelativePath; + force?: boolean; + document: DocumentRecord; + }): Promise { + const updateDetails: SyncUpdateDetails | SyncMovedDetails = + oldPath !== undefined + ? { + type: SyncType.MOVE, + relativePath: document.relativePath, + movedFrom: oldPath + } + : { + type: SyncType.UPDATE, + relativePath: document.relativePath + }; + + await this.executeSync(updateDetails, async () => { + const originalRelativePath = document.relativePath; + + if (document.isDeleted || document.metadata === undefined) { + this.logger.debug( + `Document ${document.relativePath} has been already deleted, no need to update it` + ); + return; + } + + const contentBytes = await this.operations.read( + document.relativePath + ); // this can throw FileNotFoundError + let contentHash = hash(contentBytes); + + const areThereLocalChanges = !( + document.metadata.hash === contentHash && oldPath === undefined + ); + + let response: DocumentVersion | DocumentUpdateResponse | undefined = + undefined; + + if (areThereLocalChanges) { + const isText = + !isBinary(contentBytes) && + isFileTypeMergable( + document.relativePath, + (await this.serverConfig.getConfig()) + .mergeableFileExtensions + ); + const cachedVersion = this.contentCache.get( + document.metadata.parentVersionId + ); + + response = + isText && cachedVersion !== undefined + ? await this.syncService.putText({ + documentId: document.documentId, + parentVersionId: + document.metadata.parentVersionId, + relativePath: document.relativePath, + content: diff( + new TextDecoder().decode(cachedVersion), + new TextDecoder().decode(contentBytes) + ) + }) + : await this.syncService.putBinary({ + documentId: document.documentId, + parentVersionId: + document.metadata.parentVersionId, + relativePath: document.relativePath, + contentBytes + }); + } else { + if (!force) { + this.logger.debug( + `File hash of ${document.relativePath} matches with last synced version and the path hasn't changed; no need to sync` + ); + return; + } + + response = await this.syncService.get({ + documentId: document.documentId + }); + } + + // `document` is mutable and reflects the latest state in the local database + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (document.isDeleted) { + this.logger.info( + `Document ${document.relativePath} has been deleted before we could finish updating it` + ); + this.database.addSeenUpdateId(response.vaultUpdateId); + return; + } + + if ( + // `Syncer` creates fake local document metadata for all remote docs with invalid hashes. The parent IDs will likely match + // the latest versions so we still need to update the local versions to turn the fakes into real metadata. + document.metadata.parentVersionId > response.vaultUpdateId + ) { + this.logger.debug( + `Document ${document.relativePath} is already more up to date than the fetched version` + ); + this.database.addSeenUpdateId(response.vaultUpdateId); // in case the previous `vaultUpdateId` update hasn't made it through + return; + } + + if (response.isDeleted) { + return this.applyRemoteDeleteLocally(document, response); + } + + let actualPath = document.relativePath; + + if (response.relativePath != originalRelativePath) { + actualPath = response.relativePath; + // Make sure to update the remote relative path to avoid uploading + // the file as a result of this filesystem event. + document.metadata.remoteRelativePath = response.relativePath; + await this.operations.move( + document.relativePath, + response.relativePath + ); // this can throw FileNotFoundError + } + + if (!("type" in response) || response.type === "MergingUpdate") { + const responseBytes = base64ToBytes(response.contentBase64); + contentHash = hash(responseBytes); + + this.database.updateDocumentMetadata( + { + parentVersionId: response.vaultUpdateId, + hash: contentHash, + remoteRelativePath: response.relativePath + }, + document + ); + await this.operations.write( + actualPath, + contentBytes, + responseBytes + ); + await this.updateCache( + response.vaultUpdateId, + responseBytes, + actualPath + ); + + if (!force) { + this.history.addHistoryEntry({ + status: SyncStatus.SUCCESS, + details: updateDetails, + message: `The file we updated had been updated remotely, so we downloaded the merged version` + }); + } + } else { + this.database.updateDocumentMetadata( + { + parentVersionId: response.vaultUpdateId, + hash: contentHash, + remoteRelativePath: response.relativePath + }, + document + ); + await this.updateCache( + response.vaultUpdateId, + contentBytes, + actualPath + ); + } + + this.database.addSeenUpdateId(response.vaultUpdateId); + + const actualUpdateDetails: SyncUpdateDetails | SyncMovedDetails = + oldPath !== undefined || + response.relativePath != originalRelativePath + ? { + type: SyncType.MOVE, + relativePath: response.relativePath, + movedFrom: originalRelativePath + } + : { + type: SyncType.UPDATE, + relativePath: response.relativePath + }; + + if (areThereLocalChanges) { + this.history.addHistoryEntry({ + status: SyncStatus.SUCCESS, + details: actualUpdateDetails, + message: `Successfully uploaded locally updated file to the server`, + author: response.userId + }); + } else { + this.history.addHistoryEntry({ + status: SyncStatus.SUCCESS, + details: actualUpdateDetails, + message: `Successfully downloaded remotely updated file from the server`, + author: response.userId, + timestamp: new Date(response.updatedDate) + }); + } + }); + } + + public async unrestrictedSyncRemotelyUpdatedFile( + remoteVersion: DocumentVersionWithoutContent, + document?: DocumentRecord + ): Promise { + const updateDetails: SyncCreateDetails = { + type: SyncType.CREATE, + relativePath: remoteVersion.relativePath + }; + + await this.executeSync(updateDetails, async () => { + if (document?.metadata !== undefined) { + // If the file exists locally, let's pretend the user has updated it + // and deal with remote update/deletion within `unrestrictedSyncLocallyUpdatedFile` + if ( + document.metadata.parentVersionId >= + remoteVersion.vaultUpdateId + ) { + this.logger.debug( + `Document ${remoteVersion.relativePath} is already at least as up to date as the fetched version` + ); + + return; + } + + return this.unrestrictedSyncLocallyUpdatedFile({ + document, + force: true + }); + } else if (remoteVersion.isDeleted) { + // Either the document hasn't made it to us before and therefore we don't need to delete it, + // or we already have it, in which case the preceeding if would've dealt with it + this.logger.debug( + `Document ${remoteVersion.relativePath} has been deleted remotely, no need to sync` + ); + return; + } + + // Don't download oversized files + const historyEntryForSkippedOversizedFile = + this.getHistoryEntryForSkippedOversizedFile( + remoteVersion.contentSize, + remoteVersion.relativePath + ); + if (historyEntryForSkippedOversizedFile !== undefined) { + this.history.addHistoryEntry( + historyEntryForSkippedOversizedFile + ); + return; + } + + const contentBytes = + await this.syncService.getDocumentVersionContent({ + documentId: remoteVersion.documentId, + vaultUpdateId: remoteVersion.vaultUpdateId + }); + + // We're trying to create an entirely new document that didn't exist locally + document = this.database.getDocumentByDocumentId( + remoteVersion.documentId + ); + // It can happen that a concurrent sync operation has already created the document, so we can bail here + if (document !== undefined) { + this.logger.debug( + `Document ${remoteVersion.relativePath} has already been created locally, no need to create it again` + ); + return; + } + + await this.operations.ensureClearPath(remoteVersion.relativePath); + + const [promise, resolve] = createPromise(); + this.database.updateDocumentMetadata( + { + parentVersionId: remoteVersion.vaultUpdateId, + hash: hash(contentBytes), + remoteRelativePath: remoteVersion.relativePath + }, + this.database.createNewPendingDocument( + remoteVersion.documentId, + remoteVersion.relativePath, + promise + ) + ); + + await this.operations.create( + remoteVersion.relativePath, + contentBytes + ); + await this.updateCache( + remoteVersion.vaultUpdateId, + contentBytes, + remoteVersion.relativePath + ); + + resolve(); + this.database.removeDocumentPromise(promise); + + this.history.addHistoryEntry({ + status: SyncStatus.SUCCESS, + details: updateDetails, + message: `Successfully downloaded remote file which hadn't existed locally`, + author: remoteVersion.userId, + timestamp: new Date(remoteVersion.updatedDate) + }); + }); + } + + public async executeSync( + details: SyncDetails, + fn: () => Promise + ): Promise { + for (const pattern of this.ignorePatterns) { + if (pattern.test(details.relativePath)) { + this.logger.debug( + `File '${details.relativePath}' is ignored by the ignore pattern: ${pattern}` + ); + return; // bail without SKIPPED status because we were told to ignore this file and we shouldn't clutter up the history + } + } + + try { + // Only check the size of files which already exist locally. + if (await this.operations.exists(details.relativePath)) { + const sizeInBytes = await this.operations.getFileSize( + details.relativePath + ); + const historyEntryForSkippedOversizedFile = + this.getHistoryEntryForSkippedOversizedFile( + sizeInBytes, + details.relativePath + ); + if (historyEntryForSkippedOversizedFile !== undefined) { + this.history.addHistoryEntry( + historyEntryForSkippedOversizedFile + ); + return; + } + } + + return await fn(); + } catch (e) { + if (e instanceof FileNotFoundError) { + // A subsequent sync operation must have been creating to deal with this + this.logger.info( + `Skiping file '${details.relativePath}' because it no longer exists when trying to ${details.type.toLocaleLowerCase()} it` + ); + return; + } + if (e instanceof SyncResetError) { + this.logger.info( + `Interrupting sync operation because of a reset` + ); + return; + } else { + this.history.addHistoryEntry({ + status: SyncStatus.ERROR, + details, + message: `Failed to sync file '${details.relativePath}' because of ${e} when trying to ${details.type.toLocaleLowerCase()} it` + }); + throw e; + } + } + } + + private getHistoryEntryForSkippedOversizedFile( + sizeInBytes: number, + relativePath: RelativePath + ): CommonHistoryEntry | undefined { + const sizeInMB = Math.round(sizeInBytes / 1024 / 1024); + const { maxFileSizeMB } = this.settings.getSettings(); + if (sizeInMB > maxFileSizeMB) { + return { + status: SyncStatus.SKIPPED, + details: { + type: SyncType.SKIPPED, + relativePath + }, + message: `File size of ${sizeInMB} MB exceeds the maximum file size limit of ${ + maxFileSizeMB + } MB` + }; + } + } + + private async updateCache( + updateId: number, + contentBytes: Uint8Array, + filePath: RelativePath + ): Promise { + if ( + isFileTypeMergable( + filePath, + (await this.serverConfig.getConfig()).mergeableFileExtensions + ) && + !isBinary(contentBytes) + ) { + this.contentCache.put(updateId, contentBytes); + } + } + + private async applyRemoteDeleteLocally( + document: DocumentRecord, + response: DocumentVersion | DocumentUpdateResponse + ): Promise { + this.history.addHistoryEntry({ + status: SyncStatus.SUCCESS, + details: { + type: SyncType.DELETE, + relativePath: document.relativePath + }, + message: "File has been deleted remotely, so we deleted it locally", + author: response.userId, + timestamp: new Date(response.updatedDate) + }); + + this.database.delete(document.relativePath); + this.database.updateDocumentMetadata( + { + parentVersionId: response.vaultUpdateId, + hash: EMPTY_HASH, + remoteRelativePath: response.relativePath + }, + document + ); + + await this.operations.delete(document.relativePath); + + this.database.addSeenUpdateId(response.vaultUpdateId); + } +} diff --git a/frontend/sync-client/src/tracing/sync-history.ts b/frontend/sync-client/src/tracing/sync-history.ts index c0d32032..31f77283 100644 --- a/frontend/sync-client/src/tracing/sync-history.ts +++ b/frontend/sync-client/src/tracing/sync-history.ts @@ -2,7 +2,7 @@ import { MAX_HISTORY_ENTRY_COUNT, TIMEOUT_FOR_MERGING_HISTORY_ENTRIES_IN_SECONDS } from "../consts"; -import type { RelativePath } from "../sync-operations/types"; +import type { RelativePath } from "../persistence/database"; import type { Logger } from "./logger"; import { removeFromArray } from "../utils/remove-from-array"; import { EventListeners } from "../utils/data-structures/event-listeners"; @@ -28,7 +28,7 @@ export interface SyncDeleteDetails { relativePath: RelativePath; } -interface SyncSkippedDetails { +export interface SyncSkippedDetails { type: SyncType.SKIPPED; relativePath: RelativePath; } @@ -40,15 +40,12 @@ export type SyncDetails = | SyncMovedDetails | SyncSkippedDetails; -export interface HistoryEntry { +export interface CommonHistoryEntry { status: SyncStatus; message: string; details: SyncDetails; - timestamp: Date; - // `author` is the server-side user id and only exists for entries that - // round-tripped through the server. Local-only entries (e.g. SKIPPED) - // legitimately have no author. author?: string; + timestamp?: Date; } export enum SyncType { @@ -65,6 +62,8 @@ export enum SyncStatus { SKIPPED = "SKIPPED" } +export type HistoryEntry = CommonHistoryEntry & { timestamp: Date }; + export interface HistoryStats { success: number; error: number; @@ -89,25 +88,30 @@ export class SyncHistory { } /** - * Insert the entry at the beginning of the history list. If the entry - * already in the list, it will get moved to the beginning and updated. - * - * If the entry list is too long, the oldest entry will be removed. - */ - public addHistoryEntry(entry: HistoryEntry): void { - const candidate = this.findSimilarRecentUpdateEntry(entry); + * Insert the entry at the beginning of the history list. If the entry + * already in the list, it will get moved to the beginning and updated. + * + * If the entry list is too long, the oldest entry will be removed. + */ + public addHistoryEntry(entry: CommonHistoryEntry): void { + const historyEntry = { + ...entry, + timestamp: entry.timestamp ?? new Date() + }; + + const candidate = this.findSimilarRecentUpdateEntry(historyEntry); if (candidate !== undefined) { removeFromArray(this._entries, candidate); } // Insert the entry at the beginning - this._entries.unshift(entry); + this._entries.unshift(historyEntry); if (this._entries.length > MAX_HISTORY_ENTRY_COUNT) { this._entries.pop(); } - this.updateSuccessCount(entry); + this.updateSuccessCount(historyEntry); } public reset(): void { diff --git a/frontend/sync-client/src/utils/await-all.ts b/frontend/sync-client/src/utils/await-all.ts index 43e06ce6..9406a6b8 100644 --- a/frontend/sync-client/src/utils/await-all.ts +++ b/frontend/sync-client/src/utils/await-all.ts @@ -9,7 +9,7 @@ type ResolvedTuple = { export const awaitAll = async ( promises: PromiseTuple ): Promise> => { - // eslint-disable-next-line no-restricted-properties, @typescript-eslint/await-thenable + // eslint-disable-next-line no-restricted-properties const result = await Promise.allSettled(promises); for (const res of result) { if (res.status === "rejected") { diff --git a/frontend/sync-client/src/utils/create-client-id.ts b/frontend/sync-client/src/utils/create-client-id.ts index 03dc2ae9..cfa132da 100644 --- a/frontend/sync-client/src/utils/create-client-id.ts +++ b/frontend/sync-client/src/utils/create-client-id.ts @@ -1,3 +1,5 @@ +import { v4 as uuidv4 } from "uuid"; + export function createClientId(): string { // @ts-expect-error, injected by webpack const packageVersion = __CURRENT_VERSION__; // eslint-disable-line @@ -6,8 +8,8 @@ export function createClientId(): string { typeof navigator !== "undefined" ? navigator.platform // eslint-disable-line @typescript-eslint/no-deprecated : typeof process !== "undefined" - ? process.platform - : "unknown"; + ? process.platform + : "unknown"; - return `vault-link/${packageVersion} (${Math.round(Math.random() * 1e10)}; ${platform})`; + return `vault-link/${packageVersion} (${uuidv4()}; ${platform})`; } diff --git a/frontend/sync-client/src/utils/create-promise.ts b/frontend/sync-client/src/utils/create-promise.ts new file mode 100644 index 00000000..a49196ee --- /dev/null +++ b/frontend/sync-client/src/utils/create-promise.ts @@ -0,0 +1,25 @@ +type ResolveFunction = undefined extends T + ? (value?: T) => unknown + : (value: T) => unknown; + +/** + * A type-safe utility function to create a Promise with resolve and reject functions. + * @returns A tuple containing a Promise, a resolve function, and a reject function. + */ +export function createPromise(): [ + Promise, + ResolveFunction, + (error: unknown) => unknown +] { + let resolve: undefined | ResolveFunction = undefined; + let reject: undefined | ((error: unknown) => unknown) = undefined; + + const creationPromise = new Promise( + (resolve_, reject_) => + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + ((resolve = resolve_ as ResolveFunction), (reject = reject_)) + ); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return [creationPromise, resolve!, reject!]; +} diff --git a/frontend/sync-client/src/utils/data-structures/event-listeners.ts b/frontend/sync-client/src/utils/data-structures/event-listeners.ts index 420e4e63..e08ca65e 100644 --- a/frontend/sync-client/src/utils/data-structures/event-listeners.ts +++ b/frontend/sync-client/src/utils/data-structures/event-listeners.ts @@ -13,64 +13,56 @@ export class EventListeners any> { } /** - * Adds a new listener to the collection. - * - * @param listener The listener callback to add - * @returns An unsubscribe function that removes this listener when called - */ + * Adds a new listener to the collection. + * + * @param listener The listener callback to add + * @returns An unsubscribe function that removes this listener when called + */ public add(listener: TListener): () => void { this.listeners.push(listener); return () => this.remove(listener); } /** - * Removes a listener from the collection. - * - * @param listener The listener callback to remove - * @returns true if the listener was found and removed, false otherwise - */ + * Removes a listener from the collection. + * + * @param listener The listener callback to remove + * @returns true if the listener was found and removed, false otherwise + */ public remove(listener: TListener): boolean { return removeFromArray(this.listeners, listener); } /** - * Triggers all listeners synchronously with the provided arguments. - * Any returned promises are ignored. Use triggerAsync() to await them. - * - * @param args The arguments to pass to each listener - */ + * Triggers all listeners synchronously with the provided arguments. + * Any returned promises are ignored. Use triggerAsync() to await them. + * + * @param args The arguments to pass to each listener + */ public trigger(...args: Parameters): void { - const snapshot = this.listeners.slice(); - for (const listener of snapshot) { - // allow removing listeners during the trigger loop - if (!this.listeners.includes(listener)) { - continue; - } + this.listeners.forEach((listener) => { listener(...args); - } + }); } /** - * Triggers all listeners and awaits any promises they return. - * Synchronous listeners are called immediately, and any async listeners - * are awaited in parallel. - * - * @param args The arguments to pass to each listener - */ + * Triggers all listeners and awaits any promises they return. + * Synchronous listeners are called immediately, and any async listeners + * are awaited in parallel. + * + * @param args The arguments to pass to each listener + */ public async triggerAsync(...args: Parameters): Promise { - const snapshot = this.listeners.slice(); - const promises: Promise[] = []; - for (const listener of snapshot) { - if (!this.listeners.includes(listener)) { - continue; - } - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const result = listener(...args); - if (result instanceof Promise) { - promises.push(result); - } - } - await awaitAll(promises); + await awaitAll( + this.listeners + .map((listener) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + return listener(...args); + }) + .filter((result): result is Promise => { + return result instanceof Promise; + }) + ); } public clear(): void { diff --git a/frontend/sync-client/src/utils/data-structures/fix-sized-cache.ts b/frontend/sync-client/src/utils/data-structures/fix-sized-cache.ts index 44a71dc8..51ad41c1 100644 --- a/frontend/sync-client/src/utils/data-structures/fix-sized-cache.ts +++ b/frontend/sync-client/src/utils/data-structures/fix-sized-cache.ts @@ -1,6 +1,6 @@ // Implements an in-memory fixed-size cache for document contents, -import type { VaultUpdateId } from "../../sync-operations/types"; +import type { VaultUpdateId } from "../../persistence/database"; // Doubly-linked list node for O(1) LRU operations class LRUNode { diff --git a/frontend/sync-client/src/utils/data-structures/locks.test.ts b/frontend/sync-client/src/utils/data-structures/locks.test.ts index c98bda0b..9beb867a 100644 --- a/frontend/sync-client/src/utils/data-structures/locks.test.ts +++ b/frontend/sync-client/src/utils/data-structures/locks.test.ts @@ -1,24 +1,22 @@ import { describe, it, beforeEach } from "node:test"; import assert from "node:assert"; import { Logger } from "../../tracing/logger"; -import type { RelativePath } from "../../sync-operations/types"; +import type { RelativePath } from "../../persistence/database"; import { Locks } from "./locks"; import { awaitAll } from "../await-all"; import { sleep } from "../sleep"; -import { SyncResetError } from "../../errors/sync-reset-error"; +import { SyncResetError } from "../../services/sync-reset-error"; describe("withLock", () => { const testPath: RelativePath = "test/document/path"; const testPath2: RelativePath = "test/document/path2"; - const testPath3: RelativePath = "test/document/path3"; - const logger = new Logger(); // eslint-disable-next-line @typescript-eslint/init-declarations let locks: Locks; beforeEach(() => { - locks = new Locks("locks-test", logger); + locks = new Locks(logger); }); it("should execute function with single key lock", async () => { @@ -58,32 +56,22 @@ describe("withLock", () => { it("should sort multiple keys to prevent deadlocks", async () => { const executionOrder: string[] = []; - await locks.waitForLock(testPath); + // Start two concurrent operations with keys in different orders + const promise1 = locks.withLock([testPath2, testPath], async () => { + executionOrder.push("operation1-start"); + await sleep(50); + executionOrder.push("operation1-end"); + return "result1"; + }); - const promise = awaitAll([ - locks.withLock([testPath2, testPath3, testPath], async () => { - executionOrder.push("operation1-start"); - executionOrder.push("operation1-end"); - return "result1"; - }), + const promise2 = locks.withLock([testPath, testPath2], async () => { + executionOrder.push("operation2-start"); + await sleep(50); + executionOrder.push("operation2-end"); + return "result2"; + }); - locks.withLock([testPath3, testPath, testPath2], async () => { - executionOrder.push("operation2-start"); - executionOrder.push("operation2-end"); - return "result2"; - }) - ]); - - locks.unlock(testPath); - - const [result1, result2] = await Promise.race([ - promise, - new Promise((_, reject) => { - setTimeout(() => { - reject(new Error("Deadlock detected")); - }, 1000); - }) - ]); + const [result1, result2] = await awaitAll([promise1, promise2]); assert.strictEqual(result1, "result1"); assert.strictEqual(result2, "result2"); @@ -246,14 +234,13 @@ describe("withLock", () => { describe("reset", () => { const testPath: RelativePath = "test/document/path"; - const testPath2: RelativePath = "test/document/path2"; const logger = new Logger(); // eslint-disable-next-line @typescript-eslint/init-declarations let locks: Locks; beforeEach(() => { - locks = new Locks("locks-test", logger); + locks = new Locks(logger); }); it("should reject pending waiters with SyncResetError while running operation completes", async () => { @@ -302,38 +289,4 @@ describe("reset", () => { const result = await locks.withLock(testPath, () => "success"); assert.strictEqual(result, "success"); }); - - it("should release partially acquired locks when reset interrupts multi-key acquisition", async () => { - // Hold testPath2 so multi-key acquisition will block on it - await locks.waitForLock(testPath2); - - // Start multi-key lock that will acquire testPath first, then block on testPath2 - const multiKeyPromise = locks.withLock( - [testPath, testPath2], - async () => "multi" - ); - void multiKeyPromise.catch(() => {}); // eslint-disable-line @typescript-eslint/no-empty-function - - // Wait for the multi-key operation to acquire testPath and start waiting on testPath2 - await sleep(10); - - // Reset should reject the waiting operation - locks.reset(); - - await assert.rejects(multiKeyPromise, (err: Error) => { - assert.ok(err instanceof SyncResetError); - return true; - }); - - // The key that was already acquired (testPath) should now be released - // This would hang/timeout if the lock was leaked - const result = await Promise.race([ - locks.withLock(testPath, () => "success"), - sleep(100).then(() => { - throw new Error("Lock was not released - deadlock detected"); - }) - ]); - - assert.strictEqual(result, "success"); - }); }); diff --git a/frontend/sync-client/src/utils/data-structures/locks.ts b/frontend/sync-client/src/utils/data-structures/locks.ts index 99c33075..e55c76b0 100644 --- a/frontend/sync-client/src/utils/data-structures/locks.ts +++ b/frontend/sync-client/src/utils/data-structures/locks.ts @@ -1,5 +1,6 @@ -import { SyncResetError } from "../../errors/sync-reset-error"; +import { SyncResetError } from "../../services/sync-reset-error"; import type { Logger } from "../../tracing/logger"; +import { awaitAll } from "../await-all"; /** * Manages exclusive locks on items to prevent concurrent modifications. @@ -7,53 +8,47 @@ import type { Logger } from "../../tracing/logger"; * * @template T The type of the key used for locking */ -/** Waiter entry with callbacks */ -interface WaiterEntry { - resolve: () => unknown; - reject: (err: unknown) => unknown; -} - export class Locks { /** Currently locked keys */ private readonly locked = new Set(); - /** Queue of waiters for each key */ - private readonly waiters = new Map(); + /** Queue of resolve functions waiting for each key */ + private readonly waiters = new Map< + T, + [() => unknown, (err: unknown) => unknown][] + >(); - public constructor( - private readonly name: string, - private readonly logger?: Logger - ) {} + public constructor(private readonly logger?: Logger) {} /** - * Executes a function while holding exclusive locks on one or more keys. - * - * This method ensures that the provided function runs with exclusive access to the - * specified key(s). Multiple keys are sorted to prevent deadlocks when different - * operations request the same keys in different orders. - * - * @template R The return type of the function to execute - * @param keyOrKeys A single key or array of keys to lock during function execution - * @param fn The function to execute while holding the lock(s). Can be sync or async. - * @returns A Promise that resolves to the return value of the executed function - * - * @example - * ```typescript - * // Lock a single key - * const result = await locks.withLock('file1', () => { - * // Critical section - only one operation can access 'file1' at a time - * return processFile('file1'); - * }); - * - * // Lock multiple keys (prevents deadlocks through consistent ordering) - * await locks.withLock(['file1', 'file2'], async () => { - * // Critical section - exclusive access to both files - * await moveFile('file1', 'file2'); - * }); - * ``` - * - * @throws Any error thrown by the provided function will be propagated after locks are released - */ + * Executes a function while holding exclusive locks on one or more keys. + * + * This method ensures that the provided function runs with exclusive access to the + * specified key(s). Multiple keys are sorted to prevent deadlocks when different + * operations request the same keys in different orders. + * + * @template R The return type of the function to execute + * @param keyOrKeys A single key or array of keys to lock during function execution + * @param fn The function to execute while holding the lock(s). Can be sync or async. + * @returns A Promise that resolves to the return value of the executed function + * + * @example + * ```typescript + * // Lock a single key + * const result = await locks.withLock('file1', () => { + * // Critical section - only one operation can access 'file1' at a time + * return processFile('file1'); + * }); + * + * // Lock multiple keys (prevents deadlocks through consistent ordering) + * await locks.withLock(['file1', 'file2'], async () => { + * // Critical section - exclusive access to both files + * await moveFile('file1', 'file2'); + * }); + * ``` + * + * @throws Any error thrown by the provided function will be propagated after locks are released + */ public async withLock( keyOrKeys: T | T[], fn: () => R | Promise @@ -64,17 +59,12 @@ export class Locks { const uniqueKeys = Array.from(new Set(keys)); uniqueKeys.sort((a, b) => String(a).localeCompare(String(b))); // Ensure consistent order to prevent deadlocks - const lockedKeys = []; - try { - for (const key of uniqueKeys) { - // Must acquire locks in-order (not concurrently) to prevent deadlocks - await this.waitForLock(key); - lockedKeys.push(key); - } + await awaitAll(uniqueKeys.map(async (key) => this.waitForLock(key))); + try { return await fn(); } finally { - lockedKeys.forEach((key) => { + uniqueKeys.forEach((key) => { this.unlock(key); }); } @@ -84,7 +74,7 @@ export class Locks { // Resolve all waiting promises before clearing to prevent deadlock // Any operation waiting for a lock will be granted access immediately for (const waiting of this.waiters.values()) { - for (const { reject } of waiting) { + for (const [_, reject] of waiting) { reject(new SyncResetError()); } } @@ -92,17 +82,13 @@ export class Locks { this.waiters.clear(); } - public isLocked(key: T): boolean { - return this.locked.has(key); - } - /** - * Attempts to acquire a lock immediately without waiting. - * Must call `unlock()` if successful. - * - * @param key The key to lock - * @returns `true` if lock acquired, `false` if already locked - */ + * Attempts to acquire a lock immediately without waiting. + * Must call `unlock()` if successful. + * + * @param key The key to lock + * @returns `true` if lock acquired, `false` if already locked + */ public tryLock(key: T): boolean { if (this.locked.has(key)) { return false; @@ -114,18 +100,18 @@ export class Locks { } /** - * Waits to acquire a lock, blocking until available. - * Operations are queued in FIFO order. Must call `unlock()` when done. - * - * @param key The key to wait for and lock - * @returns Promise that resolves when lock is acquired - */ + * Waits to acquire a lock, blocking until available. + * Operations are queued in FIFO order. Must call `unlock()` when done. + * + * @param key The key to wait for and lock + * @returns Promise that resolves when lock is acquired + */ public async waitForLock(key: T): Promise { if (this.tryLock(key)) { return Promise.resolve(); } - this.logger?.debug(`Waiting for lock '${this.name}' on '${key}'`); + this.logger?.debug(`Waiting for lock on ${key}`); return new Promise((resolve, reject) => { // DefaultDict behavior @@ -135,36 +121,28 @@ export class Locks { this.waiters.set(key, waiting); } - waiting.push({ - resolve, - reject - }); + waiting.push([resolve, reject]); }); } /** - * Releases a lock and grants access to the next waiting operation in FIFO order. - * Removes the key from locked set if no waiters. - * - * @param key The key to unlock - * @throws {Error} If key is not currently locked - */ + * Releases a lock and grants access to the next waiting operation in FIFO order. + * Removes the key from locked set if no waiters. + * + * @param key The key to unlock + * @throws {Error} If key is not currently locked + */ public unlock(key: T): void { if (!this.locked.has(key)) { - this.logger?.debug( - `Attempted to unlock '${this.name}' on '${key}' which is not locked` - ); return; } - this.logger?.debug(`Releasing lock '${this.name}' on '${key}'`); - // Remove first waiter to ensure FIFO order - const nextWaiter = this.waiters.get(key)?.shift(); + const [resolveNextWaiting, _] = this.waiters.get(key)?.shift() ?? []; - if (nextWaiter) { - this.logger?.debug(`Granted lock '${this.name}' on '${key}'`); - nextWaiter.resolve(); + if (resolveNextWaiting) { + this.logger?.debug(`Granted lock on ${key}`); + resolveNextWaiting(); } else { this.locked.delete(key); } @@ -174,8 +152,8 @@ export class Locks { export class Lock { private readonly locks: Locks; - public constructor(name: string, logger?: Logger) { - this.locks = new Locks(name, logger); + public constructor(logger?: Logger) { + this.locks = new Locks(logger); } public async withLock(fn: () => R | Promise): Promise { diff --git a/frontend/sync-client/src/utils/data-structures/min-covered.test.ts b/frontend/sync-client/src/utils/data-structures/min-covered.test.ts index 752227c0..7b7271d7 100644 --- a/frontend/sync-client/src/utils/data-structures/min-covered.test.ts +++ b/frontend/sync-client/src/utils/data-structures/min-covered.test.ts @@ -1,15 +1,15 @@ import { describe, it } from "node:test"; import assert from "node:assert"; -import { MinCovered } from "./min-covered"; +import { CoveredValues } from "./min-covered"; -describe("MinCovered", () => { +describe("CoveredValues", () => { it("should initialize with the given min value", () => { - const covered = new MinCovered(5); + const covered = new CoveredValues(5); assert.strictEqual(covered.min, 5); }); it("should add values greater than min", () => { - const covered = new MinCovered(0); + const covered = new CoveredValues(0); covered.add(3); assert.strictEqual(covered.min, 0); covered.add(1); @@ -21,7 +21,7 @@ describe("MinCovered", () => { }); it("should ignore duplicate values", () => { - const covered = new MinCovered(0); + const covered = new CoveredValues(0); covered.add(3); covered.add(3); covered.add(3); @@ -32,7 +32,7 @@ describe("MinCovered", () => { }); it("should handle multiple consecutive values", () => { - const covered = new MinCovered(132); + const covered = new CoveredValues(132); for (let i = 250; i > 132; i--) { assert.strictEqual(covered.min, 132); covered.add(i); @@ -41,32 +41,36 @@ describe("MinCovered", () => { }); it("should handle adding values lower than current min", () => { - const covered = new MinCovered(5); + const covered = new CoveredValues(5); covered.add(3); assert.strictEqual(covered.min, 5); covered.add(6); assert.strictEqual(covered.min, 6); }); - it("should auto-advance when adding the value that fills the next gap", () => { - const covered = new MinCovered(5); + it("should auto-advance when setting min value", () => { + const covered = new CoveredValues(5); covered.add(7); covered.add(8); covered.add(9); assert.strictEqual(covered.min, 5); - // Adding 6 fills the gap and auto-advances through 7, 8, 9 - covered.add(6); + // Setting min to 6 should auto-advance through 7, 8, 9 + covered.min = 6; assert.strictEqual(covered.min, 9); covered.add(10); assert.strictEqual(covered.min, 10); }); - it("should rewind when reset is called explicitly", () => { - const covered = new MinCovered(5); - covered.add(7); - covered.reset(3); - assert.strictEqual(covered.min, 3); - covered.add(4); - assert.strictEqual(covered.min, 4); + it("should handle setting min value with no consecutive values", () => { + const covered = new CoveredValues(5); + covered.add(10); + covered.add(15); + assert.strictEqual(covered.min, 5); + // Setting min to 8 should not auto-advance (no consecutive values) + covered.min = 8; + assert.strictEqual(covered.min, 8); + // Add 9 to trigger auto-advance to 10 + covered.add(9); + assert.strictEqual(covered.min, 10); }); }); diff --git a/frontend/sync-client/src/utils/data-structures/min-covered.ts b/frontend/sync-client/src/utils/data-structures/min-covered.ts index ed0b9d2e..8b38822f 100644 --- a/frontend/sync-client/src/utils/data-structures/min-covered.ts +++ b/frontend/sync-client/src/utils/data-structures/min-covered.ts @@ -7,13 +7,13 @@ * * @example * ```typescript - * const covered = new MinCovered(0); + * const covered = new CoveredValues(0); * covered.add(2); // seenValues = [2], min = 0 * covered.add(1); // seenValues = [], min = 2 * covered.min; // returns 2 * ``` */ -export class MinCovered { +export class CoveredValues { private seenValues: number[] = []; public constructor(private minValue: number) {} @@ -22,6 +22,12 @@ export class MinCovered { return this.minValue; } + public set min(value: number) { + this.minValue = Math.max(value, this.minValue); + this.seenValues = this.seenValues.filter((v) => v > this.minValue); + this.advanceMinWhilePossible(); + } + public add(value: number | undefined): void { if (value === undefined || value < this.minValue) { return; @@ -43,11 +49,6 @@ export class MinCovered { this.advanceMinWhilePossible(); } - public reset(minValue?: number): void { - this.minValue = minValue ?? 0; - this.seenValues = []; - } - private advanceMinWhilePossible(): void { while ( this.seenValues.length > 0 && diff --git a/frontend/sync-client/src/utils/debugging/in-memory-file-system.ts b/frontend/sync-client/src/utils/debugging/in-memory-file-system.ts deleted file mode 100644 index 0d26b175..00000000 --- a/frontend/sync-client/src/utils/debugging/in-memory-file-system.ts +++ /dev/null @@ -1,69 +0,0 @@ -import type { RelativePath } from "../../sync-operations/types"; -import type { TextWithCursors } from "reconcile-text"; -import type { FileSystemOperations } from "../../file-operations/filesystem-operations"; - -export class InMemoryFileSystem implements FileSystemOperations { - protected readonly files = new Map(); - - public async listFilesRecursively( - _root: RelativePath | undefined = undefined // we don't use multi-level paths during tests - ): Promise { - return Array.from(this.files.keys()); - } - - public async read(path: RelativePath): Promise { - const file = this.files.get(path); - if (!file) { - throw new Error(`File ${path} does not exist`); - } - return file; - } - - public async write(path: RelativePath, content: Uint8Array): Promise { - this.files.set(path, content); - } - - public async atomicUpdateText( - path: RelativePath, - updater: (current: TextWithCursors) => TextWithCursors - ): Promise { - const file = this.files.get(path); - if (!file) { - throw new Error(`File ${path} does not exist`); - } - const currentContent = new TextDecoder().decode(file); - const newContent = updater({ text: currentContent, cursors: [] }).text; - this.files.set(path, new TextEncoder().encode(newContent)); - return newContent; - } - - public async getFileSize(path: RelativePath): Promise { - return (await this.read(path)).length; - } - - public async exists(path: RelativePath): Promise { - return this.files.has(path); - } - - public async createDirectory(_path: RelativePath): Promise { - // This doesn't mean anything in our virtual FS representation - } - - public async delete(path: RelativePath): Promise { - this.files.delete(path); - } - - public async rename( - oldPath: RelativePath, - newPath: RelativePath - ): Promise { - const file = this.files.get(oldPath); - if (!file) { - throw new Error(`File ${oldPath} does not exist`); - } - this.files.set(newPath, file); - if (oldPath !== newPath) { - this.files.delete(oldPath); - } - } -} diff --git a/frontend/sync-client/src/utils/debugging/log-to-console.ts b/frontend/sync-client/src/utils/debugging/log-to-console.ts index def71400..c47f18f6 100644 --- a/frontend/sync-client/src/utils/debugging/log-to-console.ts +++ b/frontend/sync-client/src/utils/debugging/log-to-console.ts @@ -1,44 +1,10 @@ -/* eslint-disable no-console */ -import type { Logger, LogLine } from "../../tracing/logger"; +import type { SyncClient } from "../../sync-client"; +import type { LogLine } from "../../tracing/logger"; import { LogLevel } from "../../tracing/logger"; -const COLORS = { - reset: "\x1b[0m", - red: "\x1b[31m", - yellow: "\x1b[33m", - blue: "\x1b[34m", - gray: "\x1b[90m" -}; - -export function logToConsole( - logger: Logger, - { useColors = true }: { useColors?: boolean } = {} -): void { - logger.onLogEmitted.add((logLine: LogLine) => { - const timestamp = logLine.timestamp.toISOString(); - const { message } = logLine; - - let color = ""; - let reset = ""; - if (useColors) { - ({ reset } = COLORS); - switch (logLine.level) { - case LogLevel.ERROR: - color = COLORS.red; - break; - case LogLevel.WARNING: - color = COLORS.yellow; - break; - case LogLevel.INFO: - color = COLORS.blue; - break; - case LogLevel.DEBUG: - color = COLORS.gray; - break; - } - } - - const formatted = `${timestamp} ${color}${logLine.level}${reset} ${message}`; +export function logToConsole(client: SyncClient): void { + client.logger.onLogEmitted.add((logLine: LogLine) => { + const formatted = `${logLine.timestamp.toISOString()} ${logLine.level} ${logLine.message}`; switch (logLine.level) { case LogLevel.ERROR: diff --git a/frontend/sync-client/src/utils/debugging/slow-web-socket-factory.ts b/frontend/sync-client/src/utils/debugging/slow-web-socket-factory.ts index b93460b5..c64bff18 100644 --- a/frontend/sync-client/src/utils/debugging/slow-web-socket-factory.ts +++ b/frontend/sync-client/src/utils/debugging/slow-web-socket-factory.ts @@ -11,7 +11,7 @@ export function slowWebSocketFactory( private static readonly RECEIVE_KEY = "websocket-receive"; private static readonly SEND_KEY = "websocket-send"; - private readonly locks = new Locks(FlakyWebSocket.name, logger); + private readonly locks = new Locks(logger); public set onopen(callback: ((event: Event) => void) | null) { super.onopen = async (event: Event): Promise => { diff --git a/frontend/sync-client/src/utils/find-matching-file.ts b/frontend/sync-client/src/utils/find-matching-file.ts index 1e0b352c..c3d323d3 100644 --- a/frontend/sync-client/src/utils/find-matching-file.ts +++ b/frontend/sync-client/src/utils/find-matching-file.ts @@ -1,17 +1,14 @@ -import type { DocumentRecord } from "../sync-operations/types"; +import type { DocumentRecord } from "../persistence/database"; import { EMPTY_HASH } from "./hash"; // TODO: make this smarter so that offline files can be renamed & edited at the same time -export async function findMatchingFile( +export function findMatchingFile( contentHash: string, candidates: DocumentRecord[] -): Promise { - if (contentHash === (await EMPTY_HASH)) { +): DocumentRecord | undefined { + if (contentHash === EMPTY_HASH) { return undefined; } - return candidates.find( - (record) => - record.remoteHash !== undefined && record.remoteHash === contentHash - ); + return candidates.find(({ metadata }) => metadata?.hash === contentHash); } diff --git a/frontend/sync-client/src/utils/hash.ts b/frontend/sync-client/src/utils/hash.ts index b9d23041..906b6fad 100644 --- a/frontend/sync-client/src/utils/hash.ts +++ b/frontend/sync-client/src/utils/hash.ts @@ -1,14 +1,12 @@ -export async function hash(content: Uint8Array): Promise { - // Re-wrap into a fresh Uint8Array so SubtleCrypto's - // BufferSource overload accepts it without an unsafe type assertion. - // The lib types require an ArrayBuffer-backed view; the source may - // be backed by SharedArrayBuffer in some runtimes. - const buffer = new ArrayBuffer(content.byteLength); - new Uint8Array(buffer).set(content); - const digest = await crypto.subtle.digest("SHA-256", buffer); - const bytes = new Uint8Array(digest); - return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); +// https://stackoverflow.com/questions/7616461/generate-a-hash-from-string-in-javascript +export function hash(content: Uint8Array): string { + let result = 0; + // eslint-disable-next-line @typescript-eslint/prefer-for-of + for (let i = 0; i < content.length; i++) { + result = (result << 5) - result + content[i]; + result |= 0; // Convert to 32bit integer + } + return Math.abs(result).toString(16).padStart(8, "0"); } -// SHA-256 of empty content, computed once at import time -export const EMPTY_HASH: Promise = hash(new Uint8Array()); +export const EMPTY_HASH = hash(new Uint8Array(0)); diff --git a/frontend/sync-client/src/utils/rate-limit.ts b/frontend/sync-client/src/utils/rate-limit.ts index acb86393..52cbbce7 100644 --- a/frontend/sync-client/src/utils/rate-limit.ts +++ b/frontend/sync-client/src/utils/rate-limit.ts @@ -1,4 +1,4 @@ -import { awaitAll } from "./await-all"; +import { createPromise } from "./create-promise"; import { sleep } from "./sleep"; /** @@ -45,16 +45,18 @@ export function rateLimit< newArgs = undefined; } - // `running` must signal both "minimum interval has elapsed" *and* - // "fn() has finished" — otherwise an `fn` that takes longer than - // the interval would let a queued waiter fire a concurrent `fn` - const interval = + const [promise, resolve] = createPromise(); + running = promise; + sleep( typeof minIntervalMs === "function" ? minIntervalMs() - : minIntervalMs; - const fnPromise = fn(...args); - running = awaitAll([fnPromise.catch(() => undefined), sleep(interval)]); - return fnPromise; + : minIntervalMs + ) + .then(resolve) + .catch(() => { + // sleep cannot fail + }); + return fn(...args); }; return decoratedFn; diff --git a/frontend/sync-client/tsconfig.json b/frontend/sync-client/tsconfig.json index 98870f32..92caf072 100644 --- a/frontend/sync-client/tsconfig.json +++ b/frontend/sync-client/tsconfig.json @@ -12,5 +12,7 @@ "declaration": true, "declarationDir": "./dist/types" }, - "exclude": ["./dist"] + "exclude": [ + "./dist" + ] } diff --git a/frontend/sync-client/webpack.config.js b/frontend/sync-client/webpack.config.js index 413bfeba..b7c3a3fd 100644 --- a/frontend/sync-client/webpack.config.js +++ b/frontend/sync-client/webpack.config.js @@ -49,6 +49,11 @@ module.exports = [ type: "umd" }, globalObject: "this" + }, + resolve: { + fallback: { + ws: false // Exclude `ws` from the browser bundle + } } }), merge(common, { @@ -57,6 +62,10 @@ module.exports = [ path: path.resolve(__dirname, "dist"), filename: "sync-client.node.js", libraryTarget: "commonjs2" + }, + externals: { + bufferutil: "bufferutil", + "utf-8-validate": "utf-8-validate" // required for ws: https://github.com/websockets/ws/issues/2245#issuecomment-2250318733 } }) ]; diff --git a/frontend/test-client/package.json b/frontend/test-client/package.json index 02610d7d..3d0d0c1a 100644 --- a/frontend/test-client/package.json +++ b/frontend/test-client/package.json @@ -11,14 +11,14 @@ "test": "tsx --test 'src/**/*.test.ts'" }, "devDependencies": { - "@types/node": "^25.0.2", + "@types/node": "^24.8.1", "sync-client": "file:../sync-client", - "ts-loader": "^9.5.4", + "ts-loader": "^9.5.2", "tslib": "2.8.1", - "tsx": "^4.21.0", - "typescript": "5.9.3", + "tsx": "^4.20.6", + "typescript": "5.8.3", "uuid": "^13.0.0", - "webpack": "^5.103.0", + "webpack": "^5.99.9", "webpack-cli": "^6.0.1" } } diff --git a/frontend/test-client/src/agent/mock-agent.ts b/frontend/test-client/src/agent/mock-agent.ts index d4fc8c82..1640c2ec 100644 --- a/frontend/test-client/src/agent/mock-agent.ts +++ b/frontend/test-client/src/agent/mock-agent.ts @@ -1,25 +1,21 @@ -/* eslint-disable no-console */ import { choose } from "../utils/choose"; import { v4 as uuidv4 } from "uuid"; import { assert } from "../utils/assert"; import type { RelativePath, SyncSettings } from "sync-client"; import { debugging, Logger, LogLevel, utils } from "sync-client"; import { MockClient } from "./mock-client"; +import { sleep } from "../utils/sleep"; import type { LogLine } from "sync-client"; import { withTimeout } from "../utils/with-timeout"; -import type { TestErrorTracker } from "../utils/test-error-tracker"; const TIMEOUT_MS = 10 * 60 * 1000; export class MockAgent extends MockClient { private readonly writtenContents: string[] = []; - private readonly writtenBinaryContents: string[] = []; private readonly pendingActions: Promise[] = []; // The renamed file finding algorithm isn't too smart so we can't both update and rename the same file private readonly doNotTouchWhileOffline: string[] = []; - private readonly doNotRenameWhileOffline: string[] = []; - private lastSyncEnabledState = true; public constructor( initialSettings: Partial, @@ -27,8 +23,7 @@ export class MockAgent extends MockClient { private readonly doDeletes: boolean, private readonly doResets: boolean, useSlowFileEvents: boolean, - private readonly jitterScaleInSeconds: number, - private readonly errorTracker: TestErrorTracker + private readonly jitterScaleInSeconds: number ) { super(initialSettings, useSlowFileEvents); } @@ -47,28 +42,6 @@ export class MockAgent extends MockClient { "Connection check failed" ); - // When the sync engine moves a tracked file on disk (post-create - // deconflict, reconciler placement, lost-rename replay, slot - // displacement), shift the path's offline-protection forward - // so the random-op picker doesn't accidentally rename the - // moved file while offline. Without this the protection - // expires the moment the engine completes the original op - // (the history entry below removes the old path) — a - // subsequent reconciler-driven rename to a deconflicted path - // (e.g. `initial-1.md → initial-1 (2).md` after a same-path - // collision) lands at a path the touch-list never knew about, - // and an offline rename against that path strands the file. - this.client.onDocumentPathChanged.add((_documentId, oldPath, newPath) => { - if (oldPath !== undefined && newPath !== undefined) { - if (this.doNotTouchWhileOffline.includes(oldPath)) { - this.doNotTouchWhileOffline.push(newPath); - } - if (this.doNotRenameWhileOffline.includes(oldPath)) { - this.doNotRenameWhileOffline.push(newPath); - } - } - }); - this.client.logger.onLogEmitted.add((logLine: LogLine) => { const state = this.client.getSettings().isSyncEnabled ? "(online) " @@ -76,7 +49,7 @@ export class MockAgent extends MockClient { const formatted = `[${this.name} ${state}] ${logLine.timestamp.toISOString()} ${logLine.level} ${logLine.message}`; // HACK: we have to ensure the file has been synced if we want to change it offline without data loss - const historyEntry = /.*History entry: (.*\.(?:md|bin)).*/.exec( + const historyEntry = /.*History entry: (.*.md).*/.exec( logLine.message ); @@ -85,20 +58,15 @@ export class MockAgent extends MockClient { this.doNotTouchWhileOffline, historyEntry[1] ); - utils.removeFromArray( - this.doNotRenameWhileOffline, - historyEntry[1] - ); } switch (logLine.level) { case LogLevel.ERROR: console.error(formatted); - if ( - !this.useSlowFileEvents && - !formatted.includes("retrying in") - ) { - this.errorTracker.recordError(this.name, formatted); + if (!this.useSlowFileEvents) { + // Let's wait for the error to be caught if there was one + // eslint-disable-next-line @typescript-eslint/no-floating-promises + sleep(100).then(() => process.exit(1)); } break; @@ -117,34 +85,13 @@ export class MockAgent extends MockClient { this.client.logger.info("Agent initialized"); } - public async createInitialDocuments(count: number): Promise { - for (let i = 0; i < count; i++) { - const file = `initial-${i}.md`; - this.doNotTouchWhileOffline.push(file); - const content = this.getContent(); - this.files.set(file, new TextEncoder().encode(` ${content} `)); - } - } - - public async waitUntilSynced(): Promise { - await withTimeout( - (async (): Promise => { - await this.client.setSetting("isSyncEnabled", true); - await this.client.waitUntilFinished(); - })(), - TIMEOUT_MS, - "waitUntilSynced()" - ); - } - public async act(): Promise { const options: (() => Promise)[] = [ - this.createFileAction.bind(this), - this.createBinaryFileAction.bind(this) + this.createFileAction.bind(this) ]; if ( - this.lastSyncEnabledState && + this.client.getSettings().isSyncEnabled && this.doNotTouchWhileOffline.length === 0 ) { options.push(this.disableSyncAction.bind(this)); @@ -152,14 +99,17 @@ export class MockAgent extends MockClient { options.push(this.enableSyncAction.bind(this)); } - options.push( - this.renameFileAction.bind(this), - this.updateFileAction.bind(this), - this.updateBinaryFileAction.bind(this) - ); + const files = await this.listFilesRecursively(); - if (this.doDeletes) { - options.push(this.deleteFileAction.bind(this)); + if (files.length > 0) { + options.push( + this.renameFileAction.bind(this, files), + this.updateFileAction.bind(this, files) + ); + + if (this.doDeletes) { + options.push(this.deleteFileAction.bind(this, files)); + } } if (Math.random() < 0.015 && this.doResets) { @@ -171,31 +121,6 @@ export class MockAgent extends MockClient { try { return await choose(options)(); } catch (error) { - // SyncResetError is expected when a client reset - // races with a file operation. Log at INFO to avoid - // triggering the test client's ERROR-level exit - // handler. - if ( - error instanceof Error && - error.name === "SyncResetError" - ) { - this.client.logger.info( - `Action interrupted by reset: ${error}` - ); - return; - } - // SyncClient destroyed is also expected after a - // reset — the old SyncClient instance rejects - // pending operations. - if ( - error instanceof Error && - error.message.includes("SyncClient destroyed") - ) { - this.client.logger.info( - `Action interrupted by destroy: ${error}` - ); - return; - } this.client.logger.error( `Failed to perform an action: ${error}` ); @@ -203,7 +128,7 @@ export class MockAgent extends MockClient { JSON.stringify(this.data, null, 2) ); this.client.logger.info( - JSON.stringify(this.files, null, 2) + JSON.stringify(this.localFiles, null, 2) ); throw error; } @@ -236,86 +161,52 @@ export class MockAgent extends MockClient { } public assertFileSystemsAreConsistent(otherAgent: MockAgent): void { - const globalFiles = Array.from(otherAgent.files.keys()); - const localFiles = Array.from(this.files.keys()); + const globalFiles = Array.from(otherAgent.localFiles.keys()); + const localFiles = Array.from(this.localFiles.keys()); const missingInOther = localFiles.filter( - (file) => !otherAgent.files.has(file) + (file) => !otherAgent.localFiles.has(file) ); const missingInLocal = globalFiles.filter( - (file) => !this.files.has(file) + (file) => !this.localFiles.has(file) ); try { - // With slow file events, delayed filesystem notifications can - // lead to missed updates. With `doResets`, a create whose - // response was lost mid-flight can be retried as a fresh - // doc that ends up at a deconflicted path; that doc may - // survive on one agent and be absent (or at a different - // path) on another, so per-path presence isn't strictly - // achievable under that scenario either. - if (!this.useSlowFileEvents && !this.doResets) { - assert( - missingInOther.length === 0, - `Files from ${this.name} missing in ${otherAgent.name}: ${missingInOther.join(", ")}` - ); - assert( - missingInLocal.length === 0, - `Files from ${otherAgent.name} missing in ${this.name}: ${missingInLocal.join(", ")}` - ); - } + assert( + missingInOther.length === 0, + `Files from ${this.name} missing in ${otherAgent.name}: ${missingInOther.join(", ")}` + ); + assert( + missingInLocal.length === 0, + `Files from ${otherAgent.name} missing in ${this.name}: ${missingInLocal.join(", ")}` + ); - // Content equality is only strictly - // achievable when file events are immediate. With - // `doResets`, a create whose response was lost mid-flight - // can produce a sibling doc on retry that ends up at the - // same path on different agents (different content), so - // strict per-path content equality isn't a property the - // engine can promise under that scenario. - if (!this.useSlowFileEvents && !this.doResets) { - const sharedFiles = globalFiles.filter((file) => - this.files.has(file) + for (const file of globalFiles) { + const localContent = new TextDecoder().decode( + this.localFiles.get(file) + ); + const otherContent = new TextDecoder().decode( + otherAgent.localFiles.get(file) + ); + assert( + localContent === otherContent, + `Content mismatch for file ${file}:\n${localContent}\n${otherContent}` ); - for (const file of sharedFiles) { - // Binary files use LWW semantics — concurrent - // creates at the same path produce sibling docs - // on the server (deconflicted paths), and which - // doc wins each agent's "canonical" slot depends - // on the order remote events arrive. Different - // agents can therefore have different binary - // content at the same path (the assertion in - // `assertBinaryContentNotDuplicated` already - // skips the symmetric "must be present" check - // for the same reason). - if (file.endsWith(".bin")) { - continue; - } - const localContent = new TextDecoder().decode( - this.files.get(file) - ); - const otherContent = new TextDecoder().decode( - otherAgent.files.get(file) - ); - assert( - localContent === otherContent, - `Content mismatch for file ${file}:\n${localContent}\n${otherContent}` - ); - } } } catch (e) { this.client.logger.info( "Local data: " + JSON.stringify(this.data, null, 2) ); this.client.logger.info( - "Local files: " + Array.from(this.files.keys()).join(", ") + "Local files: " + + Array.from(otherAgent.localFiles.keys()).join(", ") ); otherAgent.client.logger.info( - "Other agent's data: " + - JSON.stringify(otherAgent.data, null, 2) + "Local data: " + JSON.stringify(otherAgent.data, null, 2) ); otherAgent.client.logger.info( - "Other agent's files: " + - Array.from(otherAgent.files.keys()).join(", ") + "Local files: " + + Array.from(otherAgent.localFiles.keys()).join(", ") ); throw e; @@ -325,76 +216,44 @@ export class MockAgent extends MockClient { public assertAllContentIsPresentOnce(): void { if (this.useSlowFileEvents) { this.client.logger.info( - `Running partial content check for ${this.name} (slow file events: skipping existence and cross-file duplication checks)` + // We can't ensure that we have seen every single update + `Skipping content check for ${this.name} because slow file events are enabled` ); + return; } for (const content of this.writtenContents) { - const found = Array.from(this.files.keys()).filter((key) => { + const found = Array.from(this.localFiles.keys()).filter((key) => { return new TextDecoder() - .decode(this.files.get(key)) + .decode(this.localFiles.get(key)) .includes(content); }); - // A create whose response was discarded mid-flight (sync - // reset, sync pause/resume, or `doResets`) gets retried; - // if the server already absorbed the original bytes via - // path-based merge into another doc, the retry - // legitimately deconflicts into a fresh doc, leaving - // the same UUID in two local files. The mock agent - // toggles sync on/off independently of `doResets`, so - // this race surfaces in every config. That's an accepted - // outcome of the at-least-once create semantics, not a - // sync-engine bug. - // Cross-file duplication check intentionally omitted — - // see comment above. - - if (!this.useSlowFileEvents && !this.doDeletes) { + if (this.doDeletes) { + assert( + found.length <= 1, + `[${this.name}] Content ${content} found in ${found.join(", ")}` + ); + } else { assert( found.length >= 1, `[${this.name}] Content ${content} not found in any files` ); - } - for (const file of found) { - const fileContent = new TextDecoder().decode( - this.files.get(file) - ); - if (fileContent.split(content).length > 2) { - // Same retry-class race as the cross-file - // duplication check above: a 3-way merge on a - // retried create can fold the original bytes in - // alongside a sibling deconflict, producing the - // same UUID twice in one file. Warn but don't - // fail. - this.client.logger.warn( - `Content ${content} (of ${this.name}) found more than once in '${file}'. File content:\n${fileContent}` - ); - } - } - } - } - - // Check binary content isn't duplicated across files, and (when - // deletes are disabled) that every written UUID still exists. - // Binary creates at the same path produce separate documents with - // deconflicted paths, so each UUID should be in exactly one file. - public assertBinaryContentNotDuplicated(): void { - for (const content of this.writtenBinaryContents) { - const found = Array.from(this.files.keys()).filter((key) => { - return new TextDecoder() - .decode(this.files.get(key)) - .includes(content); - }); - - if (!this.useSlowFileEvents) { assert( found.length <= 1, - `[${this.name}] Binary content ${content} found in multiple files: ${found.join(", ")}` + `[${this.name}] Content ${content} found in multiple files: ${found.join(", ")}` + ); + + const [file] = found; + const fileContent = new TextDecoder().decode( + this.localFiles.get(file) + ); + assert( + fileContent.split(content).length == 2, + `Content ${content} (of ${this.name}) found more than once in '${file}'. File content:\n${fileContent}` ); } - - // can't assert(found.length >= 1, ...); because binary files have LWW semantics } } @@ -408,7 +267,7 @@ export class MockAgent extends MockClient { const file = this.getFileName(); if ( - (!this.lastSyncEnabledState && + (!this.client.getSettings().isSyncEnabled && this.doNotTouchWhileOffline.includes(file)) || (await this.exists(file)) ) { @@ -420,76 +279,38 @@ export class MockAgent extends MockClient { `Decided to create file ${file} with content ${content}` ); - this.doNotRenameWhileOffline.push(file); - - return this.write(file, new TextEncoder().encode(` ${content} `)); - } - - // Binary file creation — exercises the putBinary server path (not in mergeable_file_extensions) - private async createBinaryFileAction(): Promise { - const file = this.getBinaryFileName(); - - if ( - (!this.lastSyncEnabledState && - this.doNotTouchWhileOffline.includes(file)) || - (await this.exists(file)) - ) { - return; - } - - const { uuid, bytes } = this.getBinaryContent(); - this.client.logger.info( - `Decided to create binary file ${file}: ${uuid}` - ); - - this.doNotRenameWhileOffline.push(file); - - return this.write(file, bytes); + return this.create(file, new TextEncoder().encode(` ${content} `)); } private async disableSyncAction(): Promise { this.client.logger.info(`Decided to disable sync`); - this.lastSyncEnabledState = false; await this.client.setSetting("isSyncEnabled", false); } private async enableSyncAction(): Promise { this.client.logger.info(`Decided to enable sync`); await this.client.setSetting("isSyncEnabled", true); - this.lastSyncEnabledState = true; } - private async renameFileAction(): Promise { - const files = await this.listFilesRecursively(); - if (files.length === 0) { - return; - } - + private async renameFileAction(files: RelativePath[]): Promise { const file = choose(files); // We can't edit files offline that have been updated while offline. // Otherwise, the resolution logic couldn't handle it. if ( - !this.lastSyncEnabledState && - (this.doNotTouchWhileOffline.includes(file) || - this.doNotRenameWhileOffline.includes(file)) + !this.client.getSettings().isSyncEnabled && + this.doNotTouchWhileOffline.includes(file) ) { this.client.logger.info( - `Skipping file ${file} because it cannot be renamed while offline` + `Skipping file ${file} because it has been updated while offline` ); return; } - // Preserve file extension to avoid renaming .bin → .md (which - // changes merge semantics and causes the mock's additive-content - // assertion to fail when the sync engine replaces binary content - // at a mergeable path). - const ext = file.substring(file.lastIndexOf(".")); - const newName = - ext === ".bin" ? this.getBinaryFileName() : this.getFileName(); + const newName = this.getFileName(); if ( - (!this.lastSyncEnabledState && + (!this.client.getSettings().isSyncEnabled && this.doNotTouchWhileOffline.includes(newName)) || (await this.exists(newName)) ) { @@ -499,24 +320,16 @@ export class MockAgent extends MockClient { this.client.logger.info(`Decided to rename file ${file} to ${newName}`); this.doNotTouchWhileOffline.push(file, newName); - this.client.logger.info(`Renamed file: ${file} -> ${newName}`); - await this.rename(file, newName); + return this.rename(file, newName); } - private async updateFileAction(): Promise { - const files = (await this.listFilesRecursively()).filter((f) => - f.endsWith(".md") - ); - if (files.length === 0) { - return; - } - + private async updateFileAction(files: RelativePath[]): Promise { const file = choose(files); // We can't edit files offline that have been updated while offline. // Otherwise, the resolution logic couldn't handle it. if ( - !this.lastSyncEnabledState && + !this.client.getSettings().isSyncEnabled && this.doNotTouchWhileOffline.includes(file) ) { this.client.logger.info( @@ -536,47 +349,10 @@ export class MockAgent extends MockClient { })); } - private async updateBinaryFileAction(): Promise { - const files = (await this.listFilesRecursively()).filter((f) => - f.endsWith(".bin") - ); - if (files.length === 0) { - return; - } - - const file = choose(files); - - if ( - !this.lastSyncEnabledState && - this.doNotTouchWhileOffline.includes(file) - ) { - return; - } - - const { uuid: _uuid, bytes } = this.getBinaryContent(); - // Remove the old UUID since binary updates are last-write-wins - this.removeBinaryUuid(file); - this.client.logger.info(`Decided to update binary file ${file}`); - this.doNotTouchWhileOffline.push(file); - await this.write(file, bytes); - } - - private async deleteFileAction(): Promise { - const files = await this.listFilesRecursively(); - if (files.length === 0) { - return; - } - + private async deleteFileAction(files: RelativePath[]): Promise { const file = choose(files); this.client.logger.info(`Decided to delete file ${file}`); - - this.removeBinaryUuid(file); - - this.client.logger.info( - `Deleting file: ${file} with:\n content '${new TextDecoder().decode(this.files.get(file))}'` - ); - await this.delete(file); - utils.removeFromArray(this.doNotRenameWhileOffline, file); + return this.delete(file); } private getContent(): string { @@ -585,32 +361,8 @@ export class MockAgent extends MockClient { return uuid; } - private removeBinaryUuid(file: string): void { - const existing = this.files.get(file); - if (existing === undefined) { - return; - } - const content = new TextDecoder().decode(existing); - if (!content.startsWith("BINARY:")) { - return; - } - const uuid = content.slice("BINARY:".length); - utils.removeFromArray(this.writtenBinaryContents, uuid); - } - - private getBinaryContent(): { uuid: string; bytes: Uint8Array } { - const uuid = uuidv4(); - this.writtenBinaryContents.push(uuid); - return { uuid, bytes: new TextEncoder().encode(`BINARY:${uuid}`) }; - } - private getFileName(): string { // Simulate name collisions between the clients return `file-${Math.floor(Math.random() * 64)}.md`; } - - private getBinaryFileName(): string { - // Smaller range to increase collision frequency for last-write-wins testing - return `binary-${Math.floor(Math.random() * 16)}.bin`; - } } diff --git a/frontend/test-client/src/agent/mock-client.ts b/frontend/test-client/src/agent/mock-client.ts index 38736f4c..c814879a 100644 --- a/frontend/test-client/src/agent/mock-client.ts +++ b/frontend/test-client/src/agent/mock-client.ts @@ -2,26 +2,30 @@ import type { StoredDatabase, TextWithCursors } from "sync-client"; import { assert } from "../utils/assert"; import { type RelativePath, + type FileSystemOperations, type SyncSettings, - SyncClient, - debugging + SyncClient } from "sync-client"; -export class MockClient extends debugging.InMemoryFileSystem { +export class MockClient implements FileSystemOperations { + protected readonly localFiles = new Map(); protected client!: SyncClient; protected data: Partial<{ settings: Partial; database: Partial; - }> = {}; - - private slowEventChain: Promise = Promise.resolve(); + }> = { + database: { + // Assume all clients start at the same time so there's no need to fetch + // any shared state. + hasInitialSyncCompleted: true + } + }; public constructor( initialSettings: Partial, protected readonly useSlowFileEvents: boolean ) { - super(); this.data.settings = initialSettings; } @@ -42,82 +46,150 @@ export class MockClient extends debugging.InMemoryFileSystem { await this.client.start(); } - public override async write( - path: RelativePath, - content: Uint8Array - ): Promise { - const isNew = !this.files.has(path); - - this.files.set(path, content); - - if (isNew) { - this.executeFileOperation(async () => { - this.client.syncLocallyCreatedFile(path); - }); - } else { - this.executeFileOperation(async () => { - this.client.syncLocallyUpdatedFile({ relativePath: path }); - }); - } + public async listFilesRecursively( + _root: RelativePath | undefined = undefined // we don't use multi-level paths during tests + ): Promise { + return Array.from(this.localFiles.keys()); } - public override async atomicUpdateText( + public async read(path: RelativePath): Promise { + const file = this.localFiles.get(path); + if (!file) { + throw new Error(`File ${path} does not exist`); + } + return file; + } + + public async getFileSize(path: RelativePath): Promise { + return (await this.read(path)).length; + } + + public async exists(path: RelativePath): Promise { + return this.localFiles.has(path); + } + + public async create( + path: RelativePath, + newContent: Uint8Array + ): Promise { + if (this.localFiles.has(path)) { + throw new Error(`File ${path} already exists`); + } + this.client.logger.info( + `Creating file ${path} with content ${new TextDecoder().decode(newContent)}` + ); + this.localFiles.set(path, newContent); + + this.executeFileOperation(async () => + this.client.syncLocallyCreatedFile(path) + ); + } + + public async createDirectory(_path: RelativePath): Promise { + // This doesn't mean anything in our virtual FS representation + } + + public async atomicUpdateText( path: RelativePath, updater: (currentContent: TextWithCursors) => TextWithCursors ): Promise { - const file = this.files.get(path); + const file = this.localFiles.get(path); if (!file) { throw new Error(`File ${path} does not exist`); } const currentContent = new TextDecoder().decode(file); const newContent = updater({ text: currentContent, cursors: [] }).text; const newContentUint8Array = new TextEncoder().encode(newContent); - this.files.set(path, newContentUint8Array); + this.localFiles.set(path, newContentUint8Array); - this.executeFileOperation(async () => { - this.client.syncLocallyUpdatedFile({ relativePath: path }); - }); + if (!this.useSlowFileEvents) { + const existingParts = currentContent + .split(" ") + .map((part) => part.trim()); + const newParts = newContent.split(" ").map((part) => part.trim()); + existingParts.forEach((part) => + // all changes should be additive + { + assert( + newParts.includes(part), + `Part ${part} not found in new content: ${newContent}` + ); + } + ); + } + + this.client.logger.info( + `Updated file ${path} with:\n current content: ${currentContent}\n new content: ${newContent}` + ); + + this.executeFileOperation(async () => + this.client.syncLocallyUpdatedFile({ + relativePath: path + }) + ); return newContent; } - public override async delete(path: RelativePath): Promise { - this.files.delete(path); + public async write(path: RelativePath, content: Uint8Array): Promise { + const hasExisted = this.localFiles.has(path); + this.localFiles.set(path, content); + + this.client.logger.info( + `Updated file ${path} with:\n new content: ${new TextDecoder().decode(content)}` + ); + this.executeFileOperation(async () => { - this.client.syncLocallyDeletedFile(path); + if (hasExisted) { + return this.client.syncLocallyUpdatedFile({ + relativePath: path + }); + } else { + return this.client.syncLocallyCreatedFile(path); + } }); } - public override async rename( + public async delete(path: RelativePath): Promise { + this.client.logger.info( + `Deleting file: ${path} with:\n content ${new TextDecoder().decode(this.localFiles.get(path))}` + ); + this.localFiles.delete(path); + + this.executeFileOperation(async () => + this.client.syncLocallyDeletedFile(path) + ); + } + + public async rename( oldPath: RelativePath, newPath: RelativePath ): Promise { - const file = this.files.get(oldPath); + const file = this.localFiles.get(oldPath); if (!file) { throw new Error(`File ${oldPath} does not exist`); } - this.files.set(newPath, file); + this.localFiles.set(newPath, file); if (oldPath !== newPath) { - this.files.delete(oldPath); + this.localFiles.delete(oldPath); } - this.executeFileOperation(async () => { + + this.client.logger.info( + `Renamed file: ${oldPath} -> ${newPath} with:\n content ${new TextDecoder().decode(file)}` + ); + + this.executeFileOperation(async () => this.client.syncLocallyUpdatedFile({ oldPath, relativePath: newPath - }); - }); + }) + ); } - protected executeFileOperation(callback: () => unknown): void { + private executeFileOperation(callback: () => unknown): void { if (this.useSlowFileEvents) { - // we aren't the best client and it takes some time to notice - // changes, but they still arrive in the order they happened - this.slowEventChain = this.slowEventChain.then(async () => { - await new Promise((resolve) => - setTimeout(resolve, Math.random() * 100) - ); - await callback(); - }); + // we aren't the best client and it takes some time to notice changes + setTimeout(callback, Math.random() * 100); } else { callback(); } diff --git a/frontend/test-client/src/cli.ts b/frontend/test-client/src/cli.ts index ece94cc3..3af547e7 100644 --- a/frontend/test-client/src/cli.ts +++ b/frontend/test-client/src/cli.ts @@ -1,14 +1,11 @@ import type { SyncSettings } from "sync-client"; -import { utils, debugging, Logger } from "sync-client"; +import { utils } from "sync-client"; import { MockAgent } from "./agent/mock-agent"; import { sleep } from "./utils/sleep"; import { v4 as uuidv4 } from "uuid"; import { randomCasing } from "./utils/random-casing"; -import { TimeoutError } from "./utils/with-timeout"; -import { TestErrorTracker } from "./utils/test-error-tracker"; const TEST_ITERATIONS = 5; -const MAX_INITIAL_DOCS = 10; // Simulate async file access by injecting waiting time before returning from file operations. let slowFileEvents = false; @@ -16,13 +13,9 @@ let slowFileEvents = false; // Whether to do resets in the test runs let doResets = false; -const logger = new Logger(); -debugging.logToConsole(logger); - -const errorTracker = new TestErrorTracker(); - async function runTest({ agentCount, + concurrency, iterations, doDeletes, useResets, @@ -30,6 +23,7 @@ async function runTest({ jitterScaleInSeconds }: { agentCount: number; + concurrency: number; iterations: number; doDeletes: boolean; useResets: boolean; @@ -38,18 +32,18 @@ async function runTest({ }): Promise { slowFileEvents = useSlowFileEvents; doResets = useResets; - errorTracker.reset(); - const settings = `with ${agentCount} agents, iterations ${iterations}, doDeletes ${doDeletes}, doResets ${useResets}, jitterScaleInSeconds ${jitterScaleInSeconds}, useSlowFileEvents ${useSlowFileEvents}`; - logger.info(`Running test ${settings}`); + const settings = `with ${agentCount} agents, concurrency ${concurrency}, iterations ${iterations}, doDeletes ${doDeletes}, doResets ${useResets}, jitterScaleInSeconds ${jitterScaleInSeconds}, useSlowFileEvents ${useSlowFileEvents}`; + console.info(`Running test ${settings}`); const vaultName = uuidv4(); - logger.info(`Using vault name: ${vaultName}`); + console.info(`Using vault name: ${vaultName}`); const initialSettings: Partial = { isSyncEnabled: true, token: " test-token-change-me ", // same as in sync-server/config-e2e.yml with spaces vaultName: randomCasing(vaultName) + (Math.random() > 0.5 ? " " : ""), // extra spaces shouldn't matter - remoteUri: "http://localhost:3010" + syncConcurrency: concurrency, + remoteUri: "http://localhost:3000" }; const clients: MockAgent[] = []; @@ -61,107 +55,67 @@ async function runTest({ doDeletes, useResets, useSlowFileEvents, - jitterScaleInSeconds, - errorTracker + jitterScaleInSeconds ) ); } try { - for (const client of clients) { - const initialDocCount = Math.floor( - Math.random() * MAX_INITIAL_DOCS - ); - if (initialDocCount > 0) { - logger.info( - `Creating ${initialDocCount} initial documents for ${client.name}` - ); - await client.createInitialDocuments(initialDocCount); - } - } - await utils.awaitAll(clients.map(async (client) => client.init())); for (let i = 0; i < iterations; i++) { - logger.info(`Iteration ${i + 1}/${iterations}`); + console.info(`Iteration ${i + 1}/${iterations}`); await utils.awaitAll(clients.map(async (client) => client.act())); await sleep(Math.random() * 200); } - errorTracker.checkAndThrow(); + console.info("Stopping agents"); - logger.info("Stopping agents"); - - // Drain pending actions and enable sync for each client + // Each agent can have unpushed changes which might conflict with eachother so each has to resolve the conflicts & push, and for (const client of clients) { try { - logger.info(`Finishing up ${client.name}`); + console.info(`Finishing up ${client.name}`); await client.finish(); } catch (err) { - if (err instanceof TimeoutError || !slowFileEvents) { + if (!slowFileEvents) { throw err; } } } - // Settling rounds: drain cascading broadcasts between agents - for (let round = 0; round < 10; round++) { - for (const client of clients) { - try { - await client.waitUntilSynced(); - } catch (err) { - if (err instanceof TimeoutError || !slowFileEvents) { - throw err; - } - } - } - // TODO: it's very ugly, let's remove this - await sleep(2000); - } - + // then we need a second pass to ensure that all agents pull the same state. for (const client of clients) { try { - logger.info(`Destroying ${client.name}`); + console.info(`Destroying ${client.name}`); await client.destroy(); } catch (err) { - if (err instanceof TimeoutError || !slowFileEvents) { + if (!slowFileEvents) { throw err; } } } - logger.info("Agents finished successfully"); - errorTracker.checkAndThrow(); + console.info("Agents finished successfully"); clients.slice(0, -1).forEach((client, i) => { - logger.info( + console.info( `Checking consistency between ${client.name} and ${clients[i + 1].name}` ); - client.assertFileSystemsAreConsistent(clients[i + 1]); - logger.info(`Consistency check for ${client.name} passed`); + client.assertFileSystemsAreConsistent(clients[i]); + console.info(`Consistency check for ${client.name} passed`); }); - logger.info("File systems found to be consistent"); + console.info("File systems found to be consistent"); clients.forEach((client) => { - logger.info(`Checking content for ${client.name}`); + console.info(`Checking content for ${client.name}`); client.assertAllContentIsPresentOnce(); - logger.info(`Content check for ${client.name} passed`); + console.info(`Content check for ${client.name} passed`); }); - clients.forEach((client) => { - logger.info( - `Checking binary content duplication for ${client.name}` - ); - client.assertBinaryContentNotDuplicated(); - logger.info( - `Binary content duplication check for ${client.name} passed` - ); - }); - - logger.info(`Test passed ${settings}`); + console.info(`Test passed ${settings}`); } catch (err) { - logger.error(`Test failed ${settings}`); + console.error(`Test failed ${settings}`); throw err; } } @@ -170,6 +124,7 @@ async function runTests(): Promise { for (let i = 0; i < TEST_ITERATIONS; i++) { await runTest({ agentCount: 2, + concurrency: 16, iterations: 100, doDeletes: true, useResets: true, @@ -178,59 +133,24 @@ async function runTests(): Promise { }); for (const useSlowFileEvents of [true, false]) { - for (const doDeletes of [false, true]) { - await runTest({ - agentCount: 2, - iterations: 100, - doDeletes, - useResets: false, - useSlowFileEvents, - jitterScaleInSeconds: 0.75 - }); + for (const concurrency of [ + 16, + 1 // test with concurrency 1 to check for deadlocks + ]) { + for (const doDeletes of [false, true]) { + await runTest({ + agentCount: 2, + concurrency, + iterations: 100, + doDeletes, + useResets: false, + useSlowFileEvents, + jitterScaleInSeconds: 0.75 + }); + } } } } - - await runTest({ - agentCount: 3, - iterations: 75, - doDeletes: true, - useResets: false, - useSlowFileEvents: false, - jitterScaleInSeconds: 0.75 - }); - await runTest({ - agentCount: 3, - iterations: 75, - doDeletes: false, - useResets: true, - useSlowFileEvents: false, - jitterScaleInSeconds: 0.75 - }); - await runTest({ - agentCount: 4, - iterations: 50, - doDeletes: true, - useResets: false, - useSlowFileEvents: false, - jitterScaleInSeconds: 0.75 - }); - await runTest({ - agentCount: 2, - iterations: 100, - doDeletes: true, - useResets: false, - useSlowFileEvents: false, - jitterScaleInSeconds: 0.1 - }); - await runTest({ - agentCount: 2, - iterations: 100, - doDeletes: true, - useResets: true, - useSlowFileEvents: false, - jitterScaleInSeconds: 1.5 - }); } process.on("uncaughtException", (error) => { @@ -243,15 +163,12 @@ process.on("uncaughtException", (error) => { return; } - logger.error(`Error: uncaught exception: ${error}`); - if (error instanceof Error && error.stack != null) { - logger.error(error.stack); - } + console.error("Uncaught exception:", error); process.exit(1); }); process.on("unhandledRejection", (error, _promise) => { - if (error instanceof Error && error.name === "SyncResetError") { + if (error instanceof Error && error.message === "Sync was reset") { return; } @@ -274,10 +191,7 @@ process.on("unhandledRejection", (error, _promise) => { return; } - logger.error(`Error - unhandled rejection: ${error}`); - if (error instanceof Error && error.stack != null) { - logger.error(error.stack); - } + console.error("Unhandled rejection:", error); process.exit(1); }); @@ -285,10 +199,7 @@ runTests() .then(() => { process.exit(0); }) - .catch((error: unknown) => { - logger.error(`Error - tests failed with ${error}`); - if (error instanceof Error && error.stack != null) { - logger.error(error.stack); - } + .catch((err: unknown) => { + console.error(err); process.exit(1); }); diff --git a/frontend/test-client/src/utils/test-error-tracker.ts b/frontend/test-client/src/utils/test-error-tracker.ts deleted file mode 100644 index 4620b1e3..00000000 --- a/frontend/test-client/src/utils/test-error-tracker.ts +++ /dev/null @@ -1,23 +0,0 @@ -export class TestErrorTracker { - private firstError: { agentName: string; message: string } | null = null; - - public recordError(agentName: string, message: string): void { - this.firstError ??= { agentName, message }; - } - - /** - * If an error was recorded, throw it. Call this at natural checkpoints: - * after each iteration, before assertions, etc. - */ - public checkAndThrow(): void { - if (this.firstError !== null) { - const { agentName, message } = this.firstError; - throw new Error(`ERROR-level log from ${agentName}: ${message}`); - } - } - - /** Clear recorded errors. Call at the start of each test. */ - public reset(): void { - this.firstError = null; - } -} diff --git a/frontend/test-client/src/utils/with-timeout.ts b/frontend/test-client/src/utils/with-timeout.ts index 6de73531..71c9568b 100644 --- a/frontend/test-client/src/utils/with-timeout.ts +++ b/frontend/test-client/src/utils/with-timeout.ts @@ -1,10 +1,3 @@ -export class TimeoutError extends Error { - public constructor(message: string) { - super(message); - this.name = "TimeoutError"; - } -} - export async function withTimeout( promise: Promise, timeoutMs: number, @@ -15,9 +8,7 @@ export async function withTimeout( new Promise((_, reject) => setTimeout(() => { reject( - new TimeoutError( - `${operationName} timed out after ${timeoutMs}ms` - ) + new Error(`${operationName} timed out after ${timeoutMs}ms`) ); }, timeoutMs) ) diff --git a/frontend/test-client/tsconfig.json b/frontend/test-client/tsconfig.json index 7558871d..e86df89d 100644 --- a/frontend/test-client/tsconfig.json +++ b/frontend/test-client/tsconfig.json @@ -5,8 +5,13 @@ "target": "ES2022", "module": "CommonJS", "esModuleInterop": true, - "lib": ["DOM", "ES2024"], + "lib": [ + "DOM", + "ES2024", + ], "moduleResolution": "node" }, - "exclude": ["./dist"] + "exclude": [ + "./dist" + ] } diff --git a/sync-server/Cargo.lock b/sync-server/Cargo.lock index 82a7ce92..b3da1486 100644 --- a/sync-server/Cargo.lock +++ b/sync-server/Cargo.lock @@ -337,11 +337,10 @@ checksum = "325918d6fe32f23b19878fe4b34794ae41fc19ddbe53b10571a4874d44ffd39b" [[package]] name = "cc" -version = "1.2.57" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a0dd1ca384932ff3641c8718a02769f1698e7563dc6974ffd03346116310423" +checksum = "f34d93e62b03caf570cccc334cbc6c2fceca82f39211051345108adcba3eebdc" dependencies = [ - "find-msvc-tools", "shlex", ] @@ -457,15 +456,6 @@ version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" -[[package]] -name = "crossbeam-channel" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "crossbeam-queue" version = "0.3.11" @@ -543,15 +533,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "deranged" -version = "0.5.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", -] - [[package]] name = "digest" version = "0.10.7" @@ -643,12 +624,6 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "486f806e73c5707928240ddc295403b1b93c96a02038563881c4a2fd84b81ac4" -[[package]] -name = "find-msvc-tools" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" - [[package]] name = "flume" version = "0.11.1" @@ -1297,16 +1272,6 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" -[[package]] -name = "mime_guess" -version = "2.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" -dependencies = [ - "mime", - "unicase", -] - [[package]] name = "miniz_oxide" version = "0.8.0" @@ -1370,12 +1335,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "num-conv" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" - [[package]] name = "num-integer" version = "0.1.46" @@ -1504,12 +1463,6 @@ version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "953ec861398dccce10c670dfeaf3ec4911ca479e9c02154b3a215178c5f566f2" -[[package]] -name = "powerfmt" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" - [[package]] name = "ppv-lite86" version = "0.2.20" @@ -1629,12 +1582,12 @@ dependencies = [ [[package]] name = "reconcile-text" -version = "0.11.0" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52e0cf361887ea64c479ca871c1170dda761f84e122f2616b5579906a38d7557" +checksum = "599cf9539996a2a19e501110404c59ba62f4974009f8fb864a8b7151c15ee5a5" dependencies = [ "serde", - "thiserror 2.0.18", + "thiserror 2.0.17", ] [[package]] @@ -1695,40 +1648,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rust-embed" -version = "8.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" -dependencies = [ - "rust-embed-impl", - "rust-embed-utils", - "walkdir", -] - -[[package]] -name = "rust-embed-impl" -version = "8.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" -dependencies = [ - "proc-macro2", - "quote", - "rust-embed-utils", - "syn 2.0.90", - "walkdir", -] - -[[package]] -name = "rust-embed-utils" -version = "8.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" -dependencies = [ - "sha2", - "walkdir", -] - [[package]] name = "rustc-demangle" version = "0.1.24" @@ -1760,15 +1679,6 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3cb5ba0dc43242ce17de99c180e96db90b235b8a9fdc9543c96d2209116bd9f" -[[package]] -name = "same-file" -version = "1.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] - [[package]] name = "sanitize-filename" version = "0.6.0" @@ -2006,7 +1916,7 @@ dependencies = [ "serde_json", "sha2", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.17", "tokio", "tokio-stream", "tracing", @@ -2090,7 +2000,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.18", + "thiserror 2.0.17", "tracing", "uuid", "whoami", @@ -2129,7 +2039,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.18", + "thiserror 2.0.17", "tracing", "uuid", "whoami", @@ -2155,7 +2065,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", - "thiserror 2.0.18", + "thiserror 2.0.17", "tracing", "url", "uuid", @@ -2190,12 +2100,6 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" -[[package]] -name = "symlink" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" - [[package]] name = "syn" version = "1.0.109" @@ -2232,22 +2136,18 @@ dependencies = [ "futures", "humantime-serde", "log", - "mime_guess", "rand 0.9.0", "reconcile-text", "regex", - "rust-embed", "sanitize-filename", "serde", "serde_json", "serde_yaml", "sqlx", - "subtle", - "thiserror 2.0.18", + "thiserror 2.0.17", "tokio", "tower-http", "tracing", - "tracing-appender", "tracing-subscriber", "ts-rs", "uuid", @@ -2303,11 +2203,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.17", ] [[package]] @@ -2323,9 +2223,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", @@ -2342,37 +2242,6 @@ dependencies = [ "once_cell", ] -[[package]] -name = "time" -version = "0.3.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" -dependencies = [ - "deranged", - "itoa", - "num-conv", - "powerfmt", - "serde_core", - "time-core", - "time-macros", -] - -[[package]] -name = "time-core" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" - -[[package]] -name = "time-macros" -version = "0.2.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" -dependencies = [ - "num-conv", - "time-core", -] - [[package]] name = "tinystr" version = "0.7.6" @@ -2407,6 +2276,7 @@ dependencies = [ "bytes", "libc", "mio", + "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", @@ -2506,19 +2376,6 @@ dependencies = [ "tracing-core", ] -[[package]] -name = "tracing-appender" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" -dependencies = [ - "crossbeam-channel", - "symlink", - "thiserror 2.0.18", - "time", - "tracing-subscriber", -] - [[package]] name = "tracing-attributes" version = "0.1.28" @@ -2577,7 +2434,7 @@ checksum = "e640d9b0964e9d39df633548591090ab92f7a4567bc31d3891af23471a3365c6" dependencies = [ "chrono", "lazy_static", - "thiserror 2.0.18", + "thiserror 2.0.17", "ts-rs-macros", "uuid", ] @@ -2624,12 +2481,6 @@ version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f720def6ce1ee2fc44d40ac9ed6d3a59c361c80a75a7aa8e75bb9baed31cf2ea" -[[package]] -name = "unicase" -version = "2.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" - [[package]] name = "unicode-bidi" version = "0.3.17" @@ -2726,16 +2577,6 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" -[[package]] -name = "walkdir" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" -dependencies = [ - "same-file", - "winapi-util", -] - [[package]] name = "wasi" version = "0.11.0+wasi-snapshot-preview1" diff --git a/sync-server/Cargo.toml b/sync-server/Cargo.toml index 6de17653..fac06efa 100644 --- a/sync-server/Cargo.toml +++ b/sync-server/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sync_server" -rust-version = "1.94.0" +rust-version = "1.89.0" authors = ["Andras Schmelczer "] edition = "2024" license = "MIT" @@ -10,7 +10,7 @@ version = "0.14.0" [dependencies] serde = { version = "1.0.219", default-features = false, features = ["derive"] } thiserror = { version = "2.0.12", default-features = false } -tokio = { version = "1.48.0", features = ["macros", "rt-multi-thread", "sync", "time", "net", "fs", "signal"]} +tokio = { version = "1.48.0", features = ["full"]} uuid = { version = "1.16.0", features = ["v4", "serde"] } log = { version = "0.4.28" } anyhow = { version = "1.0.100", features = ["backtrace"] } @@ -20,7 +20,6 @@ axum_typed_multipart = "0.11.0" tower-http = { version = "0.6.1", features = ["cors", "trace", "limit", "timeout"] } tracing = "0.1.41" tracing-subscriber = { version = "0.3.20", features = ["fmt", "env-filter"]} -tracing-appender = "0.2.5" humantime-serde = "1.1.1" sqlx = { version = "0.8.6", features = ["sqlite", "runtime-tokio", "uuid", "chrono"] } chrono = { version = "0.4.41", features = ["serde"] } @@ -34,10 +33,7 @@ serde_json = "1.0.140" bimap = "0.6.3" ts-rs = { version = "10.1", features = ["uuid-impl", "chrono-impl"] } base64 = "0.22.1" -reconcile-text = { version = "0.11.0", features = ["serde"] } -rust-embed = "8.5" -mime_guess = "2.0" -subtle = "2.6.1" +reconcile-text = { version = "0.8.0", features = ["serde"] } [profile.release] codegen-units = 1 diff --git a/sync-server/build.rs b/sync-server/build.rs index 53bd111b..d5068697 100644 --- a/sync-server/build.rs +++ b/sync-server/build.rs @@ -1,16 +1,5 @@ +// generated by `sqlx migrate build-script` fn main() { // trigger recompilation when a new migration is added println!("cargo:rerun-if-changed=migrations"); - - // Ensure the history-ui dist directory exists so rust-embed can compile - // even when the frontend hasn't been built yet. - let dist_path = std::path::Path::new("../frontend/history-ui/dist"); - if !dist_path.exists() { - std::fs::create_dir_all(dist_path).expect("Failed to create history-ui dist directory"); - std::fs::write( - dist_path.join("index.html"), - "

Run npm run build -w history-ui first.

", - ) - .expect("Failed to write placeholder index.html"); - } } diff --git a/sync-server/config-e2e.yml b/sync-server/config-e2e.yml index 03b860b7..1f235b01 100644 --- a/sync-server/config-e2e.yml +++ b/sync-server/config-e2e.yml @@ -1,34 +1,32 @@ database: - databases_directory_path: /host/tmp/vaultlink-e2e-databases - max_connections_per_vault: 8 + databases_directory_path: databases + max_connections_per_vault: 12 cursor_timeout: 1m server: host: 0.0.0.0 - port: 3010 + port: 3000 max_body_size_mb: 512 max_clients_per_vault: 256 - max_pending_websocket_connections: 4096 - broadcast_channel_capacity: 1024 response_timeout: 30m mergeable_file_extensions: - - md - - txt + - md + - txt users: user_configs: - - name: admin - token: test-token-change-me - vault_access: - type: allow_access_to_all - - name: other-admin - token: test-token-change-me2 - vault_access: - type: allow_access_to_all - - name: test - token: other-test-token - vault_access: - type: allow_list - allowed: - - default + - name: admin + token: test-token-change-me + vault_access: + type: allow_access_to_all + - name: other-admin + token: test-token-change-me2 + vault_access: + type: allow_access_to_all + - name: test + token: other-test-token + vault_access: + type: allow_list + allowed: + - default logging: log_directory: logs log_rotation: 7days diff --git a/sync-server/rust-toolchain.toml b/sync-server/rust-toolchain.toml index 567721ef..010956cc 100644 --- a/sync-server/rust-toolchain.toml +++ b/sync-server/rust-toolchain.toml @@ -1,5 +1,5 @@ [toolchain] -channel = "1.94.0" +channel = "1.89.0" targets = [ "x86_64-unknown-linux-gnu", "x86_64-unknown-linux-musl", diff --git a/sync-server/src/app_state.rs b/sync-server/src/app_state.rs index 1bd3222e..2019e08e 100644 --- a/sync-server/src/app_state.rs +++ b/sync-server/src/app_state.rs @@ -2,8 +2,6 @@ pub mod cursors; pub mod database; pub mod websocket; -use std::sync::{Arc, atomic::AtomicUsize}; - use anyhow::Result; use cursors::Cursors; use database::Database; @@ -17,42 +15,21 @@ pub struct AppState { pub database: Database, pub cursors: Cursors, pub broadcasts: Broadcasts, - /// Tracks WebSocket connections that have upgraded but not yet completed - /// the authentication handshake - pub pending_ws_connections: Arc, - /// Send on this channel to stop background tasks (cursor cleanup, - /// idle-pool cleanup) - shutdown_tx: Arc>, } impl AppState { pub async fn try_new(config: Config) -> Result { - let (shutdown_tx, shutdown_rx) = tokio::sync::watch::channel(()); - let broadcasts = Broadcasts::new(&config.server); - let database = - Database::try_new(&config.database, &broadcasts, shutdown_rx.clone()).await?; + let database = Database::try_new(&config.database, &broadcasts).await?; let cursors: Cursors = Cursors::new(&config.database, &broadcasts); - Cursors::start_background_task(cursors.clone(), shutdown_rx); + Cursors::start_background_task(cursors.clone()); Ok(Self { config, database, cursors, broadcasts, - pending_ws_connections: Arc::new(AtomicUsize::new(0)), - shutdown_tx: Arc::new(shutdown_tx), }) } - - /// Signal all background tasks (idle pool cleanup, cursor cleanup) to stop - pub fn shutdown(&self) { - let _ = self.shutdown_tx.send(()); - } - - /// Get a receiver to be notified when shutdown is triggered - pub fn subscribe_shutdown(&self) -> tokio::sync::watch::Receiver<()> { - self.shutdown_tx.subscribe() - } } diff --git a/sync-server/src/app_state/cursors.rs b/sync-server/src/app_state/cursors.rs index e17fb4f7..d083e1ac 100644 --- a/sync-server/src/app_state/cursors.rs +++ b/sync-server/src/app_state/cursors.rs @@ -42,9 +42,7 @@ impl Cursors { ) { let mut vault_to_cursors = self.vault_to_cursors.lock().await; - let all_device_cursors = vault_to_cursors - .entry(vault_id.clone()) - .or_insert_with(Vec::new); + let all_device_cursors = vault_to_cursors.entry(vault_id).or_insert_with(Vec::new); all_device_cursors.retain(|c| &c.client_cursors.device_id != device_id); all_device_cursors.push(ClientCursorsWithTimeToLive::new(ClientCursors { @@ -54,7 +52,7 @@ impl Cursors { })); drop(vault_to_cursors); // Explicitly drop the lock before broadcasting to avoid deadlock - self.broadcast_cursors_for_vault(&vault_id).await; + self.broadcast_cursors().await; } pub async fn get_cursors(&self, vault_id: &VaultId) -> Vec { @@ -71,81 +69,45 @@ impl Cursors { .unwrap_or_default() } - pub fn start_background_task(self, mut shutdown: tokio::sync::watch::Receiver<()>) { + pub fn start_background_task(self) { tokio::spawn(async move { loop { - tokio::select! { - () = tokio::time::sleep(Duration::from_secs(1)) => { - self.remove_expired_cursors().await; - } - Ok(()) = shutdown.changed() => break, - } + self.remove_expired_cursors().await; + tokio::time::sleep(Duration::from_secs(1)).await; } }); } async fn remove_expired_cursors(&self) { - let changed_vaults: Vec = { - let mut vault_to_cursors = self.vault_to_cursors.lock().await; + let mut vault_to_cursors = self.vault_to_cursors.lock().await; - let mut changed = Vec::new(); - for (vault_id, cursors) in vault_to_cursors.iter_mut() { - let before = cursors.len(); - cursors.retain(|cursor| !cursor.is_expired(self.config.cursor_timeout)); - if cursors.len() != before { - changed.push(vault_id.clone()); - } - } - - // Remove empty vault entries to prevent unbounded growth - vault_to_cursors.retain(|_, cursors| !cursors.is_empty()); - - changed - }; - - for vault_id in &changed_vaults { - self.broadcast_cursors_for_vault(vault_id).await; + for (_vault_id, cursors) in vault_to_cursors.iter_mut() { + cursors.retain(|cursor| !cursor.is_expired(self.config.cursor_timeout)); } } - async fn broadcast_cursors_for_vault(&self, vault_id: &VaultId) { - let client_cursors: Vec = { - let vault_to_cursors = self.vault_to_cursors.lock().await; - vault_to_cursors - .get(vault_id) - .map(|cursors| cursors.iter().map(|c| c.client_cursors.clone()).collect()) - .unwrap_or_default() - }; + async fn broadcast_cursors(&self) { + let vault_to_cursors = self.vault_to_cursors.lock().await; - self.broadcasts.send_document_update( - vault_id.clone(), - WebSocketServerMessageWithOrigin::new(WebSocketServerMessage::CursorPositions( - CursorPositionFromServer { - clients: client_cursors, - }, - )), - ); + for (vault_id, cursors) in vault_to_cursors.iter() { + self.broadcasts + .send_document_update( + vault_id.clone(), + WebSocketServerMessageWithOrigin::new(WebSocketServerMessage::CursorPositions( + CursorPositionFromServer { + clients: cursors.iter().map(|c| c.client_cursors.clone()).collect(), + }, + )), + ) + .await; + } } - pub async fn remove_cursors_of_device(&self, vault_id: &VaultId, device_id: &DeviceId) { - let changed = { - let mut vault_to_cursors = self.vault_to_cursors.lock().await; + pub async fn remove_cursors_of_device(&self, vault_id: &str, device_id: &str) { + let mut vault_to_cursors = self.vault_to_cursors.lock().await; - if let Some(cursors) = vault_to_cursors.get_mut(vault_id) { - let before = cursors.len(); - cursors.retain(|c| c.client_cursors.device_id != *device_id); - let changed = cursors.len() != before; - if cursors.is_empty() { - vault_to_cursors.remove(vault_id); - } - changed - } else { - false - } - }; - - if changed { - self.broadcast_cursors_for_vault(vault_id).await; + if let Some(cursors) = vault_to_cursors.get_mut(vault_id) { + cursors.retain(|c| c.client_cursors.device_id != device_id); } } } diff --git a/sync-server/src/app_state/database.rs b/sync-server/src/app_state/database.rs index 28acde41..75ce6df4 100644 --- a/sync-server/src/app_state/database.rs +++ b/sync-server/src/app_state/database.rs @@ -1,29 +1,16 @@ use core::time::Duration; -use std::{ - collections::HashMap, - sync::Arc, - sync::atomic::{AtomicU64, Ordering}, -}; +use std::{collections::HashMap, sync::Arc}; use anyhow::{Context as _, Result}; use log::info; use models::{ DocumentId, DocumentVersionWithoutContent, StoredDocumentVersion, VaultId, VaultUpdateId, }; -use sqlx::{ConnectOptions, Connection, sqlite::SqliteConnectOptions, types::chrono::Utc}; +use sqlx::{ConnectOptions, sqlite::SqliteConnectOptions, types::chrono::Utc}; pub mod models; - -/// Sentinel error indicating the `SQLite` database is busy (`SQLITE_BUSY`). -/// Handlers can downcast to this to return 429 instead of 500. -#[derive(Debug, thiserror::Error)] -#[error("Database is busy")] -pub struct WriteBusyError; - -use sqlx::{ - Pool, Sqlite, pool::PoolConnection, sqlite::SqliteConnection, sqlite::SqlitePoolOptions, -}; -use tokio::sync::{Mutex, OnceCell}; +use sqlx::{Pool, Sqlite, sqlite::SqlitePoolOptions}; +use tokio::sync::Mutex; use tokio::time::Instant; use uuid::fmt::Hyphenated; @@ -32,200 +19,33 @@ use super::websocket::{ models::{WebSocketServerMessage, WebSocketServerMessageWithOrigin, WebSocketVaultUpdate}, }; use crate::config::database_config::DatabaseConfig; -use crate::consts::IDLE_POOL_TIMEOUT; -/// Holds separate reader and writer pools for a single vault. -/// The writer pool has exactly 1 connection so writes never compete -/// with reads for pool slots. -#[derive(Debug, Clone)] -struct VaultPools { - reader: Pool, - writer: Pool, +#[derive(Clone)] +struct PoolWithTimestamp { + pool: Pool, + last_accessed: Instant, } -#[derive(Debug)] -struct VaultPool { - cell: Arc>, - /// Monotonic timestamp in milliseconds (from `Instant::now()` at server start) - last_accessed_ms: AtomicU64, +impl std::fmt::Debug for PoolWithTimestamp { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PoolWithTimestamp") + .field("pool", &"Pool") + .field("last_accessed", &self.last_accessed) + .finish() + } } #[derive(Clone, Debug)] pub struct Database { config: DatabaseConfig, broadcasts: Broadcasts, - connection_pools: Arc>>>, - /// Per-vault write serialization. `SQLite` allows only one writer at a - /// time; `BEGIN IMMEDIATE` on a second connection blocks until the first - /// commits (up to `busy_timeout`). Under concurrent load the blocked - /// connections consume the pool, starving even read-only requests. - /// This mutex moves the wait from the `SQLite` layer (where it holds a - /// pool connection) to the Tokio layer (where it holds nothing). - write_locks: Arc>>>>, - /// Monotonic epoch for lock-free `last_accessed_ms` timestamps - epoch: Instant, + connection_pools: Arc>>, } -/// A write transaction backed by a raw `BEGIN IMMEDIATE` instead of sqlx's -/// savepoint-based `Transaction`. This avoids the savepoint mismatch caused -/// by the old `END; BEGIN IMMEDIATE;` workaround. -/// -/// Holds an `OwnedMutexGuard` that serializes write transactions per vault -/// at the application level (see `Database::write_locks`). The guard is -/// released when the transaction is committed, rolled back, or dropped. -pub struct WriteTransaction { - conn: Option>, - _write_guard: tokio::sync::OwnedMutexGuard<()>, -} - -impl WriteTransaction { - async fn new( - pool: &Pool, - write_guard: tokio::sync::OwnedMutexGuard<()>, - ) -> Result { - let mut conn = pool - .acquire() - .await - .context("Cannot acquire connection for write transaction")?; - if let Err(e) = sqlx::query("BEGIN IMMEDIATE").execute(&mut *conn).await { - let is_busy = match &e { - sqlx::Error::Database(db_err) => { - // SQLITE_BUSY base code is 5. Extended codes share base 5. - let busy_by_code = db_err - .code() - .is_some_and(|c| c.parse::().is_ok_and(|n| n & 0xFF == 5)); - busy_by_code || db_err.message().contains("database is locked") - } - _ => false, - }; - if is_busy { - return Err(WriteBusyError.into()); - } - return Err(e).context("Cannot begin immediate transaction"); - } - Ok(Self { - conn: Some(conn), - _write_guard: write_guard, - }) - } - - pub async fn commit(mut self) -> Result<()> { - if let Some(mut conn) = self.conn.take() { - sqlx::query("COMMIT") - .execute(&mut *conn) - .await - .context("Failed to commit transaction")?; - } - Ok(()) - } - - pub async fn rollback(mut self) -> Result<()> { - if let Some(mut conn) = self.conn.take() { - sqlx::query("ROLLBACK") - .execute(&mut *conn) - .await - .context("Failed to rollback transaction")?; - } - Ok(()) - } -} - -impl Drop for WriteTransaction { - fn drop(&mut self) { - if self.conn.is_some() { - // The connection is returned to the pool with an open transaction. - // The pool's `before_acquire` hook issues a ROLLBACK before - // handing it to the next consumer, so no async work is needed - // here. If the pool is being shut down, SQLite itself rolls back - // uncommitted transactions when the connection closes. - log::warn!("WriteTransaction dropped without commit or rollback"); - } - } -} - -impl std::ops::Deref for WriteTransaction { - type Target = SqliteConnection; - fn deref(&self) -> &Self::Target { - self.conn - .as_ref() - .expect("BUG: WriteTransaction dereferenced after being consumed") - .deref() - } -} - -impl std::ops::DerefMut for WriteTransaction { - fn deref_mut(&mut self) -> &mut Self::Target { - self.conn - .as_mut() - .expect("BUG: WriteTransaction dereferenced after being consumed") - .deref_mut() - } -} - -/// Ensure the connection has no leftover open transaction (e.g. from a -/// `WriteTransaction` that was dropped without commit/rollback). ROLLBACK -/// is a harmless no-op if no transaction is active. -fn rollback_before_acquire( - conn: &mut SqliteConnection, - _meta: sqlx::pool::PoolConnectionMetadata, -) -> futures::future::BoxFuture<'_, Result> { - Box::pin(async move { - if let Err(e) = sqlx::query("ROLLBACK").execute(&mut *conn).await { - // "cannot rollback - no transaction is active" is the common - // case (connection returned cleanly). Only unexpected errors - // deserve attention. - log::debug!("before_acquire ROLLBACK failed: {e}"); - } - Ok(true) - }) -} +pub type Transaction<'a> = sqlx::Transaction<'a, Sqlite>; impl Database { - fn now_ms(&self) -> u64 { - self.epoch.elapsed().as_millis() as u64 - } - - /// Lists all vault IDs that exist on disk (have a `.sqlite` file). - pub async fn list_vaults(&self) -> Result> { - let mut vaults = Vec::new(); - let mut entries = tokio::fs::read_dir(&self.config.databases_directory_path) - .await - .context("Failed to read databases directory")?; - while let Some(entry) = entries.next_entry().await? { - let name = entry.file_name().to_string_lossy().to_string(); - if let Some(vault) = name.strip_suffix(".sqlite") { - vaults.push(vault.to_owned()); - } - } - vaults.sort(); - Ok(vaults) - } - - pub async fn get_vault_stats(&self, vault: &VaultId) -> Result { - let pool = self.get_connection_pool(vault).await?; - let row = sqlx::query!( - r#" - SELECT - (SELECT MIN(updated_date) FROM documents) - AS "created_at: chrono::DateTime", - (SELECT COUNT(DISTINCT document_id) FROM latest_document_versions - WHERE is_deleted = false) - AS "document_count!: u32" - "#, - ) - .fetch_one(&pool) - .await?; - Ok(models::VaultStats { - created_at: row.created_at, - document_count: row.document_count, - }) - } - - pub async fn try_new( - config: &DatabaseConfig, - broadcasts: &Broadcasts, - shutdown: tokio::sync::watch::Receiver<()>, - ) -> Result { + pub async fn try_new(config: &DatabaseConfig, broadcasts: &Broadcasts) -> Result { tokio::fs::create_dir_all(&config.databases_directory_path) .await .with_context(|| { @@ -250,207 +70,122 @@ impl Database { .trim_end_matches(".sqlite") .to_owned(); - Self::validate_vault_id(&vault)?; - - let pools = Self::create_vault_database(config, &vault).await?; - let cell = Arc::new(OnceCell::new()); - cell.set(pools).expect("cell is new"); + let pool = Self::create_vault_database(config, &vault).await?; connection_pools.insert( vault.clone(), - Arc::new(VaultPool { - cell, - last_accessed_ms: AtomicU64::new(0), - }), + PoolWithTimestamp { + pool, + last_accessed: Instant::now(), + }, ); } - info!("Database migrations applied"); let database = Self { config: config.clone(), connection_pools: Arc::new(Mutex::new(connection_pools)), broadcasts: broadcasts.clone(), - write_locks: Arc::new(Mutex::new(HashMap::new())), - epoch: Instant::now(), }; - database.start_idle_pool_cleanup(shutdown); + // Start background task to cleanup idle connection pools + database.start_idle_pool_cleanup(); Ok(database) } - async fn create_vault_database(config: &DatabaseConfig, vault: &VaultId) -> Result { + async fn create_vault_database( + config: &DatabaseConfig, + vault: &VaultId, + ) -> Result> { let file_name = config .databases_directory_path .join(format!("{vault}.sqlite")); - // Database-level PRAGMAs (auto_vacuum, journal_mode) require a write - // lock and persist across connections. Set them once with a dedicated - // init connection so pool connections never need the write lock just to - // open. - let init_options = SqliteConnectOptions::new() + let connection_options = SqliteConnectOptions::new() .filename(file_name.clone()) .create_if_missing(true) - .auto_vacuum(sqlx::sqlite::SqliteAutoVacuum::Incremental) - .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal); - - // Run migrations on a dedicated connection, NOT through the pool. - // The pool's `before_acquire` hook issues ROLLBACK on every checkout, - // which can roll back the migration's bookkeeping transaction (the - // _sqlx_migrations INSERT) while the DDL (ALTER TABLE) has already - // auto-committed — leaving the migration in a dirty state. - // - // Uses `run_direct` instead of `run` because `run` takes - // `impl Acquire<'_>`, whose lifetime bound prevents the enclosing - // future from satisfying the `Send` requirement of axum handlers. - let mut init_conn = sqlx::SqliteConnection::connect_with(&init_options).await?; - sqlx::migrate!("src/app_state/database/migrations") - .run_direct(&mut init_conn) - .await - .context("Cannot run pending migrations")?; - drop(init_conn); - - // Per-connection PRAGMAs shared by both reader and writer pools. - // journal_mode = WAL is a no-op on an already-WAL database. - let base_options = SqliteConnectOptions::new() - .filename(file_name.clone()) + .auto_vacuum(sqlx::sqlite::SqliteAutoVacuum::Full) + .busy_timeout(Duration::from_secs(3600)) .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal) - .busy_timeout(Duration::from_secs(30)) - .log_slow_statements(log::LevelFilter::Warn, Duration::from_secs(30)) - // In WAL mode, NORMAL is safe: data survives OS crashes, only the - // last transaction can be lost on power failure. The default FULL - // forces an extra fsync() per commit, roughly halving write throughput. - .pragma("synchronous", "NORMAL") - // 16 MB page cache per connection (negative = KiB). Reduces disk - // reads for the latest_document_versions GROUP BY view. - .pragma("cache_size", "-16384") - // Memory-mapped I/O avoids read() syscalls. SQLite falls back to - // regular I/O for writes and beyond the mapped region. 256 MB is - // conservative; the OS handles actual memory pressure. - .pragma("mmap_size", "268435456") - // Keep temp tables and sort spillovers in memory instead of temp files. - .pragma("temp_store", "MEMORY") - // Cap WAL file growth at 64 MB. Without this, the WAL can grow - // unbounded during heavy write bursts (e.g. E2E tests with many - // concurrent clients). SQLite truncates to this size on checkpoint. - .pragma("journal_size_limit", "67108864"); + .log_slow_statements(log::LevelFilter::Warn, Duration::from_secs(30)); - // Reader pool: multiple connections for concurrent reads. - let reader = SqlitePoolOptions::new() + let pool = SqlitePoolOptions::new() .max_connections(config.max_connections_per_vault) .acquire_slow_threshold(Duration::from_secs(30)) - // Disabled: the health-check query is subject to busy_timeout - // and blocks all connection checkouts when a write is active, - // starving the pool for up to 30s even for simple reads. - // The before_acquire ROLLBACK hook is sufficient for cleanup. - .test_before_acquire(false) - .before_acquire(rollback_before_acquire) - .connect_with(base_options.clone()) + .test_before_acquire(true) + .connect_with(connection_options) .await - .with_context(|| format!("Cannot open reader pool at `{}`", file_name.display()))?; + .with_context(|| format!("Cannot open database at `{}`", file_name.display()))?; - // Writer pool: exactly 1 connection, dedicated to writes. - // Since the Tokio mutex already serializes writers per vault, this - // single connection is never contended. Separating it from the - // reader pool ensures writes never compete with reads for pool slots. - let writer = SqlitePoolOptions::new() - .max_connections(1) - .acquire_slow_threshold(Duration::from_secs(30)) - .test_before_acquire(false) - .before_acquire(rollback_before_acquire) - .connect_with(base_options) - .await - .with_context(|| format!("Cannot open writer pool at `{}`", file_name.display()))?; + Self::run_migrations(&pool).await?; - Ok(VaultPools { reader, writer }) + Ok(pool) } - fn validate_vault_id(vault: &VaultId) -> Result<()> { - if vault.is_empty() { - anyhow::bail!("Vault ID must not be empty"); - } - if vault.contains('/') - || vault.contains('\\') - || vault.contains("..") - || vault.contains('\0') - { - anyhow::bail!( - "Invalid vault ID: must not contain path separators, '..', or null bytes" + async fn run_migrations(pool: &Pool) -> Result<()> { + sqlx::migrate!("src/app_state/database/migrations") + .run(pool) + .await + .context("Cannot check for pending migrations") + } + + async fn get_connection_pool(&self, vault: &VaultId) -> Result> { + let mut pools = self.connection_pools.lock().await; + + if !pools.contains_key(vault) { + let pool = Self::create_vault_database(&self.config, vault).await?; + pools.insert( + vault.clone(), + PoolWithTimestamp { + pool, + last_accessed: Instant::now(), + }, ); } - Ok(()) + + let pool_with_timestamp = pools + .get_mut(vault) + .expect("Pool was just inserted or already exists"); + + // Update last accessed time + pool_with_timestamp.last_accessed = Instant::now(); + + Ok(pool_with_timestamp.pool.clone()) } - async fn get_vault_pools(&self, vault: &VaultId) -> Result { - Self::validate_vault_id(vault)?; + /// Attempting to write from this transaction might result in a + /// database locked error. Use this transaction for read-only operations. + pub async fn create_readonly_transaction( + &self, + vault: &VaultId, + ) -> Result> { + self.get_connection_pool(vault) + .await? + .begin() + .await + .context("Cannot create transaction") + } - // Get or create the VaultPool entry. The global lock is held only - // long enough for a HashMap lookup/insert — never across - // create_vault_database. - let vault_pool = { - let mut pools = self.connection_pools.lock().await; - pools - .entry(vault.clone()) - .or_insert_with(|| { - Arc::new(VaultPool { - cell: Arc::new(OnceCell::new()), - last_accessed_ms: AtomicU64::new(self.now_ms()), - }) - }) - .clone() - }; + pub async fn create_write_transaction(&self, vault: &VaultId) -> Result> { + let mut transaction = self.create_readonly_transaction(vault).await?; - // OnceCell::get_or_try_init guarantees exactly-once - // initialization: concurrent callers for the same vault wait - // here; callers for other vaults are not blocked. - let config = self.config.clone(); - let vault_clone = vault.clone(); - let pools = vault_pool - .cell - .get_or_try_init(|| async { Self::create_vault_database(&config, &vault_clone).await }) + // sqlx doesn't support immediate transactions for sqlite: https://github.com/launchbadge/sqlx/issues/481 + sqlx::query!("END; BEGIN IMMEDIATE;") + .execute(&mut *transaction) .await?; - vault_pool - .last_accessed_ms - .store(self.now_ms(), Ordering::Relaxed); - Ok(pools.clone()) + Ok(transaction) } - /// Return the reader pool for read-only queries. - async fn get_connection_pool(&self, vault: &VaultId) -> Result> { - Ok(self.get_vault_pools(vault).await?.reader) - } - - pub async fn create_write_transaction(&self, vault: &VaultId) -> Result { - let write_lock = { - let mut locks = self.write_locks.lock().await; - locks - .entry(vault.clone()) - .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))) - .clone() - }; - let write_guard = write_lock.lock_owned().await; - let pools = self.get_vault_pools(vault).await?; - WriteTransaction::new(&pools.writer, write_guard).await - } - - /// Return the latest state of all documents in the vault, optionally - /// bounded above by `up_to_vault_update_id` so that the result is a - /// stable snapshot at exactly that cursor (commits past the cursor - /// will be delivered separately via the broadcast channel). + /// Return the latest state of all documents in the vault pub async fn get_latest_documents( &self, vault: &VaultId, - up_to_vault_update_id: Option, - connection: Option<&mut SqliteConnection>, + transaction: Option<&mut Transaction<'_>>, ) -> Result> { - // `i64::MAX` makes the upper bound a no-op for callers that don't - // care about an exact snapshot (they pass `None`). - let upper = up_to_vault_update_id.unwrap_or(i64::MAX); let query = sqlx::query!( r#" select vault_update_id, - creation_vault_update_id, document_id as "document_id: Hyphenated", relative_path, updated_date as "updated_date: chrono::DateTime", @@ -459,14 +194,12 @@ impl Database { device_id, length(content) as "content_size: u64" from latest_document_versions - where vault_update_id <= ? order by vault_update_id "#, - upper, ); - if let Some(conn) = connection { - query.fetch_all(&mut *conn).await + if let Some(transaction) = transaction { + query.fetch_all(&mut **transaction).await } else { query .fetch_all(&self.get_connection_pool(vault).await?) @@ -483,72 +216,42 @@ impl Database { is_deleted: row.is_deleted, user_id: row.user_id, device_id: row.device_id, - content_size: row.content_size.unwrap_or(0), - is_new_file: row.creation_vault_update_id == row.vault_update_id, + content_size: row + .content_size + .expect("Content size can't be null but sqlx can't infer it"), }) .collect() }) } /// Return the latest state of all documents (including deleted) in the - /// vault which have changed since the given update id, bounded above - /// by `up_to_vault_update_id` so the catch-up result is a stable - /// snapshot at exactly that cursor. Commits past the cursor will be - /// delivered separately via the broadcast channel. + /// vault which have changed since the given update id pub async fn get_latest_documents_since( &self, vault: &VaultId, vault_update_id: VaultUpdateId, - up_to_vault_update_id: Option, - connection: Option<&mut SqliteConnection>, + transaction: Option<&mut Transaction<'_>>, ) -> Result> { - // `i64::MAX` makes the upper bound a no-op for callers that don't - // care about an exact snapshot (they pass `None`). - let upper = up_to_vault_update_id.unwrap_or(i64::MAX); - // Compute "latest version as of `upper`" per document — NOT - // global latest. The `latest_document_versions` view is keyed - // on global max, so a write that commits between the catch-up's - // cursor capture (under broadcast send-lock) and this query - // (which runs after drop-lock) would expose a `vault_update_id - // > cursor` row that the cursor filter then drops, removing - // the doc from the catch-up entirely. The post-cursor live - // broadcast then carries `is_new_file = false` (per real-time - // semantics it's an update of a previously-existing version), - // and the receiving client — which has no record of the doc — - // ignores it as stale, stranding the doc forever. Computing - // the snapshot from the documents table directly with the - // upper bound applied at the GROUP BY layer keeps the - // catch-up self-contained at exactly the cursor. let query = sqlx::query!( r#" select - d.vault_update_id, - d.creation_vault_update_id, - d.document_id as "document_id: Hyphenated", - d.relative_path, - d.updated_date as "updated_date: chrono::DateTime", - d.is_deleted, - d.user_id, - d.device_id, - length(d.content) as "content_size: u64" - from documents d - inner join ( - select document_id, max(vault_update_id) as max_vid - from documents - where vault_update_id <= ? - group by document_id - ) latest_at_cursor - on d.document_id = latest_at_cursor.document_id - and d.vault_update_id = latest_at_cursor.max_vid - where d.vault_update_id > ? - order by d.vault_update_id + vault_update_id, + document_id as "document_id: Hyphenated", + relative_path, + updated_date as "updated_date: chrono::DateTime", + is_deleted, + user_id, + device_id, + length(content) as "content_size: u64" + from latest_document_versions + where vault_update_id > ? + order by vault_update_id "#, - upper, - vault_update_id, + vault_update_id ); - if let Some(conn) = connection { - query.fetch_all(&mut *conn).await + if let Some(transaction) = transaction { + query.fetch_all(&mut **transaction).await } else { query .fetch_all(&self.get_connection_pool(vault).await?) @@ -567,18 +270,9 @@ impl Database { is_deleted: row.is_deleted, user_id: row.user_id, device_id: row.device_id, - content_size: row.content_size.unwrap_or(0), - // For catch-up streams, "new file" means "new to this - // recipient" — the doc was created past the recipient's - // watermark. The catch-up only carries the doc's - // *latest* version (not its full history), so using - // `creation == latest` instead would mis-flag every - // doc that was created and then updated before the - // client reconnected, and the client's - // `processRemoteChange` would drop it as "stale - // RemoteChange for untracked, non-new document", - // silently leaking docs to clients catching up. - is_new_file: row.creation_vault_update_id > vault_update_id, + content_size: row + .content_size + .expect("Content size can't be null but sqlx can't infer it"), }) .collect() }) @@ -587,7 +281,7 @@ impl Database { pub async fn get_max_update_id_in_vault( &self, vault: &VaultId, - connection: Option<&mut SqliteConnection>, + transaction: Option<&mut Transaction<'_>>, ) -> Result { let query = sqlx::query!( r#" @@ -596,8 +290,8 @@ impl Database { "#, ); - if let Some(conn) = connection { - query.fetch_one(&mut *conn).await + if let Some(transaction) = transaction { + query.fetch_one(&mut **transaction).await } else { query .fetch_one(&self.get_connection_pool(vault).await?) @@ -607,18 +301,17 @@ impl Database { .context("Cannot fetch max update id in vault") } - pub async fn get_latest_non_deleted_document_by_path( + pub async fn get_latest_document_by_path( &self, vault: &VaultId, relative_path: &str, - connection: Option<&mut SqliteConnection>, + transaction: Option<&mut Transaction<'_>>, ) -> Result> { let query = sqlx::query_as!( StoredDocumentVersion, r#" select vault_update_id, - creation_vault_update_id, document_id as "document_id: Hyphenated", relative_path, updated_date as "updated_date: chrono::DateTime", @@ -637,8 +330,8 @@ impl Database { relative_path ); - if let Some(conn) = connection { - query.fetch_optional(&mut *conn).await + if let Some(transaction) = transaction { + query.fetch_optional(&mut **transaction).await } else { query .fetch_optional(&self.get_connection_pool(vault).await?) @@ -647,79 +340,11 @@ impl Database { .context("Cannot fetch latest document version") } - /// Find a doc whose CREATE was authored by this device with - /// matching content, and whose creation the requesting client - /// hasn't observed yet (`creation_vault_update_id > last_seen`). - /// Used by `create_document` to recover from a "lost create" - /// race: this device's create response was discarded mid-flight, - /// so the retry comes in as a brand-new create — possibly at a - /// renamed path. Binding the retry to the existing doc avoids - /// duplicating the content under a deconflicted path. - /// - /// Matches against the doc's CREATION version (not the latest) - /// because a same-path concurrent create from another agent may - /// have merged into our doc since: the latest version's content - /// is the merge result, not what we originally sent. Joining on - /// `creation_vault_update_id` recovers the original bytes. - /// - /// The `device_id` + `creation > last_seen` combination scopes - /// the dedup to "we genuinely lost track of our own create"; - /// another agent's same-content doc won't match because of - /// `device_id`, and a doc this client already saw won't match - /// because of the watermark check. - pub async fn find_unseen_lost_create_by_device_and_content( - &self, - vault: &VaultId, - device_id: &str, - last_seen_vault_update_id: VaultUpdateId, - content: &[u8], - connection: Option<&mut SqliteConnection>, - ) -> Result> { - let query = sqlx::query_as!( - StoredDocumentVersion, - r#" - select - lv.vault_update_id, - lv.creation_vault_update_id, - lv.document_id as "document_id: Hyphenated", - lv.relative_path, - lv.updated_date as "updated_date: chrono::DateTime", - lv.content, - lv.is_deleted, - lv.user_id, - lv.device_id, - lv.has_been_merged - from latest_document_versions lv - inner join documents creation - on creation.document_id = lv.document_id - and creation.vault_update_id = lv.creation_vault_update_id - where creation.device_id = ? - and creation.content = ? - and lv.is_deleted = false - and lv.creation_vault_update_id > ? - order by lv.creation_vault_update_id desc - limit 1 - "#, - device_id, - content, - last_seen_vault_update_id, - ); - - if let Some(conn) = connection { - query.fetch_optional(&mut *conn).await - } else { - query - .fetch_optional(&self.get_connection_pool(vault).await?) - .await - } - .context("Cannot fetch lost-create candidate") - } - pub async fn get_latest_document( &self, vault: &VaultId, document_id: &DocumentId, - connection: Option<&mut SqliteConnection>, + transaction: Option<&mut Transaction<'_>>, ) -> Result> { let document_id = document_id.as_hyphenated(); let query = sqlx::query_as!( @@ -727,7 +352,6 @@ impl Database { r#" select vault_update_id, - creation_vault_update_id, document_id as "document_id: Hyphenated", relative_path, updated_date as "updated_date: chrono::DateTime", @@ -742,8 +366,8 @@ impl Database { document_id ); - if let Some(conn) = connection { - query.fetch_optional(&mut *conn).await + if let Some(transaction) = transaction { + query.fetch_optional(&mut **transaction).await } else { query .fetch_optional(&self.get_connection_pool(vault).await?) @@ -756,14 +380,13 @@ impl Database { &self, vault: &VaultId, vault_update_id: VaultUpdateId, - connection: Option<&mut SqliteConnection>, + transaction: Option<&mut Transaction<'_>>, ) -> Result> { let query = sqlx::query_as!( StoredDocumentVersion, r#" select vault_update_id, - creation_vault_update_id, document_id as "document_id: Hyphenated", relative_path, updated_date as "updated_date: chrono::DateTime", @@ -777,8 +400,8 @@ impl Database { vault_update_id ); - if let Some(conn) = connection { - query.fetch_optional(&mut *conn).await + if let Some(transaction) = transaction { + query.fetch_optional(&mut **transaction).await } else { query .fetch_optional(&self.get_connection_pool(vault).await?) @@ -787,307 +410,105 @@ impl Database { .context("Cannot fetch document version") } - // inserting the document must be the last step of the transaction + // inserting the document must be the last step of the transaction if there's one pub async fn insert_document_version( &self, vault_id: &VaultId, version: &StoredDocumentVersion, - mut transaction: WriteTransaction, + transaction: Option>, ) -> Result<()> { let document_id = version.document_id.as_hyphenated(); let query = sqlx::query!( r#" insert into documents ( vault_update_id, - creation_vault_update_id, document_id, relative_path, updated_date, content, is_deleted, user_id, - device_id, - has_been_merged + device_id ) - values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + values (?, ?, ?, ?, ?, ?, ?, ?) "#, version.vault_update_id, - version.creation_vault_update_id, document_id, version.relative_path, version.updated_date, version.content, version.is_deleted, version.user_id, - version.device_id, - version.has_been_merged + version.device_id ); - // Acquire the broadcast send lock before the insert so that - // broadcasts are serialized in vault_update_id order even after - // the write transaction (and its per-vault lock) is released. - let _send_guard = self.broadcasts.acquire_send_lock(vault_id).await; + if let Some(mut transaction) = transaction { + query + .execute(&mut *transaction) + .await + .context("Cannot insert document version")?; - query - .execute(&mut *transaction) - .await - .context("Cannot insert document version")?; - - transaction - .commit() - .await - .context("Failed to commit transaction")?; - - // For non-delete writes the originating device already has - // authoritative state from its HTTP response, so we tag the - // broadcast with `origin_device_id` and the send task in - // `websocket.rs` filters it out for that device. Deletes are - // delivered to *every* connected client including the author — - // the originator only removes the document from its sync queue - // once it receives this receipt. - let envelope = WebSocketServerMessage::VaultUpdate(WebSocketVaultUpdate { - document: version.clone().into(), - }); - let with_origin = if version.is_deleted { - WebSocketServerMessageWithOrigin::new(envelope) + transaction + .commit() + .await + .context("Failed to commit transaction")?; } else { - WebSocketServerMessageWithOrigin::with_origin(version.device_id.clone(), envelope) - }; + query + .execute(&self.get_connection_pool(vault_id).await?) + .await + .context("Cannot insert document version")?; + } + self.broadcasts - .send_document_update(vault_id.clone(), with_origin); + .send_document_update( + vault_id.clone(), + WebSocketServerMessageWithOrigin::with_origin( + version.device_id.clone(), + WebSocketServerMessage::VaultUpdate(WebSocketVaultUpdate { + documents: vec![version.clone().into()], + is_initial_sync: false, + }), + ), + ) + .await; Ok(()) } - /// Return all versions (without content) of a specific document, ordered by `vault_update_id` - pub async fn get_document_versions( - &self, - vault: &VaultId, - document_id: &DocumentId, - connection: Option<&mut SqliteConnection>, - ) -> Result> { - let document_id = document_id.as_hyphenated(); - let query = sqlx::query!( - r#" - select - vault_update_id, - creation_vault_update_id, - document_id as "document_id: Hyphenated", - relative_path, - updated_date as "updated_date: chrono::DateTime", - is_deleted, - user_id, - device_id, - length(content) as "content_size: u64" - from documents - where document_id = ? - order by vault_update_id - "#, - document_id, - ); - - if let Some(conn) = connection { - query.fetch_all(&mut *conn).await - } else { - query - .fetch_all(&self.get_connection_pool(vault).await?) - .await - } - .with_context(|| format!("Cannot fetch document versions for document `{document_id}`")) - .map(|rows| { - rows.into_iter() - .map(|row| DocumentVersionWithoutContent { - vault_update_id: row.vault_update_id, - document_id: row.document_id.into(), - relative_path: row.relative_path, - updated_date: row.updated_date, - is_deleted: row.is_deleted, - user_id: row.user_id, - device_id: row.device_id, - content_size: row.content_size.unwrap_or(0), - is_new_file: row.creation_vault_update_id == row.vault_update_id, - }) - .collect() - }) - } - - /// Return all versions across all documents, paginated, ordered by `vault_update_id` DESC - pub async fn get_vault_history( - &self, - vault: &VaultId, - limit: i64, - before_update_id: Option, - connection: Option<&mut SqliteConnection>, - ) -> Result> { - let map_row = |row: models::VaultHistoryRow| DocumentVersionWithoutContent { - vault_update_id: row.vault_update_id, - document_id: row.document_id, - relative_path: row.relative_path, - updated_date: row.updated_date, - is_deleted: row.is_deleted, - user_id: row.user_id, - device_id: row.device_id, - content_size: row.content_size.unwrap_or(0), - is_new_file: row.creation_vault_update_id == row.vault_update_id, - }; - - if let Some(before) = before_update_id { - let query = sqlx::query_as!( - models::VaultHistoryRow, - r#" - select - vault_update_id, - creation_vault_update_id, - document_id as "document_id: Hyphenated", - relative_path, - updated_date as "updated_date: chrono::DateTime", - is_deleted, - user_id, - device_id, - length(content) as "content_size: u64" - from documents - where vault_update_id < ? - order by vault_update_id desc - limit ? - "#, - before, - limit, - ); - - let rows = if let Some(conn) = connection { - query.fetch_all(&mut *conn).await - } else { - query - .fetch_all(&self.get_connection_pool(vault).await?) - .await - } - .context("Cannot fetch vault history")?; - - Ok(rows.into_iter().map(map_row).collect()) - } else { - let query = sqlx::query_as!( - models::VaultHistoryRow, - r#" - select - vault_update_id, - creation_vault_update_id, - document_id as "document_id: Hyphenated", - relative_path, - updated_date as "updated_date: chrono::DateTime", - is_deleted, - user_id, - device_id, - length(content) as "content_size: u64" - from documents - order by vault_update_id desc - limit ? - "#, - limit, - ); - - let rows = if let Some(conn) = connection { - query.fetch_all(&mut *conn).await - } else { - query - .fetch_all(&self.get_connection_pool(vault).await?) - .await - } - .context("Cannot fetch vault history")?; - - Ok(rows.into_iter().map(map_row).collect()) - } - } - /// Cleanup idle connection pools that haven't been accessed in more than 5 minutes async fn cleanup_idle_pools(&self) { - // Collect idle vaults and remove them from the map while holding - // the lock briefly. Close pools OUTSIDE the lock so that - // pool.close().await doesn't block other get_connection_pool calls. - let idle_pools: Vec<(VaultId, Arc)> = { - let mut pools = self.connection_pools.lock().await; - let now_ms = self.now_ms(); - let idle_threshold_ms = IDLE_POOL_TIMEOUT.as_millis() as u64; + let mut pools = self.connection_pools.lock().await; + let now = Instant::now(); + let idle_timeout = Duration::from_secs(5 * 60); // 5 minutes - let vaults_to_remove: Vec = pools - .iter() - .filter(|(_, vp)| { - let last = vp.last_accessed_ms.load(Ordering::Relaxed); - now_ms.saturating_sub(last) > idle_threshold_ms - }) - .map(|(vault_id, _)| vault_id.clone()) - .collect(); - - vaults_to_remove - .into_iter() - .filter_map(|id| pools.remove(&id).map(|vp| (id, vp))) - .collect() - }; - - // Close pools concurrently so cleanup doesn't serialise across vaults - let closures: Vec<_> = idle_pools - .into_iter() - .filter_map(|(vault_id, vault_pool)| { - vault_pool - .cell - .get() - .cloned() - .map(|pools| (vault_id, pools)) + // Collect vaults to remove + let vaults_to_remove: Vec = pools + .iter() + .filter(|(_, pool_with_timestamp)| { + now.duration_since(pool_with_timestamp.last_accessed) > idle_timeout }) + .map(|(vault_id, _)| vault_id.clone()) .collect(); - let handles: Vec<_> = closures - .into_iter() - .map(|(vault_id, pools)| { - tokio::spawn(async move { - // Checkpoint the WAL before closing to reclaim disk space. - // Run on the blocking pool so disk I/O doesn't starve the runtime - let writer_clone = pools.writer.clone(); - let ckpt_result = tokio::task::spawn_blocking(move || { - futures::executor::block_on( - sqlx::query("PRAGMA wal_checkpoint(TRUNCATE)").execute(&writer_clone), - ) - }) - .await; - - match ckpt_result { - Ok(Err(e)) => { - log::warn!("WAL checkpoint failed for vault `{vault_id}`: {e}"); - } - Err(e) => { - log::warn!("WAL checkpoint task panicked for vault `{vault_id}`: {e}"); - } - _ => {} - } - - info!("Closing idle database connection pools for vault `{vault_id}`"); - pools.reader.close().await; - pools.writer.close().await; - }) - }) - .collect(); - - for handle in handles { - let _ = handle.await; + // Close and remove idle pools + for vault_id in &vaults_to_remove { + if let Some(pool_with_timestamp) = pools.remove(vault_id) { + info!("Closing idle database connection pool for vault `{vault_id}`"); + pool_with_timestamp.pool.close().await; + } } } /// Start a background task that periodically cleans up idle connection pools - fn start_idle_pool_cleanup(&self, mut shutdown: tokio::sync::watch::Receiver<()>) { + fn start_idle_pool_cleanup(&self) { let database = self.clone(); tokio::spawn(async move { let mut interval = tokio::time::interval(Duration::from_secs(60)); // Check every minute interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); loop { - tokio::select! { - _ = interval.tick() => { - database.cleanup_idle_pools().await; - } - _ = shutdown.changed() => { - info!("Idle pool cleanup task shutting down"); - break; - } - } + interval.tick().await; + database.cleanup_idle_pools().await; } }); } diff --git a/sync-server/src/app_state/database/migrations/20260314000000_add_idempotency_key.sql b/sync-server/src/app_state/database/migrations/20260314000000_add_idempotency_key.sql deleted file mode 100644 index f3ee8dd3..00000000 --- a/sync-server/src/app_state/database/migrations/20260314000000_add_idempotency_key.sql +++ /dev/null @@ -1,2 +0,0 @@ -CREATE INDEX IF NOT EXISTS idx_documents_document_id -ON documents (document_id, vault_update_id); diff --git a/sync-server/src/app_state/database/migrations/20260421000000_add_creation_vault_update_id.sql b/sync-server/src/app_state/database/migrations/20260421000000_add_creation_vault_update_id.sql deleted file mode 100644 index 40dc85fb..00000000 --- a/sync-server/src/app_state/database/migrations/20260421000000_add_creation_vault_update_id.sql +++ /dev/null @@ -1,20 +0,0 @@ -ALTER TABLE documents ADD COLUMN creation_vault_update_id INTEGER NOT NULL DEFAULT 0; - -UPDATE documents -SET creation_vault_update_id = ( - SELECT MIN(d2.vault_update_id) - FROM documents d2 - WHERE d2.document_id = documents.document_id -); - -DROP VIEW latest_document_versions; - -CREATE VIEW IF NOT EXISTS latest_document_versions AS --recreate view as it now includes one more field -SELECT d.* -FROM documents d -INNER JOIN ( - SELECT MAX(vault_update_id) AS max_version_id - FROM documents - GROUP BY document_id -) max_versions -ON d.vault_update_id = max_versions.max_version_id; diff --git a/sync-server/src/app_state/database/models.rs b/sync-server/src/app_state/database/models.rs index 89867067..a216125a 100644 --- a/sync-server/src/app_state/database/models.rs +++ b/sync-server/src/app_state/database/models.rs @@ -13,7 +13,6 @@ pub type DeviceId = String; #[derive(Debug, Clone)] pub struct StoredDocumentVersion { pub vault_update_id: VaultUpdateId, - pub creation_vault_update_id: VaultUpdateId, pub document_id: DocumentId, pub relative_path: String, pub updated_date: DateTime, @@ -34,7 +33,7 @@ impl PartialEq for StoredDocumentVersion { #[derive(TS, Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct DocumentVersionWithoutContent { - #[ts(type = "number")] + #[ts(as = "i32")] pub vault_update_id: VaultUpdateId, pub document_id: DocumentId, @@ -44,16 +43,12 @@ pub struct DocumentVersionWithoutContent { pub user_id: UserId, pub device_id: DeviceId, - #[ts(type = "number")] + #[ts(as = "i32")] pub content_size: u64, - - /// True iff this is the first version of the document - pub is_new_file: bool, } impl From for DocumentVersionWithoutContent { fn from(value: StoredDocumentVersion) -> Self { - let is_new_file = value.creation_vault_update_id == value.vault_update_id; Self { vault_update_id: value.vault_update_id, document_id: value.document_id, @@ -63,7 +58,6 @@ impl From for DocumentVersionWithoutContent { user_id: value.user_id, device_id: value.device_id, content_size: value.content.len() as u64, - is_new_file, } } } @@ -71,7 +65,7 @@ impl From for DocumentVersionWithoutContent { #[derive(TS, Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct DocumentVersion { - #[ts(type = "number")] + #[ts(as = "i32")] pub vault_update_id: VaultUpdateId, pub document_id: DocumentId, @@ -83,25 +77,6 @@ pub struct DocumentVersion { pub device_id: DeviceId, } -/// Row struct for vault history queries (used by `sqlx::query_as!`) -#[derive(Debug)] -pub struct VaultHistoryRow { - pub vault_update_id: VaultUpdateId, - pub creation_vault_update_id: VaultUpdateId, - pub document_id: DocumentId, - pub relative_path: String, - pub updated_date: DateTime, - pub is_deleted: bool, - pub user_id: String, - pub device_id: String, - pub content_size: Option, -} - -pub struct VaultStats { - pub created_at: Option>, - pub document_count: u32, -} - impl From for DocumentVersion { fn from(value: StoredDocumentVersion) -> Self { Self { diff --git a/sync-server/src/app_state/websocket/broadcasts.rs b/sync-server/src/app_state/websocket/broadcasts.rs index b9e2ea39..60ae0219 100644 --- a/sync-server/src/app_state/websocket/broadcasts.rs +++ b/sync-server/src/app_state/websocket/broadcasts.rs @@ -1,147 +1,69 @@ -use std::{ - collections::HashMap, - sync::{Arc, Mutex as StdMutex}, -}; +use std::{collections::HashMap, sync::Arc}; -use log::{debug, info, warn}; +use anyhow::Context; +use log::{debug, warn}; use tokio::sync::{Mutex, broadcast}; -use super::models::{WebSocketServerMessage, WebSocketServerMessageWithOrigin}; -use crate::{app_state::database::models::VaultId, config::server_config::ServerConfig}; +use super::models::WebSocketServerMessageWithOrigin; +use crate::{ + app_state::database::models::VaultId, config::server_config::ServerConfig, errors::server_error, +}; #[derive(Debug, Clone)] pub struct Broadcasts { - broadcast_channel_capacity: usize, - // `tx` uses a blocking std::sync::Mutex because the critical section is - // a HashMap lookup plus a synchronous `broadcast::Sender::send`. Making - // this non-async lets `send_document_update` run without an `.await`, - // so an axum handler that is cancelled between `transaction.commit()` - // and the broadcast can never drop the notification mid-flight. - tx: Arc>>>, - send_locks: Arc>>>>, + max_clients_per_vault: usize, + tx: Arc>>>, } -type TxMap = HashMap>; - impl Broadcasts { pub fn new(server_config: &ServerConfig) -> Self { Self { - broadcast_channel_capacity: server_config.broadcast_channel_capacity, - tx: Arc::new(StdMutex::new(HashMap::new())), - send_locks: Arc::new(Mutex::new(HashMap::new())), + max_clients_per_vault: server_config.max_clients_per_vault, + tx: Arc::new(Mutex::new(HashMap::new())), } } - /// Acquire a per-vault lock that serializes broadcasts in commit order. - /// Must be acquired before the insert, held through commit and broadcast. - pub async fn acquire_send_lock(&self, vault: &VaultId) -> tokio::sync::OwnedMutexGuard<()> { - let lock = { - let mut locks = self.send_locks.lock().await; - locks - .entry(vault.clone()) - .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))) - .clone() - }; - lock.lock_owned().await - } - - /// Remove senders for vaults with no active receivers - fn prune_inactive_vaults(tx_map: &mut TxMap) -> Vec { - let mut pruned = Vec::new(); - tx_map.retain(|vault, sender| { - let alive = sender.receiver_count() > 0; - if !alive { - pruned.push(vault.clone()); - } - alive - }); - pruned - } - - pub fn get_receiver( + pub async fn get_receiver( &self, vault: VaultId, - max_clients: usize, - ) -> Result, crate::errors::SyncServerError> - { - let mut tx_map = self - .tx - .lock() - .expect("broadcasts.tx mutex poisoned — a previous holder panicked"); + ) -> broadcast::Receiver { + let tx = self.get_or_create(vault).await; - let count_before_prune = tx_map - .get(&vault) - .map_or(0, tokio::sync::broadcast::Sender::receiver_count); - let pruned = Self::prune_inactive_vaults(&mut tx_map); - let pruned_self = pruned.contains(&vault); - - let sender = tx_map - .entry(vault.clone()) - .or_insert_with(|| broadcast::channel(self.broadcast_channel_capacity).0); - - // Hold the lock across the count check *and* the subscribe so the - // `max_clients` cap is atomic: two concurrent callers can't both - // observe `receiver_count() < max_clients` and both subscribe. - if sender.receiver_count() >= max_clients { - return Err(crate::errors::client_error(anyhow::anyhow!( - "Vault has reached the maximum number of clients ({max_clients})" - ))); - } - - let receiver = sender.subscribe(); - let count_after = sender.receiver_count(); - info!( - "[BCAST] get_receiver vault={vault} count_before_prune={count_before_prune} pruned_self={pruned_self} pruned_total={} count_after_subscribe={count_after}", - pruned.len() - ); - Ok(receiver) + tx.subscribe() } /// Notify all clients (who are subscribed to the vault) about an update. - /// Synchronous: safe to invoke from a handler between `commit()` and - /// function return without worrying about task cancellation dropping - /// the broadcast mid-flight. Failures are logged, never propagated. - pub fn send_document_update(&self, vault: VaultId, document: WebSocketServerMessageWithOrigin) { - let vault_update_id = match &document.message { - WebSocketServerMessage::VaultUpdate(u) => Some(u.document.vault_update_id), - WebSocketServerMessage::CursorPositions(_) => None, - }; - let is_deleted = match &document.message { - WebSocketServerMessage::VaultUpdate(u) => Some(u.document.is_deleted), - WebSocketServerMessage::CursorPositions(_) => None, - }; - let mut tx_map = self - .tx - .lock() - .expect("broadcasts.tx mutex poisoned — a previous holder panicked"); - let count_before_prune = tx_map - .get(&vault) - .map_or(0, tokio::sync::broadcast::Sender::receiver_count); - let pruned = Self::prune_inactive_vaults(&mut tx_map); - let pruned_self = pruned.contains(&vault); + /// We only log failures and don't propagate them. + pub async fn send_document_update( + &self, + vault: VaultId, + document: WebSocketServerMessageWithOrigin, + ) { + let tx = self.get_or_create(vault.clone()).await; - let sender = tx_map - .entry(vault.clone()) - .or_insert_with(|| broadcast::channel(self.broadcast_channel_capacity).0); - - let count_before_send = sender.receiver_count(); - - if count_before_send == 0 { - info!( - "[BCAST] send_document_update vault={vault} vuid={vault_update_id:?} is_deleted={is_deleted:?} count_before_prune={count_before_prune} pruned_self={pruned_self} count_before_send=0 SKIPPED" - ); + if tx.receiver_count() == 0 { debug!("Skipping broadcast, no clients connected for vault `{vault}`"); return; } - let send_result = sender.send(document); - match &send_result { - Ok(n) => info!( - "[BCAST] send_document_update vault={vault} vuid={vault_update_id:?} is_deleted={is_deleted:?} count_before_prune={count_before_prune} pruned_self={pruned_self} count_before_send={count_before_send} SENT delivered_to={n}" - ), - Err(e) => warn!( - "[BCAST] send_document_update vault={vault} vuid={vault_update_id:?} is_deleted={is_deleted:?} count_before_prune={count_before_prune} pruned_self={pruned_self} count_before_send={count_before_send} FAILED err={e}" - ), + let result = tx + .send(document) + .context("Cannot broadcast server message to websocket listeners") + .map_err(server_error); + + if result.is_err() { + warn!("Failed to send message: {result:?}"); } } + + async fn get_or_create( + &self, + vault: VaultId, + ) -> broadcast::Sender { + let mut tx = self.tx.lock().await; + + tx.entry(vault) + .or_insert_with(|| broadcast::channel(self.max_clients_per_vault).0.clone()) + .clone() + } } diff --git a/sync-server/src/app_state/websocket/models.rs b/sync-server/src/app_state/websocket/models.rs index eb6c956a..e037fb7e 100644 --- a/sync-server/src/app_state/websocket/models.rs +++ b/sync-server/src/app_state/websocket/models.rs @@ -11,7 +11,7 @@ pub struct WebSocketHandshake { pub token: String, pub device_id: DeviceId, - #[ts(type = "number | null")] + #[ts(as = "Option")] pub last_seen_vault_update_id: Option, } @@ -22,14 +22,13 @@ pub struct CursorPositionFromClient { } #[derive(TS, Serialize, Deserialize, Clone, Debug)] -#[serde(rename_all = "camelCase")] pub struct DocumentWithCursors { // It's None in case the document is dirty. // We still want to sync the cursor to mark // that it exists and can be client-side // interpolated. However, the actual // position is meaningless. - #[ts(type = "number | null")] + #[ts(as = "Option")] pub vault_update_id: Option, pub document_id: DocumentId, @@ -58,19 +57,11 @@ pub struct CursorPositionFromServer { pub clients: Vec, } -// One committed version. Non-delete updates are broadcast to every -// connected client *except* the device that authored them — that -// device already has the new state via its HTTP response. Deletes are -// broadcast to every client including the author: the author keeps -// the document in its sync queue until this receipt arrives so a late -// remote update can't sneak in between the HTTP response and the -// queue cleanup. The server also emits these one-at-a-time to catch -// up a freshly-connected client on versions committed while it was -// offline, in ascending `vault_update_id` order. #[derive(TS, Serialize, Clone, Debug)] #[serde(rename_all = "camelCase")] pub struct WebSocketVaultUpdate { - pub document: DocumentVersionWithoutContent, + pub documents: Vec, + pub is_initial_sync: bool, } #[derive(TS, Deserialize, Clone, Debug)] @@ -89,10 +80,6 @@ pub enum WebSocketServerMessage { CursorPositions(CursorPositionFromServer), } -/// Broadcast envelope carrying the message plus the device that produced -/// it. The per-recipient send task compares `origin_device_id` against -/// its own device id to fill in `originates_from_self` before the message -/// is serialized on the wire. #[derive(Clone, Debug)] pub struct WebSocketServerMessageWithOrigin { pub origin_device_id: Option, diff --git a/sync-server/src/app_state/websocket/utils.rs b/sync-server/src/app_state/websocket/utils.rs index d78360de..1e0dd243 100644 --- a/sync-server/src/app_state/websocket/utils.rs +++ b/sync-server/src/app_state/websocket/utils.rs @@ -9,7 +9,7 @@ use crate::{ database::models::{DocumentVersionWithoutContent, VaultId, VaultUpdateId}, }, config::user_config::User, - errors::{SyncServerError, client_error, server_error, unauthenticated_error}, + errors::{SyncServerError, server_error, unauthenticated_error}, server::auth::auth, }; @@ -26,7 +26,7 @@ pub fn get_authenticated_handshake( if let Some(Message::Text(message)) = message { let message: WebSocketClientMessage = serde_json::from_str(&message) .context("Failed to parse message") - .map_err(client_error)?; + .map_err(server_error)?; match message { WebSocketClientMessage::Handshake(handshake) => { @@ -44,29 +44,21 @@ pub fn get_authenticated_handshake( } } -/// Stream the documents the client missed while offline, bounded above -/// by `up_to_vault_update_id` so the catch-up is a stable snapshot at -/// exactly that cursor. The WebSocket handshake atomically subscribes -/// to the broadcast channel and snapshots this cursor under the per- -/// vault send lock; commits past the cursor are then delivered solely -/// through the broadcast channel (filtered by the same cursor on the -/// receive side), so every committed update is delivered exactly once. pub async fn get_unseen_documents( state: &AppState, vault_id: &VaultId, last_seen_vault_update_id: Option, - up_to_vault_update_id: VaultUpdateId, ) -> Result, SyncServerError> { if let Some(update_id) = last_seen_vault_update_id { state .database - .get_latest_documents_since(vault_id, update_id, Some(up_to_vault_update_id), None) + .get_latest_documents_since(vault_id, update_id, None) .await .map_err(server_error) } else { state .database - .get_latest_documents(vault_id, Some(up_to_vault_update_id), None) + .get_latest_documents(vault_id, None) .await .map_err(server_error) } diff --git a/sync-server/src/config.rs b/sync-server/src/config.rs index 26b11a4c..6a003d2e 100644 --- a/sync-server/src/config.rs +++ b/sync-server/src/config.rs @@ -27,34 +27,24 @@ pub struct Config { } impl Config { - pub fn validate(&self) -> Result<()> { - self.server - .validate() - .context("Invalid server configuration")?; - self.logging - .validate() - .context("Invalid logging configuration")?; - self.database - .validate() - .context("Invalid database configuration")?; - Ok(()) - } - pub async fn read_or_create(path: &Path) -> Result { - let display_path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); - - if path.exists() { - info!("Loading configuration from `{}`", display_path.display()); - Self::load_from_file(path).await - } else { - let config = Self::default(); - config.write(path).await?; + let config = if path.exists() { info!( - "Created default configuration at `{}`", - display_path.display() + "Loading configuration from `{}`", + path.canonicalize().unwrap().display() ); - Ok(config) - } + Self::load_from_file(path).await? + } else { + Self::default() + }; + + config.write(path).await?; + info!( + "Updated configuration at `{}`", + path.canonicalize().unwrap().display() + ); + + Ok(config) } pub async fn load_from_file(path: &Path) -> Result { diff --git a/sync-server/src/config/database_config.rs b/sync-server/src/config/database_config.rs index a6f57e1f..20a9a21e 100644 --- a/sync-server/src/config/database_config.rs +++ b/sync-server/src/config/database_config.rs @@ -1,6 +1,5 @@ use std::{path::PathBuf, time::Duration}; -use anyhow::{Result, ensure}; use log::debug; use serde::{Deserialize, Serialize}; @@ -35,24 +34,6 @@ fn default_cursor_timeout() -> Duration { DEFAULT_CURSOR_TIMEOUT } -impl DatabaseConfig { - pub fn validate(&self) -> Result<()> { - ensure!( - !self.databases_directory_path.as_os_str().is_empty(), - "databases_directory_path must not be empty" - ); - ensure!( - self.max_connections_per_vault > 0, - "max_connections_per_vault must be greater than 0" - ); - ensure!( - !self.cursor_timeout.is_zero(), - "cursor_timeout must be greater than 0" - ); - Ok(()) - } -} - impl Default for DatabaseConfig { fn default() -> Self { Self { diff --git a/sync-server/src/config/logging_config.rs b/sync-server/src/config/logging_config.rs index dae67288..ad449d1a 100644 --- a/sync-server/src/config/logging_config.rs +++ b/sync-server/src/config/logging_config.rs @@ -1,13 +1,10 @@ use std::time::Duration; -use anyhow::{Result, ensure}; use log::debug; use serde::{Deserialize, Serialize}; use crate::{ - consts::{ - DEFAULT_LOG_DIRECTORY, DEFAULT_LOG_LEVEL, DEFAULT_LOG_ROTATION_INTERVAL, DURATION_ZERO, - }, + consts::{DEFAULT_LOG_DIRECTORY, DEFAULT_LOG_LEVEL, DEFAULT_LOG_ROTATION_INTERVAL}, utils::log_level::LogLevel, }; @@ -23,20 +20,6 @@ pub struct LoggingConfig { pub log_level: LogLevel, } -impl LoggingConfig { - pub fn validate(&self) -> Result<()> { - ensure!( - !self.log_directory.is_empty(), - "log_directory must not be an empty string" - ); - ensure!( - self.log_rotation > DURATION_ZERO, - "log_rotation must be greater than 0" - ); - Ok(()) - } -} - impl Default for LoggingConfig { fn default() -> Self { Self { diff --git a/sync-server/src/config/server_config.rs b/sync-server/src/config/server_config.rs index 715d216c..4a9da0f4 100644 --- a/sync-server/src/config/server_config.rs +++ b/sync-server/src/config/server_config.rs @@ -1,13 +1,10 @@ -use anyhow::{Result, ensure}; use log::debug; use serde::{Deserialize, Serialize}; use std::time::Duration; use crate::consts::{ - DEFAULT_ALLOWED_ORIGINS, DEFAULT_BROADCAST_CHANNEL_CAPACITY, DEFAULT_HOST, - DEFAULT_MAX_BODY_SIZE_MB, DEFAULT_MAX_CLIENTS_PER_VAULT, DEFAULT_MAX_PENDING_WS_CONNECTIONS, - DEFAULT_MERGEABLE_FILE_EXTENSIONS, DEFAULT_PORT, DEFAULT_RATE_LIMIT_PER_USER_PER_SECOND, - DEFAULT_RESPONSE_TIMEOUT_SECONDS, DURATION_ZERO, + DEFAULT_HOST, DEFAULT_MAX_BODY_SIZE_MB, DEFAULT_MAX_CLIENTS_PER_VAULT, + DEFAULT_MERGEABLE_FILE_EXTENSIONS, DEFAULT_PORT, DEFAULT_RESPONSE_TIMEOUT_SECONDS, }; #[derive(Debug, Deserialize, Serialize, Clone, Default)] @@ -24,56 +21,11 @@ pub struct ServerConfig { #[serde(default = "default_max_clients_per_vault")] pub max_clients_per_vault: usize, - #[serde(default = "default_broadcast_channel_capacity")] - pub broadcast_channel_capacity: usize, - #[serde(default = "default_response_timeout", with = "humantime_serde")] pub response_timeout: Duration, #[serde(default = "default_mergeable_file_extensions")] pub mergeable_file_extensions: Vec, - - /// Per-user maximum requests per second (keyed by bearer token). - /// `None` disables rate limiting. - #[serde(default = "default_rate_limit_per_user_per_second")] - pub rate_limit_per_user_per_second: Option, - - /// Allowed CORS origins. Default: `["*"]` (allow all). - #[serde(default = "default_allowed_origins")] - pub allowed_origins: Vec, - - /// Maximum concurrent unauthenticated WebSocket connections waiting for - /// handshake. Limits resource consumption from clients that connect but - /// never authenticate. - #[serde(default = "default_max_pending_websocket_connections")] - pub max_pending_websocket_connections: usize, -} - -impl ServerConfig { - pub fn validate(&self) -> Result<()> { - ensure!( - self.response_timeout > DURATION_ZERO, - "response_timeout must be greater than 0" - ); - ensure!( - self.max_body_size_mb > 0, - "max_body_size_mb must be greater than 0" - ); - ensure!( - self.max_clients_per_vault > 0, - "max_clients_per_vault must be greater than 0" - ); - ensure!( - self.broadcast_channel_capacity > 0, - "broadcast_channel_capacity must be greater than 0" - ); - ensure!( - self.max_pending_websocket_connections > 0, - "max_pending_websocket_connections must be greater than 0" - ); - - Ok(()) - } } fn default_host() -> String { @@ -96,11 +48,6 @@ fn default_max_clients_per_vault() -> usize { DEFAULT_MAX_CLIENTS_PER_VAULT } -fn default_broadcast_channel_capacity() -> usize { - debug!("Using default broadcast channel capacity: {DEFAULT_BROADCAST_CHANNEL_CAPACITY}"); - DEFAULT_BROADCAST_CHANNEL_CAPACITY -} - fn default_response_timeout() -> Duration { debug!("Using default response timeout: {DEFAULT_RESPONSE_TIMEOUT_SECONDS:?}"); DEFAULT_RESPONSE_TIMEOUT_SECONDS @@ -113,21 +60,3 @@ fn default_mergeable_file_extensions() -> Vec { .map(|s| (*s).to_owned()) .collect() } - -fn default_rate_limit_per_user_per_second() -> Option { - debug!("Using default rate limit per second: {DEFAULT_RATE_LIMIT_PER_USER_PER_SECOND:?}"); - DEFAULT_RATE_LIMIT_PER_USER_PER_SECOND -} - -fn default_allowed_origins() -> Vec { - debug!("Using default allowed origins: {DEFAULT_ALLOWED_ORIGINS:?}"); - DEFAULT_ALLOWED_ORIGINS - .iter() - .map(|s| (*s).to_owned()) - .collect() -} - -fn default_max_pending_websocket_connections() -> usize { - debug!("Using default max pending WebSocket connections: {DEFAULT_MAX_PENDING_WS_CONNECTIONS}"); - DEFAULT_MAX_PENDING_WS_CONNECTIONS -} diff --git a/sync-server/src/config/user_config.rs b/sync-server/src/config/user_config.rs index fd824f39..8b2537f0 100644 --- a/sync-server/src/config/user_config.rs +++ b/sync-server/src/config/user_config.rs @@ -1,7 +1,6 @@ use bimap::BiHashMap; use rand::{Rng, distr::Alphanumeric, rng}; use serde::{Deserialize, Deserializer, Serialize, de::Error}; -use subtle::ConstantTimeEq; use crate::app_state::database::models::VaultId; @@ -20,19 +19,10 @@ where let mut user_token_map = BiHashMap::new(); for user in &users { if let Some(existing_name) = user_token_map.get_by_right(&user.token) { - let redacted = if user.token.len() > 6 { - format!( - "{}...{}", - &user.token[..3], - &user.token[user.token.len() - 3..] - ) - } else { - "***".to_owned() - }; return Err(D::Error::custom(format!( - "Duplicate user token found: `{redacted}` for users `{}` and `{}`. User tokens \ - must be unique.", - existing_name, user.name + "Duplicate user token found: `{}` for users `{}` and `{}`. User tokens must be \ + unique.", + user.token, existing_name, user.name ))); } @@ -51,9 +41,7 @@ where impl UserConfig { pub fn get_user(&self, token: &str) -> Option<&User> { - self.user_configs - .iter() - .find(|u| u.token.as_bytes().ct_eq(token.as_bytes()).into()) + self.user_configs.iter().find(|u| u.token == token) } } diff --git a/sync-server/src/consts.rs b/sync-server/src/consts.rs index e03b848f..98ed1c1f 100644 --- a/sync-server/src/consts.rs +++ b/sync-server/src/consts.rs @@ -2,36 +2,22 @@ use std::time::Duration; use crate::utils::log_level::LogLevel; -pub const DURATION_ZERO: Duration = Duration::from_secs(0); - pub const DEFAULT_CONFIG_PATH: &str = "config.yml"; pub const DEFAULT_DATABASES_DIRECTORY_PATH: &str = "databases"; -pub const DEFAULT_MAX_CONNECTIONS_PER_VAULT: u32 = 6; +pub const DEFAULT_MAX_CONNECTIONS_PER_VAULT: u32 = 12; pub const DEFAULT_CURSOR_TIMEOUT: Duration = Duration::from_secs(60); pub const DEFAULT_HOST: &str = "127.0.0.1"; pub const DEFAULT_PORT: u16 = 3000; pub const DEFAULT_MAX_BODY_SIZE_MB: usize = 4096; -pub const DEFAULT_RESPONSE_TIMEOUT_SECONDS: Duration = Duration::from_mins(30); +pub const DEFAULT_RESPONSE_TIMEOUT_SECONDS: Duration = Duration::from_secs(1800); pub const DEFAULT_MAX_CLIENTS_PER_VAULT: usize = 256; -pub const DEFAULT_BROADCAST_CHANNEL_CAPACITY: usize = 4096; -pub const DEFAULT_MAX_PENDING_WS_CONNECTIONS: usize = 128; pub const DEFAULT_LOG_DIRECTORY: &str = "logs"; -pub const DEFAULT_LOG_ROTATION_INTERVAL: Duration = Duration::from_hours(24); -pub const IDLE_POOL_TIMEOUT: Duration = Duration::from_mins(5); -pub const GRACEFUL_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(10); -pub const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10); - -pub const MAX_CURSOR_DOCUMENTS: usize = 1000; -pub const MAX_CURSORS_PER_DOCUMENT: usize = 100; -pub const MAX_RELATIVE_PATH_LEN: usize = 4096; - +pub const DEFAULT_LOG_ROTATION_INTERVAL: Duration = Duration::from_secs(60 * 60 * 24); // 1 day pub const DEFAULT_LOG_LEVEL: LogLevel = LogLevel::Info; pub const DEFAULT_MERGEABLE_FILE_EXTENSIONS: &[&str] = &["md", "txt"]; -pub const DEFAULT_RATE_LIMIT_PER_USER_PER_SECOND: Option = None; -pub const DEFAULT_ALLOWED_ORIGINS: &[&str] = &["*"]; -pub const SUPPORTED_API_VERSION: u32 = 3; +pub const SUPPORTED_API_VERSION: u32 = 2; diff --git a/sync-server/src/errors.rs b/sync-server/src/errors.rs index 892db36f..831b0e86 100644 --- a/sync-server/src/errors.rs +++ b/sync-server/src/errors.rs @@ -5,7 +5,7 @@ use axum::{ http::StatusCode, response::{IntoResponse, Response}, }; -use log::{debug, error, warn}; +use log::{debug, error}; use serde::Serialize; use thiserror::Error; use ts_rs::TS; @@ -29,9 +29,6 @@ pub enum SyncServerError { #[error("Permission denied error: {0}")] PermissionDeniedError(#[source] anyhow::Error), - - #[error("Too many requests: {0}")] - TooManyRequests(#[source] anyhow::Error), } impl SyncServerError { @@ -42,8 +39,7 @@ impl SyncServerError { | Self::ServerError(error) | Self::NotFound(error) | Self::Unauthenticated(error) - | Self::PermissionDeniedError(error) - | Self::TooManyRequests(error) => error.into(), + | Self::PermissionDeniedError(error) => error.into(), } } } @@ -73,22 +69,7 @@ impl Display for SerializedError { impl IntoResponse for SyncServerError { fn into_response(self) -> Response { - let serialized = self.serialize(); - - match &self { - Self::InitError(_) | Self::ServerError(_) => { - error!("{serialized}"); - } - Self::ClientError(_) | Self::NotFound(_) => { - warn!("{serialized}"); - } - Self::TooManyRequests(_) => { - warn!("{serialized}"); - } - Self::Unauthenticated(_) | Self::PermissionDeniedError(_) => {} - } - - let body = Json(serialized); + let body = Json(self.serialize()); match self { Self::InitError(_) | Self::ServerError(_) => { @@ -98,7 +79,6 @@ impl IntoResponse for SyncServerError { Self::NotFound(_) => (StatusCode::NOT_FOUND, body).into_response(), Self::Unauthenticated(_) => (StatusCode::UNAUTHORIZED, body).into_response(), Self::PermissionDeniedError(_) => (StatusCode::FORBIDDEN, body).into_response(), - Self::TooManyRequests(_) => (StatusCode::TOO_MANY_REQUESTS, body).into_response(), } } } @@ -122,7 +102,6 @@ impl From<&anyhow::Error> for SerializedError { SyncServerError::NotFound(_) => "NotFound", SyncServerError::Unauthenticated(_) => "Unauthenticated", SyncServerError::PermissionDeniedError(_) => "PermissionDeniedError", - SyncServerError::TooManyRequests(_) => "TooManyRequests", }, ), message: error.to_string(), @@ -160,21 +139,3 @@ pub fn permission_denied_error(error: anyhow::Error) -> SyncServerError { debug!("Permission denied: {error:?}"); SyncServerError::PermissionDeniedError(error) } - -pub fn too_many_requests_error(error: anyhow::Error) -> SyncServerError { - debug!("Too many requests: {error:?}"); - SyncServerError::TooManyRequests(error) -} - -/// Maps a `create_write_transaction` error to 429 if the database is busy, -/// or 500 for all other failures. -pub fn write_transaction_error(error: anyhow::Error) -> SyncServerError { - if error - .downcast_ref::() - .is_some() - { - too_many_requests_error(error) - } else { - server_error(error) - } -} diff --git a/sync-server/src/main.rs b/sync-server/src/main.rs index dc00d4d5..1285ed7b 100644 --- a/sync-server/src/main.rs +++ b/sync-server/src/main.rs @@ -16,7 +16,6 @@ use consts::DEFAULT_CONFIG_PATH; use errors::{SyncServerError, init_error}; use log::info; use server::create_server; -use tracing_appender::non_blocking::WorkerGuard; use tracing_subscriber::{EnvFilter, fmt::format, layer::SubscriberExt, util::SubscriberInitExt}; use utils::rotating_file_writer::RotatingFileWriter; @@ -42,14 +41,11 @@ async fn main() -> ExitCode { } }; - let result = async { - config.validate().map_err(init_error)?; - // Hold the non-blocking writer guards until shutdown so the - // dedicated writer threads stay alive and flush queued log lines. - let _log_guards = set_up_logging(&args, &config.logging)?; - start_server(config).await + let mut result = set_up_logging(&args, &config.logging); + + if result.is_ok() { + result = start_server(config).await; } - .await; match result { Ok(()) => ExitCode::SUCCESS, @@ -63,7 +59,7 @@ async fn main() -> ExitCode { fn set_up_logging( args: &Args, logging_config: &config::logging_config::LoggingConfig, -) -> Result<[WorkerGuard; 2], SyncServerError> { +) -> Result<(), SyncServerError> { let level_filter = logging_config.log_level.as_tracing_level(); let env_filter = EnvFilter::builder() @@ -84,14 +80,6 @@ fn set_up_logging( .context("Failed to create rotating file writer") .map_err(init_error)?; - // Decouple log emission from disk/stderr I/O. Without this, a tokio - // worker that holds the writer's std::sync::Mutex while a `write(2)` - // is throttled by the kernel (e.g. btrfs writeback) cascades the - // stall to every other worker that tries to log, freezing the whole - // runtime. The guards must outlive every emitter. - let (file_writer, file_guard) = tracing_appender::non_blocking(file_appender); - let (stderr_writer, stderr_guard) = tracing_appender::non_blocking(std::io::stderr()); - let format = format() .with_target(is_debug_mode) .with_line_number(is_debug_mode) @@ -99,12 +87,12 @@ fn set_up_logging( let stderr_layer = tracing_subscriber::fmt::layer() .with_ansi(use_colors) - .with_writer(stderr_writer) + .with_writer(std::io::stderr) .event_format(format.clone()); let file_layer = tracing_subscriber::fmt::layer() .with_ansi(false) - .with_writer(file_writer) + .with_writer(file_appender) .event_format(format); tracing_subscriber::registry() @@ -115,7 +103,7 @@ fn set_up_logging( .context("Failed to initialise tracing") .map_err(init_error)?; - Ok([file_guard, stderr_guard]) + Ok(()) } async fn start_server(config: Config) -> Result<(), SyncServerError> { diff --git a/sync-server/src/server.rs b/sync-server/src/server.rs index 934e9428..01b09cf6 100644 --- a/sync-server/src/server.rs +++ b/sync-server/src/server.rs @@ -4,30 +4,27 @@ mod delete_document; mod device_id_header; mod fetch_document_version; mod fetch_document_version_content; -mod fetch_document_versions; mod fetch_latest_document_version; mod fetch_latest_documents; -mod fetch_vault_history; mod index; -mod list_vaults; mod ping; -mod rate_limit; mod requests; mod responses; mod update_document; mod websocket; -use anyhow::{Context as _, Result}; +use anyhow::{Context as _, Result, anyhow}; use auth::auth_middleware; use axum::{ Router, extract::{DefaultBodyLimit, Request}, http::{self, HeaderValue, Method}, middleware, + response::IntoResponse, routing::{IntoMakeService, delete, get, post, put}, }; use device_id_header::DEVICE_ID_HEADER_NAME; -use log::{info, warn}; +use log::info; use tokio::signal; use tower_http::{ LatencyUnit, @@ -44,7 +41,7 @@ use tracing::{Level, info_span}; use crate::{ app_state::AppState, config::{Config, server_config::ServerConfig}, - consts::GRACEFUL_SHUTDOWN_TIMEOUT, + errors::{client_error, not_found_error}, }; pub async fn create_server(config: Config) -> Result<()> { @@ -54,33 +51,26 @@ pub async fn create_server(config: Config) -> Result<()> { let server_config = app_state.config.server.clone(); - let mut app = Router::new() + let app = Router::new() .nest("/", get_authed_routes(app_state.clone())) .route("/", get(index::index)) - .route("/assets/*path", get(index::spa_assets)) - .route("/vaults", get(list_vaults::list_vaults)) .route("/vaults/:vault_id/ping", get(ping::ping)) .route("/vaults/:vault_id/ws", get(websocket::websocket_handler)) - .fallback(index::spa_fallback); - - let cors_layer = build_cors_layer(&server_config).context("Invalid CORS configuration")?; - - if let Some(rate_limit) = server_config.rate_limit_per_user_per_second { - info!("Rate limiting enabled: {rate_limit} requests/second per user"); - let limiter = rate_limit::RateLimiter::new(rate_limit); - app = app.layer(middleware::from_fn_with_state( - limiter, - rate_limit::rate_limit_middleware, - )); - } - - let app = app .layer(DefaultBodyLimit::disable()) .layer(RequestBodyLimitLayer::new( app_state.config.server.max_body_size_mb * 1024 * 1024, )) .layer(TimeoutLayer::new(server_config.response_timeout)) - .layer(cors_layer) + .layer( + CorsLayer::new() + .allow_origin("*".parse::().expect("Failed to parse origin")) + .allow_headers([ + http::header::CONTENT_TYPE, + http::header::AUTHORIZATION, + DEVICE_ID_HEADER_NAME.clone(), + ]) + .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE]), + ) .layer( TraceLayer::new_for_http() .make_span_with(|request: &Request<_>| { @@ -100,39 +90,12 @@ pub async fn create_server(config: Config) -> Result<()> { .on_eos(DefaultOnEos::new()) .on_failure(DefaultOnFailure::new().level(Level::ERROR)), ) - .with_state(app_state.clone()) + .with_state(app_state) + .fallback(handle_404) + .fallback(handle_405) .into_make_service(); - start_server(app, &server_config, app_state).await -} - -fn build_cors_layer(server_config: &ServerConfig) -> Result { - let origins = &server_config.allowed_origins; - - let cors = if origins.len() == 1 && origins[0] == "*" { - info!("CORS: allowing all origins"); - let header: HeaderValue = "*" - .parse() - .context("Failed to parse wildcard CORS origin")?; - CorsLayer::new().allow_origin(header) - } else { - let parsed: Vec = origins - .iter() - .map(|o| { - o.parse::() - .with_context(|| format!("Failed to parse CORS origin: `{o}`")) - }) - .collect::>>()?; - CorsLayer::new().allow_origin(parsed) - }; - - Ok(cors - .allow_headers([ - http::header::CONTENT_TYPE, - http::header::AUTHORIZATION, - DEVICE_ID_HEADER_NAME.clone(), - ]) - .allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE])) + start_server(app, &server_config).await } fn get_authed_routes(app_state: AppState) -> Router { @@ -157,10 +120,6 @@ fn get_authed_routes(app_state: AppState) -> Router { "/vaults/:vault_id/documents/:document_id/text", put(update_document::update_text), ) - .route( - "/vaults/:vault_id/documents/:document_id/versions", - get(fetch_document_versions::fetch_document_versions), - ) .route( "/vaults/:vault_id/documents/:document_id/versions/:vault_update_id", get(fetch_document_version::fetch_document_version), @@ -173,18 +132,10 @@ fn get_authed_routes(app_state: AppState) -> Router { "/vaults/:vault_id/documents/:document_id", delete(delete_document::delete_document), ) - .route( - "/vaults/:vault_id/history", - get(fetch_vault_history::fetch_vault_history), - ) .layer(middleware::from_fn_with_state(app_state, auth_middleware)) } -async fn start_server( - app: IntoMakeService, - config: &ServerConfig, - app_state: AppState, -) -> Result<()> { +async fn start_server(app: IntoMakeService, config: &ServerConfig) -> Result<()> { let address = format!("{}:{}", config.host, config.port); let listener = tokio::net::TcpListener::bind(address.clone()) .await @@ -197,46 +148,26 @@ async fn start_server( .context("Failed to get local address")? ); - let mut shutdown_rx = app_state.subscribe_shutdown(); - - let server = axum::serve(listener, app) - .with_graceful_shutdown(async move { - shutdown_signal().await; - app_state.shutdown(); - }) - .tcp_nodelay(true); - - tokio::select! { - result = server => result.context("Failed to start server"), - () = async { - let _ = shutdown_rx.changed().await; - info!( - "Shutdown signal received, waiting up to {}s for in-flight requests to complete...", - GRACEFUL_SHUTDOWN_TIMEOUT.as_secs() - ); - tokio::time::sleep(GRACEFUL_SHUTDOWN_TIMEOUT).await; - warn!("Graceful shutdown timed out, forcing exit"); - } => Ok(()), - } + axum::serve(listener, app) + .with_graceful_shutdown(shutdown_signal()) + .tcp_nodelay(true) + .await + .context("Failed to start server") } async fn shutdown_signal() { let ctrl_c = async { - if let Err(e) = signal::ctrl_c().await { - log::error!("Failed to install Ctrl+C handler: {e}"); - } + signal::ctrl_c() + .await + .expect("failed to install Ctrl+C handler"); }; #[cfg(unix)] let terminate = async { - match signal::unix::signal(signal::unix::SignalKind::terminate()) { - Ok(mut signal) => { - signal.recv().await; - } - Err(e) => { - log::error!("Failed to install SIGTERM handler: {e}"); - } - } + signal::unix::signal(signal::unix::SignalKind::terminate()) + .expect("failed to install signal handler") + .recv() + .await; }; #[cfg(not(unix))] @@ -247,3 +178,11 @@ async fn shutdown_signal() { () = terminate => {}, } } + +async fn handle_404() -> impl IntoResponse { + not_found_error(anyhow!("Page not found")) +} + +async fn handle_405() -> impl IntoResponse { + client_error(anyhow!("Method not allowed")) +} diff --git a/sync-server/src/server/auth.rs b/sync-server/src/server/auth.rs index 7fa45abd..e56f4acc 100644 --- a/sync-server/src/server/auth.rs +++ b/sync-server/src/server/auth.rs @@ -9,7 +9,7 @@ use axum_extra::{ TypedHeader, headers::{Authorization, authorization::Bearer}, }; -use log::{debug, info}; +use log::info; use crate::{ app_state::{AppState, database::models::VaultId}, @@ -21,12 +21,10 @@ use crate::{ pub async fn auth_middleware( State(state): State, Path(path_params): Path>, - auth_header: Option>>, + TypedHeader(auth_header): TypedHeader>, mut req: Request, next: Next, ) -> Result { - let auth_header = auth_header - .ok_or_else(|| unauthenticated_error(anyhow::anyhow!("Missing Authorization header")))?; let token = auth_header.token().trim(); let vault_id = normalize_string( path_params @@ -41,24 +39,20 @@ pub async fn auth_middleware( Ok(next.run(req).await) } -pub fn authenticate(state: &AppState, token: &str) -> Result { - state +pub fn auth(state: &AppState, token: &str, vault_id: &VaultId) -> Result { + let user = state .config .users .get_user(token) .cloned() - .ok_or_else(|| unauthenticated_error(anyhow::anyhow!("Invalid token"))) -} - -pub fn auth(state: &AppState, token: &str, vault_id: &VaultId) -> Result { - let user = authenticate(state, token)?; + .ok_or_else(|| unauthenticated_error(anyhow::anyhow!("Invalid token")))?; if match user.vault_access { VaultAccess::AllowAccessToAll => true, VaultAccess::AllowList(AllowListedVaults { ref allowed }) => allowed.contains(vault_id), } { - debug!( - "User `{}` is authenticated and is authorised to access vault `{vault_id}`", + info!( + "User `{}` is authenticated and is authorised to access to vault `{vault_id}`", user.name ); diff --git a/sync-server/src/server/create_document.rs b/sync-server/src/server/create_document.rs index d772e16a..859c0db4 100644 --- a/sync-server/src/server/create_document.rs +++ b/sync-server/src/server/create_document.rs @@ -11,14 +11,12 @@ use super::{device_id_header::DeviceIdHeader, requests::CreateDocumentVersion}; use crate::{ app_state::{ AppState, - database::models::{StoredDocumentVersion, VaultId}, + database::models::{DocumentVersionWithoutContent, StoredDocumentVersion, VaultId}, }, config::user_config::User, - errors::{SyncServerError, client_error, server_error, write_transaction_error}, - server::{responses::DocumentUpdateResponse, update_document}, + errors::{SyncServerError, client_error, server_error}, utils::{ - find_first_available_path::find_first_available_path, is_binary::is_binary, - is_file_type_mergable::is_file_type_mergable, normalize::normalize, + find_first_available_path::find_first_available_path, normalize::normalize, sanitize_path::sanitize_path, }, }; @@ -32,137 +30,48 @@ pub struct CreateDocumentPathParams { /// Create a new document in case a document with the same doesn't exist /// already. If a document with the same path exists, a new version is created /// with their content merged. -/// -/// Text content must be UTF-8 encoded. Clients are responsible for -/// transcoding other encodings (e.g. UTF-16) to UTF-8 before sending. #[axum::debug_handler] -#[allow(clippy::too_many_lines)] pub async fn create_document( Path(CreateDocumentPathParams { vault_id }): Path, Extension(user): Extension, TypedHeader(device_id): TypedHeader, State(state): State, TypedMultipart(request): TypedMultipart, -) -> Result, SyncServerError> { +) -> Result, SyncServerError> { debug!("Creating document in vault `{vault_id}`"); let mut transaction = state .database .create_write_transaction(&vault_id) .await - .map_err(write_transaction_error)?; - - let sanitized_relative_path = sanitize_path(&request.relative_path).map_err(client_error)?; - let new_content = request.content.contents.to_vec(); - - let latest_version = state - .database - .get_latest_non_deleted_document_by_path( - &vault_id, - &sanitized_relative_path, - Some(&mut *transaction), - ) - .await .map_err(server_error)?; - if let Some(latest_version) = latest_version { - // Only merge with an existing document the client couldn't have - // known about: its creation is newer than the client's last seen - // vault update to avoid creating cycles by merging two documents into one. - // This could happen if both clients know of document A at path P1, - // but client 2 moves it to P2 while client 1 creates a new document at P2, - // then client 1 would merge its new document with the moved version of A at P2 - // that client 2 resulting in two files (P1 and P2) with the same doc id (A). - if latest_version.creation_vault_update_id > request.last_seen_vault_update_id - && latest_version.creation_vault_update_id == latest_version.vault_update_id - // can't allow merging with a moved document as that could create a cycle - { - let is_mergeable_text = is_file_type_mergable( - &sanitized_relative_path, - &state.config.server.mergeable_file_extensions, - ) && !is_binary(&latest_version.content) - && !is_binary(&new_content); + let document_id = match request.document_id { + Some(document_id) => { + let existing_version = state + .database + .get_latest_document(&vault_id, &document_id, Some(&mut transaction)) + .await + .map_err(server_error)?; - if is_mergeable_text || new_content == latest_version.content { - return update_document::update_document( - &sanitized_relative_path, - Vec::new(), - vault_id, - latest_version.document_id, - Some(&request.relative_path), - new_content, - user, - device_id, - state, - transaction, - ) - .await; + if existing_version.is_some() { + return Err(client_error(anyhow::anyhow!( + "Document with the same ID `{document_id}` already exists" + ))); } - // For non-mergeable (binary) files with different content, don't - // merge, create a separate document at a deconflicted path so - // neither client's data is silently overwritten. + document_id } - } - - // Lost-create + local rename recovery. If this device has a doc - // the requesting client hasn't seen yet (its create succeeded - // server-side but the response was discarded — e.g. a sync - // reset mid-flight) and the new request carries the same content - // at a different path (the user renamed the file before the - // retry), bind the retry to that existing doc instead of - // creating a duplicate. The dedup is scoped tightly: - // - same `device_id` (only this client's own lost create), - // - `creation_vault_update_id > last_seen` (client never saw - // this doc, so it can't be deliberately creating another - // copy with matching content), - // - `creation == latest` (the doc has only its create version, - // nobody else has touched it; safe to relocate), - // - exact content match. - // Outside that window we fall through to the normal deconflict - // path, so legitimate "this device created a duplicate of an - // already-acknowledged file" flows still produce a new doc. - if let Some(lost_create) = state - .database - .find_unseen_lost_create_by_device_and_content( - &vault_id, - &device_id.0, - request.last_seen_vault_update_id, - &new_content, - Some(&mut *transaction), - ) - .await - .map_err(server_error)? - { - info!( - "Lost-create recovery: binding retry at `{sanitized_relative_path}` to existing doc {} (was at `{}`) in vault `{vault_id}` for device `{}`", - lost_create.document_id, - lost_create.relative_path, - device_id.0 - ); - return update_document::update_document( - &sanitized_relative_path, - Vec::new(), - vault_id, - lost_create.document_id, - Some(&request.relative_path), - new_content, - user, - device_id, - state, - transaction, - ) - .await; - } - - let document_id = uuid::Uuid::new_v4(); + None => uuid::Uuid::new_v4(), + }; let last_update_id = state .database - .get_max_update_id_in_vault(&vault_id, Some(&mut *transaction)) + .get_max_update_id_in_vault(&vault_id, Some(&mut transaction)) .await .map_err(server_error)?; + let sanitized_relative_path = sanitize_path(&request.relative_path); let deduped_path = find_first_available_path( &vault_id, &sanitized_relative_path, @@ -178,13 +87,11 @@ pub async fn create_document( ); } - let new_vault_update_id = last_update_id + 1; let new_version = StoredDocumentVersion { - vault_update_id: new_vault_update_id, - creation_vault_update_id: new_vault_update_id, + vault_update_id: last_update_id + 1, document_id, relative_path: deduped_path, - content: new_content, + content: request.content.contents.to_vec(), updated_date: chrono::Utc::now(), is_deleted: false, user_id: user.name, @@ -194,11 +101,9 @@ pub async fn create_document( state .database - .insert_document_version(&vault_id, &new_version, transaction) + .insert_document_version(&vault_id, &new_version, Some(transaction)) .await .map_err(server_error)?; - Ok(Json(DocumentUpdateResponse::FastForwardUpdate( - new_version.into(), - ))) + Ok(Json(new_version.into())) } diff --git a/sync-server/src/server/delete_document.rs b/sync-server/src/server/delete_document.rs index 2ee6eac3..e126d6b5 100644 --- a/sync-server/src/server/delete_document.rs +++ b/sync-server/src/server/delete_document.rs @@ -1,4 +1,4 @@ -use anyhow::{Context, anyhow}; +use anyhow::Context; use axum::{ Extension, Json, extract::{Path, State}, @@ -7,7 +7,7 @@ use axum_extra::TypedHeader; use log::{debug, info}; use serde::Deserialize; -use super::device_id_header::DeviceIdHeader; +use super::{device_id_header::DeviceIdHeader, requests::DeleteDocumentVersion}; use crate::{ app_state::{ AppState, @@ -16,8 +16,8 @@ use crate::{ }, }, config::user_config::User, - errors::{SyncServerError, not_found_error, server_error, write_transaction_error}, - utils::normalize::normalize, + errors::{SyncServerError, server_error}, + utils::{normalize::normalize, sanitize_path::sanitize_path}, }; #[derive(Deserialize)] @@ -37,6 +37,7 @@ pub async fn delete_document( Extension(user): Extension, TypedHeader(device_id): TypedHeader, State(state): State, + Json(request): Json, ) -> Result, SyncServerError> { debug!("Deleting document `{document_id}` in vault `{vault_id}`"); @@ -44,7 +45,7 @@ pub async fn delete_document( .database .create_write_transaction(&vault_id) .await - .map_err(write_transaction_error)?; + .map_err(server_error)?; let last_update_id = state .database @@ -58,18 +59,9 @@ pub async fn delete_document( .await .map_err(server_error)?; - let Some(latest_version) = latest_version else { - transaction - .rollback() - .await - .context("Failed to roll back transaction") - .map_err(server_error)?; - return Err(not_found_error(anyhow!( - "Document `{document_id}` not found in vault `{vault_id}`" - ))); - }; - - if latest_version.is_deleted { + if let Some(latest_version) = &latest_version + && latest_version.is_deleted + { transaction .rollback() .await @@ -77,19 +69,15 @@ pub async fn delete_document( .map_err(server_error)?; info!("Document `{document_id}` has already been deleted",); - return Ok(Json(latest_version.into())); + return Ok(Json(latest_version.clone().into())); } - let new_vault_update_id = last_update_id + 1; - let latest_relative_path = latest_version.relative_path; - let latest_content = latest_version.content; - let creation_vault_update_id = latest_version.creation_vault_update_id; + let latest_content = latest_version.map_or_else(Vec::new, |version| version.content); // in case the document has never existed before deleting it let new_version = StoredDocumentVersion { - vault_update_id: new_vault_update_id, - creation_vault_update_id, + vault_update_id: last_update_id + 1, document_id, - relative_path: latest_relative_path, + relative_path: sanitize_path(&request.relative_path), content: latest_content, // copy the content from the latest version updated_date: chrono::Utc::now(), is_deleted: true, @@ -100,7 +88,7 @@ pub async fn delete_document( state .database - .insert_document_version(&vault_id, &new_version, transaction) + .insert_document_version(&vault_id, &new_version, Some(transaction)) .await .map_err(server_error)?; diff --git a/sync-server/src/server/device_id_header.rs b/sync-server/src/server/device_id_header.rs index 13bd17a8..af9d6413 100644 --- a/sync-server/src/server/device_id_header.rs +++ b/sync-server/src/server/device_id_header.rs @@ -16,31 +16,20 @@ impl Header for DeviceIdHeader { { let value = values.next().ok_or_else(headers::Error::invalid)?; - let s = value.to_str().map_err(|_| headers::Error::invalid())?; - - if s.is_empty() || s.len() > 256 { - return Err(headers::Error::invalid()); - } - - // Only allow safe characters to prevent log injection and similar attacks. - // Covers UUIDs, user-agent strings like "vault-link/1.0 (12345; linux)", - // and human-readable device names. - if !s - .chars() - .all(|c| c.is_ascii_alphanumeric() || "-_./ ();:@+,".contains(c)) - { - return Err(headers::Error::invalid()); - } - - Ok(DeviceIdHeader(s.to_owned())) + Ok(DeviceIdHeader( + value + .to_str() + .map_err(|_| headers::Error::invalid())? + .to_owned(), + )) } fn encode(&self, values: &mut E) where E: Extend, { - if let Ok(value) = HeaderValue::from_str(&self.0) { - values.extend(std::iter::once(value)); - } + let value = HeaderValue::from_static(Box::leak(self.0.clone().into_boxed_str())); + + values.extend(std::iter::once(value)); } } diff --git a/sync-server/src/server/fetch_document_version.rs b/sync-server/src/server/fetch_document_version.rs index 159cad3a..c30f1d76 100644 --- a/sync-server/src/server/fetch_document_version.rs +++ b/sync-server/src/server/fetch_document_version.rs @@ -11,7 +11,7 @@ use crate::{ AppState, database::models::{DocumentId, DocumentVersion, VaultId, VaultUpdateId}, }, - errors::{SyncServerError, client_error, not_found_error, server_error}, + errors::{SyncServerError, not_found_error, server_error}, utils::normalize::normalize, }; @@ -52,7 +52,7 @@ pub async fn fetch_document_version( )?; if result.document_id != document_id { - return Err(client_error(anyhow!( + return Err(not_found_error(anyhow!( "Document with document id `{document_id}` does not have a version with id \ `{vault_update_id}`", ))); diff --git a/sync-server/src/server/fetch_document_version_content.rs b/sync-server/src/server/fetch_document_version_content.rs index a163b036..9fdd0ad8 100644 --- a/sync-server/src/server/fetch_document_version_content.rs +++ b/sync-server/src/server/fetch_document_version_content.rs @@ -11,7 +11,7 @@ use crate::{ AppState, database::models::{DocumentId, VaultId, VaultUpdateId}, }, - errors::{SyncServerError, client_error, not_found_error, server_error}, + errors::{SyncServerError, not_found_error, server_error}, utils::normalize::normalize, }; @@ -52,7 +52,7 @@ pub async fn fetch_document_version_content( )?; if result.document_id != document_id { - return Err(client_error(anyhow!( + return Err(not_found_error(anyhow!( "Document with document id `{document_id}` does not have a version with id \ `{vault_update_id}`", ))); diff --git a/sync-server/src/server/fetch_document_versions.rs b/sync-server/src/server/fetch_document_versions.rs deleted file mode 100644 index 46d0e073..00000000 --- a/sync-server/src/server/fetch_document_versions.rs +++ /dev/null @@ -1,42 +0,0 @@ -use axum::{ - Json, - extract::{Path, State}, -}; -use log::debug; -use serde::Deserialize; - -use crate::{ - app_state::{ - AppState, - database::models::{DocumentId, DocumentVersionWithoutContent, VaultId}, - }, - errors::{SyncServerError, server_error}, - utils::normalize::normalize, -}; - -#[derive(Deserialize)] -pub struct FetchDocumentVersionsPathParams { - #[serde(deserialize_with = "normalize")] - vault_id: VaultId, - - document_id: DocumentId, -} - -#[axum::debug_handler] -pub async fn fetch_document_versions( - Path(FetchDocumentVersionsPathParams { - vault_id, - document_id, - }): Path, - State(state): State, -) -> Result>, SyncServerError> { - debug!("Fetching all versions for document `{document_id}` in vault `{vault_id}`"); - - let versions = state - .database - .get_document_versions(&vault_id, &document_id, None) - .await - .map_err(server_error)?; - - Ok(Json(versions)) -} diff --git a/sync-server/src/server/fetch_latest_documents.rs b/sync-server/src/server/fetch_latest_documents.rs index f1ca702d..209374ce 100644 --- a/sync-server/src/server/fetch_latest_documents.rs +++ b/sync-server/src/server/fetch_latest_documents.rs @@ -37,13 +37,13 @@ pub async fn fetch_latest_documents( let documents = if let Some(since_update_id) = since_update_id { state .database - .get_latest_documents_since(&vault_id, since_update_id, None, None) + .get_latest_documents_since(&vault_id, since_update_id, None) .await .map_err(server_error) } else { state .database - .get_latest_documents(&vault_id, None, None) + .get_latest_documents(&vault_id, None) .await .map_err(server_error) }?; diff --git a/sync-server/src/server/fetch_vault_history.rs b/sync-server/src/server/fetch_vault_history.rs deleted file mode 100644 index 42cceaa6..00000000 --- a/sync-server/src/server/fetch_vault_history.rs +++ /dev/null @@ -1,70 +0,0 @@ -use axum::{ - Json, - extract::{Path, Query, State}, -}; -use log::debug; -use serde::Deserialize; - -use super::responses::VaultHistoryResponse; -use crate::{ - app_state::{ - AppState, - database::models::{VaultId, VaultUpdateId}, - }, - errors::{SyncServerError, client_error, server_error}, - utils::normalize::normalize, -}; - -const DEFAULT_LIMIT: i64 = 50; -const MAX_LIMIT: i64 = 500; - -#[derive(Deserialize)] -pub struct FetchVaultHistoryPathParams { - #[serde(deserialize_with = "normalize")] - vault_id: VaultId, -} - -#[derive(Deserialize)] -pub struct QueryParams { - limit: Option, - before_update_id: Option, -} - -#[axum::debug_handler] -pub async fn fetch_vault_history( - Path(FetchVaultHistoryPathParams { vault_id }): Path, - Query(QueryParams { - limit, - before_update_id, - }): Query, - State(state): State, -) -> Result, SyncServerError> { - if let Some(id) = before_update_id - && id <= 0 - { - return Err(client_error(anyhow::anyhow!( - "before_update_id must be a positive integer" - ))); - } - - let limit = limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT); - - debug!( - "Fetching vault history for vault `{vault_id}` (limit={limit}, before={before_update_id:?})" - ); - - // Fetch one extra row to determine if there are more results - let mut versions = state - .database - .get_vault_history(&vault_id, limit + 1, before_update_id, None) - .await - .map_err(server_error)?; - - #[allow(clippy::cast_sign_loss)] // limit is clamped to [1, 500] above - let has_more = versions.len() > limit as usize; - if has_more { - versions.pop(); - } - - Ok(Json(VaultHistoryResponse { versions, has_more })) -} diff --git a/sync-server/src/server/index.rs b/sync-server/src/server/index.rs index ca8f38ff..64b053f7 100644 --- a/sync-server/src/server/index.rs +++ b/sync-server/src/server/index.rs @@ -1,77 +1,7 @@ -use axum::{ - body::Body, - extract::{Path, State}, - http::{StatusCode, header}, - response::{Html, IntoResponse, Response}, -}; -use log::warn; -use rust_embed::Embed; +use axum::response::{Html, IntoResponse}; -use crate::app_state::AppState; - -#[derive(Embed)] -#[folder = "../frontend/history-ui/dist/"] -struct HistoryUiAssets; - -pub async fn index(State(_state): State) -> impl IntoResponse { - if let Some(content) = HistoryUiAssets::get("index.html") { - Html( - std::str::from_utf8(content.data.as_ref()) - .inspect_err(|e| warn!("Embedded index.html is not valid UTF-8: {e}")) - .unwrap_or("

VaultLink

") - .to_owned(), - ) - .into_response() - } else { - warn!("No embedded index.html found — history UI may not have been built"); - Html("

VaultLink server

".to_owned()).into_response() - } -} - -pub async fn spa_assets(Path(path): Path) -> impl IntoResponse { - // The route is /assets/*path so path is relative to assets/. - // The embedded files include the assets/ prefix from the dist directory. - let full_path = format!("assets/{path}"); - if let Some(content) = HistoryUiAssets::get(&full_path) { - let mime = mime_guess::from_path(&full_path).first_or_octet_stream(); - return Response::builder() - .status(StatusCode::OK) - .header(header::CONTENT_TYPE, mime.as_ref()) - .body(Body::from(content.data.to_vec())) - .unwrap_or_else(|_| { - Response::builder() - .status(StatusCode::INTERNAL_SERVER_ERROR) - .body(Body::empty()) - .unwrap_or_else(|_| Response::new(Body::empty())) - }); - } - - // Asset paths must match an embedded file — no SPA fallback. - // Serving index.html here would return 200 with text/html for missing - // .css/.js files, causing the browser to silently ignore the content. - Response::builder() - .status(StatusCode::NOT_FOUND) - .body(Body::from("Not found")) - .unwrap_or_else(|_| Response::new(Body::from("Not found"))) -} - -/// SPA fallback for production: serves index.html for client-side routes -/// (e.g. `/documents/123`). -pub async fn spa_fallback() -> impl IntoResponse { - match HistoryUiAssets::get("index.html") { - Some(content) => Response::builder() - .status(StatusCode::OK) - .header(header::CONTENT_TYPE, "text/html") - .body(Body::from(content.data.to_vec())) - .unwrap_or_else(|_| { - Response::builder() - .status(StatusCode::INTERNAL_SERVER_ERROR) - .body(Body::empty()) - .unwrap_or_else(|_| Response::new(Body::empty())) - }), - None => Response::builder() - .status(StatusCode::NOT_FOUND) - .body(Body::from("Not found")) - .unwrap_or_else(|_| Response::new(Body::from("Not found"))), - } +pub async fn index() -> impl IntoResponse { + const HTML_CONTENT: &str = include_str!("./assets/index.html"); + let html_content = HTML_CONTENT; + Html(html_content) } diff --git a/sync-server/src/server/list_vaults.rs b/sync-server/src/server/list_vaults.rs deleted file mode 100644 index 7ef23405..00000000 --- a/sync-server/src/server/list_vaults.rs +++ /dev/null @@ -1,82 +0,0 @@ -use axum::{ - Json, - extract::{Query, State}, -}; -use axum_extra::{ - TypedHeader, - headers::{Authorization, authorization::Bearer}, -}; -use log::debug; -use serde::Deserialize; - -use super::{ - auth::authenticate, - responses::{ListVaultsResponse, VaultInfo}, -}; -use crate::{ - app_state::AppState, - config::user_config::{AllowListedVaults, VaultAccess}, - errors::{SyncServerError, server_error, unauthenticated_error}, -}; - -const DEFAULT_LIMIT: usize = 50; -const MAX_LIMIT: usize = 200; - -#[derive(Deserialize)] -pub struct QueryParams { - limit: Option, - after: Option, -} - -#[axum::debug_handler] -pub async fn list_vaults( - auth_header: Option>>, - Query(QueryParams { limit, after }): Query, - State(state): State, -) -> Result, SyncServerError> { - let auth_header = auth_header - .ok_or_else(|| unauthenticated_error(anyhow::anyhow!("Missing Authorization header")))?; - - let user = authenticate(&state, auth_header.token().trim())?; - - debug!("User `{}` listing accessible vaults", user.name); - - let existing_vaults = state.database.list_vaults().await.map_err(server_error)?; - - let mut accessible: Vec = match user.vault_access { - VaultAccess::AllowAccessToAll => existing_vaults, - VaultAccess::AllowList(AllowListedVaults { ref allowed }) => existing_vaults - .into_iter() - .filter(|v| allowed.contains(v)) - .collect(), - }; - - // Cursor-based pagination: skip vaults up to and including `after` - if let Some(ref cursor) = after { - accessible.retain(|v| v.as_str() > cursor.as_str()); - } - - let limit = limit.unwrap_or(DEFAULT_LIMIT).clamp(1, MAX_LIMIT); - let has_more = accessible.len() > limit; - accessible.truncate(limit); - - let mut vaults = Vec::with_capacity(accessible.len()); - for name in accessible { - let stats = state - .database - .get_vault_stats(&name) - .await - .map_err(server_error)?; - vaults.push(VaultInfo { - name, - document_count: stats.document_count, - created_at: stats.created_at, - }); - } - - Ok(Json(ListVaultsResponse { - vaults, - has_more, - user_name: user.name, - })) -} diff --git a/sync-server/src/server/rate_limit.rs b/sync-server/src/server/rate_limit.rs deleted file mode 100644 index 7792a814..00000000 --- a/sync-server/src/server/rate_limit.rs +++ /dev/null @@ -1,102 +0,0 @@ -use std::{ - collections::HashMap, - sync::{Arc, Mutex}, - time::Instant, -}; - -use axum::{extract::Request, http::StatusCode, middleware::Next, response::Response}; -use axum_extra::{ - TypedHeader, - headers::{Authorization, authorization::Bearer}, -}; - -/// Per-user token-bucket rate limiter. Each bearer token gets its own bucket -/// that refills to `max_per_second` tokens every second. -#[derive(Clone, Debug)] -pub struct RateLimiter { - max_per_second: u64, - buckets: Arc>>>, -} - -#[derive(Debug)] -struct TokenBucket { - state: Mutex, - max_tokens: u64, -} - -#[derive(Debug)] -struct BucketState { - tokens: u64, - last_refill: Instant, -} - -impl RateLimiter { - /// Create a new per-user rate limiter. - /// - /// # Panics - /// - /// Panics if `max_per_second` is 0. - pub fn new(max_per_second: u64) -> Self { - assert!( - max_per_second > 0, - "max_per_second must be > 0 (set rate_limit_per_user_per_second to null in config to disable)" - ); - - Self { - max_per_second, - buckets: Arc::new(Mutex::new(HashMap::new())), - } - } - - fn get_or_create_bucket(&self, token: &str) -> Arc { - self.buckets - .lock() - .expect("rate limiter lock poisoned") - .entry(token.to_owned()) - .or_insert_with(|| { - Arc::new(TokenBucket { - state: Mutex::new(BucketState { - tokens: self.max_per_second, - last_refill: Instant::now(), - }), - max_tokens: self.max_per_second, - }) - }) - .clone() - } -} - -impl TokenBucket { - fn try_acquire(&self) -> bool { - let mut state = self.state.lock().expect("token bucket lock poisoned"); - let now = Instant::now(); - if now.duration_since(state.last_refill).as_secs() >= 1 { - state.tokens = self.max_tokens; - state.last_refill = now; - } - if state.tokens > 0 { - state.tokens -= 1; - true - } else { - false - } - } -} - -pub async fn rate_limit_middleware( - axum::extract::State(limiter): axum::extract::State, - auth_header: Option>>, - req: Request, - next: Next, -) -> Result { - let Some(TypedHeader(auth)) = auth_header else { - return Ok(next.run(req).await); - }; - - let bucket = limiter.get_or_create_bucket(auth.token()); - if bucket.try_acquire() { - Ok(next.run(req).await) - } else { - Err(StatusCode::TOO_MANY_REQUESTS) - } -} diff --git a/sync-server/src/server/requests.rs b/sync-server/src/server/requests.rs index 232e514d..119ad467 100644 --- a/sync-server/src/server/requests.rs +++ b/sync-server/src/server/requests.rs @@ -4,16 +4,18 @@ use reconcile_text::NumberOrText; use serde::{self, Deserialize}; use ts_rs::TS; -use crate::app_state::database::models::VaultUpdateId; +use crate::app_state::database::models::{DocumentId, VaultUpdateId}; #[derive(TS, Debug, TryFromMultipart)] #[ts(export)] pub struct CreateDocumentVersion { + /// The client can decide the document id (if it wishes to) in order + /// to help with syncing. If the client does not provide a document id, + /// the server will generate one. If the client provides a document id + /// it must not already exist in the database. + pub document_id: Option, pub relative_path: String, - #[ts(type = "number")] - pub last_seen_vault_update_id: VaultUpdateId, - #[ts(as = "Vec")] #[form_data(limit = "unlimited")] pub content: FieldData, @@ -22,9 +24,7 @@ pub struct CreateDocumentVersion { #[derive(Debug, TryFromMultipart)] pub struct UpdateBinaryDocumentVersion { pub parent_version_id: VaultUpdateId, - // None on a content-only edit; Some on a user rename. When None, - // the server keeps the document at its current path. - pub relative_path: Option, + pub relative_path: String, #[form_data(limit = "unlimited")] pub content: FieldData, @@ -34,13 +34,18 @@ pub struct UpdateBinaryDocumentVersion { #[serde(rename_all = "camelCase")] #[ts(export)] pub struct UpdateTextDocumentVersion { - #[ts(type = "number")] + #[ts(as = "i32")] pub parent_version_id: VaultUpdateId, - // None on a content-only edit; Some on a user rename. When None, - // the server keeps the document at its current path. - pub relative_path: Option, + pub relative_path: String, #[ts(type = "Array")] pub content: Vec, } + +#[derive(TS, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +#[ts(export)] +pub struct DeleteDocumentVersion { + pub relative_path: String, +} diff --git a/sync-server/src/server/responses.rs b/sync-server/src/server/responses.rs index f5b30782..a8b3fcd7 100644 --- a/sync-server/src/server/responses.rs +++ b/sync-server/src/server/responses.rs @@ -1,4 +1,3 @@ -use chrono::{DateTime, Utc}; use serde::{self, Serialize}; use ts_rs::TS; @@ -37,36 +36,7 @@ pub struct FetchLatestDocumentsResponse { pub last_update_id: VaultUpdateId, } -/// Response to a vault history request (paginated). -#[derive(TS, Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -#[ts(export)] -pub struct VaultHistoryResponse { - pub versions: Vec, - pub has_more: bool, -} - -/// Summary of a single vault returned by the list-vaults endpoint. -#[derive(TS, Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -#[ts(export)] -pub struct VaultInfo { - pub name: String, - pub document_count: u32, - pub created_at: Option>, -} - -/// Response to listing vaults accessible to the authenticated user. -#[derive(TS, Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -#[ts(export)] -pub struct ListVaultsResponse { - pub vaults: Vec, - pub has_more: bool, - pub user_name: String, -} - -/// Response to a create/update document request. +/// Response to an update document request. #[derive(TS, Debug, Clone, Serialize)] #[serde(tag = "type")] #[ts(export)] diff --git a/sync-server/src/server/update_document.rs b/sync-server/src/server/update_document.rs index 0145288c..00fbd008 100644 --- a/sync-server/src/server/update_document.rs +++ b/sync-server/src/server/update_document.rs @@ -16,15 +16,10 @@ use super::{ use crate::{ app_state::{ AppState, - database::{ - WriteTransaction, - models::{DocumentId, StoredDocumentVersion, VaultId, VaultUpdateId}, - }, + database::models::{DocumentId, StoredDocumentVersion, VaultId, VaultUpdateId}, }, config::user_config::User, - errors::{ - SyncServerError, client_error, not_found_error, server_error, write_transaction_error, - }, + errors::{SyncServerError, client_error, not_found_error, server_error}, server::requests::UpdateBinaryDocumentVersion, utils::{ find_first_available_path::find_first_available_path, is_binary::is_binary, @@ -51,27 +46,18 @@ pub async fn update_binary( State(state): State, TypedMultipart(request): TypedMultipart, ) -> Result, SyncServerError> { - let parent_document = - get_parent_document(&state, &vault_id, &document_id, request.parent_version_id).await?; + let parent_document = get_parent_document(&state, &vault_id, request.parent_version_id).await?; let content = request.content.contents.to_vec(); - let transaction = state - .database - .create_write_transaction(&vault_id) - .await - .map_err(write_transaction_error)?; - update_document( - &parent_document.relative_path, - parent_document.content, + parent_document, vault_id, document_id, - request.relative_path.as_deref(), - content, user, device_id, state, - transaction, + &request.relative_path, + content, ) .await } @@ -88,36 +74,28 @@ pub async fn update_text( State(state): State, Json(request): Json, ) -> Result, SyncServerError> { - let parent_document = - get_parent_document(&state, &vault_id, &document_id, request.parent_version_id).await?; + let parent_document = get_parent_document(&state, &vault_id, request.parent_version_id).await?; - let parent_text = str::from_utf8(&parent_document.content) - .context("Parent version contains binary content; use putBinary instead of putText") - .map_err(client_error)?; - - let edited_text = EditedText::from_diff(parent_text, request.content, &*BuiltinTokenizer::Word) - .context("Failed to apply given diff to parent document") - .map_err(client_error)?; + let edited_text = EditedText::from_diff( + str::from_utf8(&parent_document.content) + .expect("parent must be valid UTF-8 because it's a text document"), + request.content, + &*BuiltinTokenizer::Word, + ) + .context("Failed to apply given diff to parent document") + .map_err(client_error)?; let content = edited_text.apply().text().into_bytes(); - let transaction = state - .database - .create_write_transaction(&vault_id) - .await - .map_err(write_transaction_error)?; - update_document( - &parent_document.relative_path, - parent_document.content, + parent_document, vault_id, document_id, - request.relative_path.as_deref(), - content, user, device_id, state, - transaction, + &request.relative_path, + content, ) .await } @@ -125,10 +103,9 @@ pub async fn update_text( async fn get_parent_document( state: &AppState, vault_id: &VaultId, - document_id: &DocumentId, parent_version_id: VaultUpdateId, ) -> Result { - let parent = state + state .database .get_document_version(vault_id, parent_version_id, None) .await @@ -140,36 +117,29 @@ async fn get_parent_document( ))) }, Ok, - )?; - - if &parent.document_id != document_id { - return Err(client_error(anyhow!( - "Parent version `{parent_version_id}` does not belong to document `{document_id}`" - ))); - } - - Ok(parent) + ) } #[allow(clippy::too_many_lines, clippy::too_many_arguments)] -pub async fn update_document( - parent_relative_path: &str, - parent_content: Vec, +async fn update_document( + parent_document: StoredDocumentVersion, vault_id: VaultId, document_id: DocumentId, - relative_path: Option<&str>, - content: Vec, user: User, device_id: DeviceIdHeader, state: AppState, - mut transaction: WriteTransaction, + relative_path: &str, + content: Vec, ) -> Result, SyncServerError> { debug!("Updating document `{document_id}` in vault `{vault_id}`"); - let sanitized_relative_path = relative_path - .map(sanitize_path) - .transpose() - .map_err(client_error)?; + let sanitized_relative_path = sanitize_path(relative_path); + + let mut transaction = state + .database + .create_write_transaction(&vault_id) + .await + .map_err(server_error)?; let last_update_id = state .database @@ -205,12 +175,9 @@ pub async fn update_document( } // Return the latest version if the content and path are the same as the latest - // version. A missing relative_path means "keep current path", so the path - // is implicitly unchanged. - let path_unchanged = sanitized_relative_path - .as_deref() - .is_none_or(|p| p == latest_version.relative_path); - if content == latest_version.content && path_unchanged { + // version + if content == latest_version.content && sanitized_relative_path == latest_version.relative_path + { info!( "Document content is the same as the latest version for `{document_id}`, skipping update" ); @@ -225,89 +192,62 @@ pub async fn update_document( ))); } - // For mergability, use whichever path the new version will live at — the - // requested rename target if the client sent one, otherwise the existing - // server-side path. - let mergable_check_path = sanitized_relative_path - .as_deref() - .unwrap_or(&latest_version.relative_path); let are_all_participants_mergable = is_file_type_mergable( - mergable_check_path, + &sanitized_relative_path, &state.config.server.mergeable_file_extensions, - ) && !is_binary(&parent_content) + ) && !is_binary(&parent_document.content) && !is_binary(&latest_version.content) && !is_binary(&content); - let (merged_content, is_different_from_request_content) = if are_all_participants_mergable { + let merged_content = if are_all_participants_mergable { info!("Merging changes for document `{document_id}` in vault `{vault_id}`"); - let parent_text = str::from_utf8(&parent_content) - .context("Parent document content is not valid UTF-8") - .map_err(client_error)?; - let latest_text = str::from_utf8(&latest_version.content) - .context("Latest version content is not valid UTF-8") - .map_err(client_error)?; - let new_text = str::from_utf8(&content) - .context("New content is not valid UTF-8") - .map_err(client_error)?; - let parent_owned = parent_text.to_owned(); - let latest_owned = latest_text.to_owned(); - let new_owned = new_text.to_owned(); - let content_clone = content.clone(); - - let (merged, is_different) = tokio::task::spawn_blocking(move || { - let merged = reconcile( - &parent_owned, - &latest_owned.into(), - &new_owned.into(), - &*BuiltinTokenizer::Word, - ) - .apply() - .text() - .into_bytes(); - let is_different = merged != content_clone; - (merged, is_different) - }) - .await - .map_err(|e| server_error(anyhow::anyhow!("Reconcile task failed: {e}")))?; - - (merged, is_different) + reconcile( + str::from_utf8(&parent_document.content) + .expect("parent must be valid UTF-8 because it's not binary"), + &str::from_utf8(&latest_version.content) + .expect("latest_version must be valid UTF-8 because it's not binary") + .into(), + &str::from_utf8(&content) + .expect("content must be valid UTF-8 because it's not binary") + .into(), + &*BuiltinTokenizer::Word, + ) + .apply() + .text() + .into_bytes() } else { - (content, false) // false means that the client doesn't need to refetch the file as we can ensure the remote and local versions are the same as LWW is the merging method for binary files + content.clone() }; - // Rename resolution: only apply the client's rename if (a) the client - // requested one (`sanitized_relative_path` is `Some`) and (b) the - // document's path hasn't changed since this client's parent version. - // If the parent and latest paths differ, another client already renamed - // the document — keep the latest path (first rename wins). Content - // changes from both clients are still merged correctly via the 3-way - // reconcile above, independent of which rename wins. A missing - // relative_path means "keep current path" (content-only edit). - let new_relative_path = match sanitized_relative_path.as_deref() { - Some(requested) - if parent_relative_path == latest_version.relative_path - && requested != latest_version.relative_path => - { - let new_path = - find_first_available_path(&vault_id, requested, &state.database, &mut transaction) - .await - .map_err(server_error)?; + let is_different_from_request_content = merged_content != content; - if new_path != requested { - info!( - "Document already exists at new location: `{requested}` when trying to update it in vault `{vault_id}`, deconflicting by creating at `{new_path}`" - ); - } + // We can only update the relative path if we're the first one to do so + let new_relative_path = if parent_document.relative_path == latest_version.relative_path + && latest_version.relative_path != sanitized_relative_path + { + let new_path = find_first_available_path( + &vault_id, + &sanitized_relative_path, + &state.database, + &mut transaction, + ) + .await + .map_err(server_error)?; - new_path + if new_path != sanitized_relative_path { + info!( + "Document already exists at new location: `{sanitized_relative_path}` when trying to update it in vault `{vault_id}`, deconflicting by creating at `{new_path}`" + ); } - _ => latest_version.relative_path.clone(), + + new_path + } else { + latest_version.relative_path.clone() }; let new_version = StoredDocumentVersion { document_id, vault_update_id: last_update_id + 1, - creation_vault_update_id: latest_version.creation_vault_update_id, relative_path: new_relative_path, content: merged_content, updated_date: chrono::Utc::now(), @@ -319,7 +259,7 @@ pub async fn update_document( state .database - .insert_document_version(&vault_id, &new_version, transaction) + .insert_document_version(&vault_id, &new_version, Some(transaction)) .await .map_err(server_error)?; diff --git a/sync-server/src/server/websocket.rs b/sync-server/src/server/websocket.rs index 6e1af0ba..bb10b49f 100644 --- a/sync-server/src/server/websocket.rs +++ b/sync-server/src/server/websocket.rs @@ -1,3 +1,15 @@ +use anyhow::Context; +use axum::{ + extract::{ + Path, State, + ws::{Message, WebSocket, WebSocketUpgrade}, + }, + response::Response, +}; +use futures::stream::StreamExt; +use log::{debug, info}; +use serde::Deserialize; + use crate::{ app_state::{ AppState, @@ -12,35 +24,9 @@ use crate::{ }, }, }, - consts::{ - HANDSHAKE_TIMEOUT, MAX_CURSOR_DOCUMENTS, MAX_CURSORS_PER_DOCUMENT, MAX_RELATIVE_PATH_LEN, - }, errors::{SyncServerError, client_error, server_error}, utils::normalize::normalize, }; -use anyhow::Context; -use axum::{ - extract::{ - Path, State, - ws::{Message, WebSocket, WebSocketUpgrade}, - }, - response::Response, -}; -use futures::sink::SinkExt; -use futures::stream::StreamExt; -use log::{debug, info, warn}; -use serde::Deserialize; - -/// Tracks a pending (not yet authenticated) WebSocket connection. -/// Decrements the counter when dropped, ensuring cleanup even if -/// the upgrade never completes or auth fails. -struct PendingWsGuard(std::sync::Arc); - -impl Drop for PendingWsGuard { - fn drop(&mut self) { - self.0.fetch_sub(1, std::sync::atomic::Ordering::Relaxed); - } -} #[derive(Deserialize)] pub struct WebSocketPathParams { @@ -53,31 +39,13 @@ pub async fn websocket_handler( Path(WebSocketPathParams { vault_id }): Path, State(state): State, ) -> Result { - let current = state - .pending_ws_connections - .fetch_add(1, std::sync::atomic::Ordering::Relaxed); - if current >= state.config.server.max_pending_websocket_connections { - state - .pending_ws_connections - .fetch_sub(1, std::sync::atomic::Ordering::Relaxed); - return Err(client_error(anyhow::anyhow!( - "Too many pending WebSocket connections" - ))); - } - - let guard = PendingWsGuard(state.pending_ws_connections.clone()); - Ok(ws.on_upgrade(move |socket| websocket_wrapped(state, socket, vault_id, guard))) + Ok(ws.on_upgrade(move |socket| websocket_wrapped(state, socket, vault_id))) } -async fn websocket_wrapped( - state: AppState, - stream: WebSocket, - vault_id: VaultId, - pending_guard: PendingWsGuard, -) { +async fn websocket_wrapped(state: AppState, stream: WebSocket, vault_id: VaultId) { info!("WebSocket connection opened on vault `{vault_id}`"); - let result = websocket(state, stream, vault_id.clone(), pending_guard).await; + let result = websocket(state, stream, vault_id.clone()).await; if let Err(err) = result { debug!("WebSocket connection error on vault `{vault_id}`: {err}"); @@ -89,112 +57,39 @@ async fn websocket( state: AppState, stream: WebSocket, vault_id: VaultId, - pending_guard: PendingWsGuard, ) -> Result<(), SyncServerError> { let (mut sender, mut websocket_receiver) = stream.split(); - let handshake_msg = tokio::time::timeout(HANDSHAKE_TIMEOUT, websocket_receiver.next()) - .await - .map_err(|_| client_error(anyhow::anyhow!("WebSocket handshake timed out")))? - .transpose() - .map_err(|e| client_error(anyhow::anyhow!("WebSocket error during handshake: {e}")))?; - - let authed_handshake = get_authenticated_handshake(&state, &vault_id, handshake_msg)?; + let authed_handshake = get_authenticated_handshake( + &state, + &vault_id, + websocket_receiver + .next() + .await + .transpose() + .unwrap_or_default(), + )?; info!( "WebSocket handshake successful for vault `{vault_id}` for `{}`", authed_handshake.handshake.device_id ); - // Auth complete — no longer a pending connection. - drop(pending_guard); + let mut broadcast_receiver = state.broadcasts.get_receiver(vault_id.clone()).await; - let max_clients = state.config.server.max_clients_per_vault; - - // Atomic subscribe + cursor snapshot, serialized against in-flight - // broadcasts: - // - // 1. Acquire the per-vault broadcast send lock. While we hold it, - // no `send_document_update` can run, so no broadcast can fire - // between our subscribe and our cursor snapshot. - // 2. Subscribe to the broadcast channel (now we'll see every - // broadcast that fires after we drop the send guard). - // 3. Snapshot `cursor = max committed vault_update_id`. Because - // `insert_document_version` holds the same send lock from - // *before* the commit through *after* the broadcast, every doc - // visible at this cursor has either (a) already had its - // broadcast delivered to all then-existing subscribers — and we - // weren't one of them, so we'll catch it via the snapshot — or - // (b) had its broadcast contend on the lock we're holding, and - // will be delivered to us as soon as we drop the guard, with - // `vault_update_id > cursor`. - // 4. Drop the send guard so writers can resume broadcasting. - // 5. Stream the catch-up bounded by the cursor — i.e. only docs - // with `vault_update_id <= cursor` — exactly once. - // 6. The send task forwards broadcasts but filters to - // `vault_update_id > cursor`, so a doc that's both in the - // catch-up and in a contended-then-released broadcast is - // delivered exactly once (via the catch-up). - let send_guard = state.broadcasts.acquire_send_lock(&vault_id).await; - let mut broadcast_receiver = match state.broadcasts.get_receiver(vault_id.clone(), max_clients) - { - Ok(receiver) => receiver, - Err(err) => { - drop(send_guard); - warn!( - "Vault `{vault_id}` has reached the maximum number of clients ({max_clients}), rejecting connection from `{}`", - authed_handshake.handshake.device_id - ); - if let Err(e) = sender - .send(Message::Close(Some(axum::extract::ws::CloseFrame { - code: 4000, - reason: format!( - "Vault has reached the maximum number of clients ({max_clients})" - ) - .into(), - }))) - .await - { - warn!("Failed to send WebSocket close frame: {e}"); - } - return Err(err); - } - }; - let cursor = state - .database - .get_max_update_id_in_vault(&vault_id, None) - .await - .map_err(server_error)?; - drop(send_guard); - - // Catch-up on versions committed while this client was offline, - // streamed one-at-a-time in ascending `vault_update_id` order, up - // to the snapshot cursor. - let unseen_documents = get_unseen_documents( - &state, - &vault_id, - authed_handshake.handshake.last_seen_vault_update_id, - cursor, + send_update_over_websocket( + &WebSocketServerMessage::VaultUpdate(WebSocketVaultUpdate { + documents: get_unseen_documents( + &state, + &vault_id, + authed_handshake.handshake.last_seen_vault_update_id, + ) + .await?, + is_initial_sync: true, + }), + &mut sender, ) .await?; - let unseen_summary: Vec<(i64, bool, String)> = unseen_documents - .iter() - .map(|d| (d.vault_update_id, d.is_deleted, d.relative_path.clone())) - .collect(); - info!( - "[CATCHUP] vault={vault_id} device={} last_seen={:?} cursor={cursor} unseen_count={} unseen={:?}", - authed_handshake.handshake.device_id, - authed_handshake.handshake.last_seen_vault_update_id, - unseen_summary.len(), - unseen_summary - ); - for document in unseen_documents { - send_update_over_websocket( - &WebSocketServerMessage::VaultUpdate(WebSocketVaultUpdate { document }), - &mut sender, - ) - .await?; - } send_update_over_websocket( &WebSocketServerMessage::CursorPositions(CursorPositionFromServer { @@ -206,57 +101,24 @@ async fn websocket( let device_id = authed_handshake.handshake.device_id.clone(); let mut send_task = tokio::spawn(async move { - loop { - match broadcast_receiver.recv().await { - Ok(update) => { - // Drop messages this device authored because the HTTP - // response already carried authoritative state back. - // Delete broadcasts are sent without an origin so the - // author also receives them — that's the receipt the - // client needs to drop the doc from its sync queue. - if Some(&device_id) == update.origin_device_id.as_ref() { - continue; - } - - // Filter out vault updates already covered by the - // catch-up snapshot. The handshake atomically - // subscribed and snapshotted `cursor` under the - // broadcast send lock, so any broadcast with - // `vault_update_id <= cursor` is one that contended - // on the lock during our subscribe — its row is - // already in the catch-up stream and re-delivering - // it via this channel would duplicate the message. - // Cursor messages aren't versioned and are always - // forwarded. - if let WebSocketServerMessage::VaultUpdate(WebSocketVaultUpdate { document }) = - &update.message - && document.vault_update_id <= cursor - { - continue; - } - - let message = match update.message { - WebSocketServerMessage::CursorPositions(CursorPositionFromServer { - clients, - }) => WebSocketServerMessage::CursorPositions(CursorPositionFromServer { - clients: clients - .into_iter() - .filter(|client| client.device_id != device_id) - .collect(), - }), - WebSocketServerMessage::VaultUpdate(_) => update.message, - }; - - send_update_over_websocket(&message, &mut sender).await?; - } - Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { - warn!( - "WebSocket receiver lagged, dropped {n} messages — disconnecting client to force full resync" - ); - break; - } - Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + while let Ok(update) = broadcast_receiver.recv().await { + if Some(&device_id) == update.origin_device_id.as_ref() { + continue; } + + let message = match update.message { + WebSocketServerMessage::CursorPositions(CursorPositionFromServer { clients }) => { + WebSocketServerMessage::CursorPositions(CursorPositionFromServer { + clients: clients + .into_iter() + .filter(|client| client.device_id != device_id) + .collect(), + }) + } + WebSocketServerMessage::VaultUpdate(_) => update.message, + }; + + send_update_over_websocket(&message, &mut sender).await?; } Ok::<(), SyncServerError>(()) @@ -266,59 +128,26 @@ async fn websocket( let vault_id_clone = vault_id.clone(); let cursor_manager = state.cursors.clone(); let mut receive_task = tokio::spawn(async move { - while let Some(msg) = websocket_receiver.next().await { - match msg { - Ok(Message::Text(message)) => { - let message: WebSocketClientMessage = serde_json::from_str(&message) - .context("Failed to parse WebSocket message from client") - .map_err(client_error)?; + while let Some(Ok(Message::Text(message))) = websocket_receiver.next().await { + let message: WebSocketClientMessage = serde_json::from_str(&message) + .context("Failed to parse WebSocket message from client") + .map_err(server_error)?; - match message { - WebSocketClientMessage::Handshake(_) => { - return Err(client_error(anyhow::anyhow!( - "Unexpected handshake message" - ))); - } - WebSocketClientMessage::CursorPositions(cursors) => { - let docs = cursors.documents_with_cursors; - if docs.len() > MAX_CURSOR_DOCUMENTS { - warn!( - "Cursor update rejected: {} documents exceeds limit of {MAX_CURSOR_DOCUMENTS}", - docs.len() - ); - continue; - } - - let valid = docs.iter().all(|doc| { - doc.cursors.len() <= MAX_CURSORS_PER_DOCUMENT - && doc.relative_path.len() <= MAX_RELATIVE_PATH_LEN - }); - if !valid { - warn!( - "Cursor update rejected: a document exceeds cursor or path length limits" - ); - continue; - } - - cursor_manager - .update_cursors( - vault_id_clone.clone(), - authed_handshake.user.name.clone(), - &device_id, - docs, - ) - .await; - } - } + match message { + WebSocketClientMessage::Handshake(_) => { + return Err(client_error(anyhow::anyhow!( + "Unexpected handshake message" + ))); } - Ok(Message::Close(_)) => break, - Ok(Message::Binary(_)) => { - warn!("Received unexpected binary WebSocket message, ignoring"); - } - Ok(_) => {} // Ping/Pong frames handled by axum - Err(e) => { - debug!("WebSocket receive error: {e}"); - break; + WebSocketClientMessage::CursorPositions(cursors) => { + cursor_manager + .update_cursors( + vault_id_clone.clone(), + authed_handshake.user.name.clone(), + &device_id, + cursors.documents_with_cursors, + ) + .await; } } } @@ -326,47 +155,38 @@ async fn websocket( Ok::<(), SyncServerError>(()) }); - let result: Result<(), SyncServerError> = tokio::select! { - send_result = &mut send_task => { - receive_task.abort(); - let _ = receive_task.await; - match send_result { - Err(e) => Err(server_error( - anyhow::Error::from(e).context("WebSocket send task failed"), - )), - Ok(inner) => inner, - } - }, - receive_result = &mut receive_task => { - send_task.abort(); - let _ = send_task.await; - match receive_result { - Err(e) => Err(server_error( - anyhow::Error::from(e).context("WebSocket receive task failed"), - )), - Ok(inner) => inner, - } - }, + tokio::select! { + _ = &mut send_task => receive_task.abort(), + _ = &mut receive_task => send_task.abort(), }; + let result: Result<(), SyncServerError> = (async { + send_task + .await + .context("WebSocket send task failed") + .map_err(client_error) + .and_then(|err| err)?; + + receive_task + .await + .context("WebSocket receive task failed") + .map_err(client_error) + .and_then(|err| err)?; + + Ok(()) + }) + .await; + state .cursors .remove_cursors_of_device(&vault_id, &authed_handshake.handshake.device_id) .await; - match &result { - Ok(()) => { - info!( - "WebSocket disconnected on vault `{vault_id}` for `{}`", - authed_handshake.handshake.device_id - ); - } - Err(err) => { - warn!( - "WebSocket error on vault `{vault_id}` for `{}`: {err}", - authed_handshake.handshake.device_id - ); - } + if result.is_err() { + info!( + "WebSocket disconnected on vault `{vault_id}` for `{}`", + authed_handshake.handshake.device_id + ); } result diff --git a/sync-server/src/utils/dedup_paths.rs b/sync-server/src/utils/dedup_paths.rs index 0baf8ba8..bc687f6a 100644 --- a/sync-server/src/utils/dedup_paths.rs +++ b/sync-server/src/utils/dedup_paths.rs @@ -1,17 +1,8 @@ -use std::sync::LazyLock; - use regex::Regex; -static DEDUP_SUFFIX_REGEX: LazyLock = - LazyLock::new(|| Regex::new(r" \((\d+)\)$").expect("invalid regex")); - pub fn dedup_paths(path: &str) -> impl Iterator { let mut path_parts = path.split('/').collect::>(); - let file_name = path_parts - .pop() - .filter(|s| !s.is_empty()) - .unwrap_or(path) - .to_owned(); + let file_name = path_parts.pop().unwrap().to_owned(); let mut directory = path_parts.join("/"); if !directory.is_empty() { @@ -38,13 +29,14 @@ pub fn dedup_paths(path: &str) -> impl Iterator { } }; - let start_number = DEDUP_SUFFIX_REGEX + let regex = Regex::new(r" \((\d+)\)$").unwrap(); + let start_number = regex .captures(&stem) .and_then(|caps| caps.get(1)) .and_then(|m| m.as_str().parse::().ok()) .unwrap_or(0); - let clean_stem = DEDUP_SUFFIX_REGEX.replace(&stem, "").to_string(); + let clean_stem = regex.replace(&stem, "").to_string(); (start_number..).map(move |dedup_number| { if dedup_number == 0 { diff --git a/sync-server/src/utils/find_first_available_path.rs b/sync-server/src/utils/find_first_available_path.rs index eddd81d2..7629d8f1 100644 --- a/sync-server/src/utils/find_first_available_path.rs +++ b/sync-server/src/utils/find_first_available_path.rs @@ -1,30 +1,25 @@ use crate::app_state::database::models::VaultId; -use crate::utils::dedup_paths::dedup_paths; +use crate::{app_state::database::Transaction, utils::dedup_paths::dedup_paths}; use anyhow::Result; use log::{debug, info}; -use sqlx::sqlite::SqliteConnection; pub async fn find_first_available_path( vault_id: &VaultId, sanitized_relative_path: &str, database: &crate::app_state::database::Database, - connection: &mut SqliteConnection, + transaction: &mut Transaction<'_>, ) -> Result { info!("Finding first available path for `{sanitized_relative_path}` in vault `{vault_id}`"); for candidate in dedup_paths(sanitized_relative_path) { debug!("Checking candidate path for deconflicting names: `{candidate}`"); if database - .get_latest_non_deleted_document_by_path(vault_id, &candidate, Some(connection)) + .get_latest_document_by_path(vault_id, &candidate, Some(transaction)) .await? .is_none() { info!("Selected available path: `{candidate}`"); return Ok(candidate); } - - info!( - "Finding first available path for `{sanitized_relative_path}` in vault `{vault_id}` as `{candidate}` is already taken" - ); } unreachable!("dedup_paths produces infinite paths"); diff --git a/sync-server/src/utils/rotating_file_writer.rs b/sync-server/src/utils/rotating_file_writer.rs index 1c5c86c5..f04f9ba9 100644 --- a/sync-server/src/utils/rotating_file_writer.rs +++ b/sync-server/src/utils/rotating_file_writer.rs @@ -6,7 +6,7 @@ use std::{ time::{Duration, SystemTime, UNIX_EPOCH}, }; -use chrono::NaiveDateTime; +use chrono::{Local, NaiveDateTime}; use tracing_subscriber::fmt::MakeWriter; #[derive(Clone)] @@ -55,7 +55,7 @@ impl RotatingFileWriter { let timestamp_str = filename.get(prefix_len..filename.len().checked_sub(4)?)?; let dt = NaiveDateTime::parse_from_str(timestamp_str, "%Y-%m-%d_%H-%M-%S").ok()?; - let timestamp = dt.and_utc(); + let timestamp = dt.and_local_timezone(Local).single()?; let secs: u64 = timestamp.timestamp().try_into().ok()?; Some(UNIX_EPOCH + Duration::from_secs(secs)) @@ -114,7 +114,7 @@ impl RotatingFileWriter { } fn rotate(inner: &mut RotatingFileWriterInner) -> io::Result<()> { - let timestamp = chrono::Utc::now().format("%Y-%m-%d_%H-%M-%S"); + let timestamp = Local::now().format("%Y-%m-%d_%H-%M-%S"); let filename = format!("{}.{}.log", inner.file_prefix, timestamp); let filepath = inner.directory.join(filename); @@ -132,14 +132,8 @@ impl RotatingFileWriter { impl Write for RotatingFileWriter { fn write(&mut self, buf: &[u8]) -> io::Result { - let mut inner = self.inner.lock().unwrap_or_else(|poisoned| { - eprintln!("RotatingFileWriter mutex was poisoned, recovering"); - poisoned.into_inner() - }); + let mut inner = self.inner.lock().unwrap(); - // Reset file handle after poison recovery so the next branch - // re-opens a valid file rather than writing to a potentially - // half-closed handle. if inner.current_file.is_none() { Self::open_or_create_log_file(&mut inner)?; } else if Self::should_rotate(&inner) { @@ -154,10 +148,7 @@ impl Write for RotatingFileWriter { } fn flush(&mut self) -> io::Result<()> { - let mut inner = self.inner.lock().unwrap_or_else(|poisoned| { - eprintln!("RotatingFileWriter mutex was poisoned, recovering"); - poisoned.into_inner() - }); + let mut inner = self.inner.lock().unwrap(); if let Some(ref mut file) = inner.current_file { file.flush() } else { @@ -276,7 +267,7 @@ mod tests { // Parse the expected time let expected_dt = NaiveDateTime::parse_from_str(timestamp_str, "%Y-%m-%d_%H-%M-%S").unwrap(); - let expected_timestamp = expected_dt.and_utc(); + let expected_timestamp = expected_dt.and_local_timezone(Local).single().unwrap(); let expected_duration = Duration::from_secs(expected_timestamp.timestamp().try_into().unwrap()); let expected_next = UNIX_EPOCH + expected_duration + rotation_duration; @@ -315,7 +306,7 @@ mod tests { // Should use the latest file (2025-10-26_14-00-00) let expected_dt = NaiveDateTime::parse_from_str("2025-10-26_14-00-00", "%Y-%m-%d_%H-%M-%S").unwrap(); - let expected_timestamp = expected_dt.and_utc(); + let expected_timestamp = expected_dt.and_local_timezone(Local).single().unwrap(); let expected_duration = Duration::from_secs(expected_timestamp.timestamp().try_into().unwrap()); let expected_next = UNIX_EPOCH + expected_duration + rotation_duration; diff --git a/sync-server/src/utils/sanitize_path.rs b/sync-server/src/utils/sanitize_path.rs index 05100f68..9703225c 100644 --- a/sync-server/src/utils/sanitize_path.rs +++ b/sync-server/src/utils/sanitize_path.rs @@ -1,28 +1,14 @@ -use anyhow::{Result, ensure}; - -use crate::consts::MAX_RELATIVE_PATH_LEN; - /// Sanitize the document's path to allow all clients to create the same path in /// their filesystem. If we didn't do this server-side, client's would need to /// deal with mapping invalid names to valid ones and then back. -pub fn sanitize_path(path: &str) -> Result { - // Enforce the length cap at the single chokepoint every create/update - // handler goes through, so clients can't blow up axum's JSON/multipart - // parser with a 1 MB `relative_path` before the handler ever runs. - // The WebSocket cursor handler enforces this separately. - ensure!( - path.len() <= MAX_RELATIVE_PATH_LEN, - "Relative path exceeds the maximum length of {MAX_RELATIVE_PATH_LEN} bytes" - ); - +pub fn sanitize_path(path: &str) -> String { let options = sanitize_filename::Options { truncate: true, windows: true, // Windows is the lowest common denominator replacement: "", }; - let result = path - .split('/') + path.split('/') .map(|part| { let proposal = sanitize_filename::sanitize_with_options(part, options.clone()); if !part.is_empty() && proposal.is_empty() { @@ -32,13 +18,7 @@ pub fn sanitize_path(path: &str) -> Result { } }) .collect::>() - .join("/"); - - ensure!( - !result.is_empty(), - "Relative path is empty after sanitization" - ); - Ok(result) + .join("/") } #[cfg(test)] @@ -47,32 +27,8 @@ mod test { #[test] fn test_sanitize_path() { - assert_eq!(sanitize_path("/my/path/what?").unwrap(), "/my/path/what"); - assert_eq!(sanitize_path("file (1).md").unwrap(), "file (1).md"); - assert_eq!(sanitize_path("/my/path/\\\\:?").unwrap(), "/my/path/_"); - } - - #[test] - fn test_sanitize_path_empty() { - assert!(sanitize_path("").is_err()); - } - - #[test] - fn test_sanitize_path_idempotent_simple() { - let mut result = sanitize_path("notes/my file.md").unwrap(); - for _ in 0..5 { - result = sanitize_path(&result).unwrap(); - } - assert_eq!(result, "notes/my file.md"); - } - - #[test] - fn test_sanitize_path_idempotent_special_chars() { - let first = sanitize_path("/my/path/what?/file:name<>.md").unwrap(); - let mut result = first.clone(); - for _ in 0..5 { - result = sanitize_path(&result).unwrap(); - } - assert_eq!(result, first); + assert_eq!(sanitize_path("/my/path/what?"), "/my/path/what"); + assert_eq!(sanitize_path("file (1).md"), "file (1).md"); + assert_eq!(sanitize_path("/my/path/\\\\:?"), "/my/path/_"); } }