diff --git a/.gitignore b/.gitignore index 967b2b65..a1c1ac4f 100644 --- a/.gitignore +++ b/.gitignore @@ -7,18 +7,15 @@ node_modules # Frontend build folders frontend/*/dist +sync-server/db.sqlite3* +sync-server/databases + # Rust build folders sync-server/target sync-server/artifacts sync-server/bindings/*.ts -# build folders -sync-server/db.sqlite3* -**/databases - *.log *.sqlx target - -.task diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..c77b091b --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,110 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +VaultLink is a self-hosted Obsidian plugin for real-time collaborative file syncing. The project consists of a Rust-based sync server and a TypeScript frontend with three main components: an Obsidian plugin, a sync client library, and a test client. + +## Architecture + +### Core Components + +- **sync-server/**: Rust-based WebSocket server with SQLite database for document versioning and real-time synchronization +- **frontend/sync-client/**: TypeScript library providing core sync functionality, WebSocket management, and file operations +- **frontend/obsidian-plugin/**: Obsidian plugin that integrates the sync client with Obsidian's API +- **frontend/test-client/**: CLI testing tool for the sync functionality + +### Key Technologies + +- **Backend**: Rust with Axum framework, SQLite with SQLx, WebSockets for real-time sync +- **Frontend**: TypeScript, Webpack for bundling, Jest for testing +- **Sync Algorithm**: Uses reconcile-text library for operational transformation + +## Development Commands + +### Server Development +```bash +cd sync-server +cargo run config-e2e.yml # Start development server +cargo test --verbose # Run Rust tests +cargo clippy --all-targets --all-features # Lint Rust code +cargo clippy --all-targets --all-features --fix --allow-dirty --allow-staged # Auto-fix clippy warnings +cargo fmt --all -- --check # Check Rust formatting +cargo fmt --all # Auto-format Rust code +cargo machete --with-metadata # Detect unused dependencies +``` + +### Frontend Development +```bash +cd frontend +npm run dev # Start development mode (watches sync-client and obsidian-plugin) +npm run build # Build all workspaces +npm run test # Run all tests +npm run lint # Lint and format TypeScript code +``` + +### Database Setup (Development) +```bash +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 +cargo sqlx prepare --workspace +``` + +### Initial Setup +```bash +# Install required cargo tools +cargo install sqlx-cli cargo-machete cargo-edit +``` + +### Scripts +- `scripts/check.sh`: Full CI check (builds, lints, tests both server and frontend) +- `scripts/check.sh --fix`: Same as above but auto-fixes linting and formatting issues +- `scripts/e2e.sh`: End-to-end testing +- `scripts/clean-up.sh`: Clean logs and database files +- `scripts/bump-version.sh patch`: Publish new version +- `scripts/update-api-types.sh`: Update TypeScript bindings from Rust types + +## Code Structure + +### Workspace Configuration +The frontend uses npm workspaces with four packages: +- `sync-client`: Core synchronization logic +- `obsidian-plugin`: Obsidian-specific integration +- `test-client`: Testing utilities +- `local-client-cli`: Standalone CLI for VaultLink sync client + +### Type Generation +Rust structs generate TypeScript types via ts-rs crate, stored in `sync-server/bindings/` and used by frontend packages. + +### Key Files +- `sync-server/src/`: Rust server implementation with WebSocket handlers +- `frontend/sync-client/src/sync-client.ts`: Main sync client entry point +- `frontend/obsidian-plugin/src/vault-link-plugin.ts`: Main Obsidian plugin class +- `frontend/sync-client/src/services/sync-service.ts`: Core synchronization logic + +## Testing + +### Running Tests +- Server: `cargo test --verbose` +- Frontend: `npm run test` (runs Jest across all workspaces) +- E2E: `scripts/e2e.sh` + +### Test Structure +- Rust: Unit tests alongside source files +- TypeScript: `.test.ts` files using Jest +- E2E: Uses test-client to simulate multiple concurrent users + +## Code Style + +### Rust +- Uses extensive Clippy lints (see Cargo.toml) +- Follows pedantic linting rules +- Forbids unsafe code +- Uses cargo fmt with default settings + +### TypeScript +- Prettier configuration: 4-space tabs, trailing commas removed, LF line endings +- ESLint with unused imports plugin +- Consistent across all three frontend packages diff --git a/README.md b/README.md index 74c6ee97..f5da9b61 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,12 @@ ## Develop -### Set up Node.JS 25 with [nvm](https://github.com/nvm-sh/nvm) +### Install [nvm](https://github.com/nvm-sh/nvm) - `curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash` -- `nvm install 25` -- `nvm use 25` -- Optionally, set the system-wide default: `nvm alias default 25` +- `nvm install 22` +- `nvm use 22` +- Optionally set the system-wide default: `nvm alias default 22` ### Set up Rust diff --git a/frontend/deterministic-tests/README.md b/frontend/deterministic-tests/README.md index 678cd0fe..5c835326 100644 --- a/frontend/deterministic-tests/README.md +++ b/frontend/deterministic-tests/README.md @@ -24,9 +24,6 @@ Clients always start with syncing disabled. - `barrier` — retry until all clients converge to identical file state (60s timeout) - `enable-sync` / `disable-sync` — simulate going online/offline -**WebSocket control** (per-client): -- `pause-websocket` / `resume-websocket` — buffer/release WebSocket messages for a specific client - **Server control:** - `pause-server` / `resume-server` — SIGSTOP/SIGCONT the server process diff --git a/frontend/deterministic-tests/src/consts.ts b/frontend/deterministic-tests/src/consts.ts index a04c9b61..32c03efa 100644 --- a/frontend/deterministic-tests/src/consts.ts +++ b/frontend/deterministic-tests/src/consts.ts @@ -6,7 +6,7 @@ export const STOP_TIMEOUT_MS = 5_000; export const CONVERGENCE_TIMEOUT_MS = 60_000; export const CONVERGENCE_RETRY_DELAY_MS = 500; export const AGENT_INIT_TIMEOUT_MS = 30_000; -export const IS_SYNC_ENABLED_BY_DEFAULT = false; +export const IS_SYNC_ENABLED_DEFAULT = false; export const WAIT_TIMEOUT_MS = 60_000; export const WEBSOCKET_CONNECT_TIMEOUT_MS = 10_000; diff --git a/frontend/deterministic-tests/src/deterministic-agent.ts b/frontend/deterministic-tests/src/deterministic-agent.ts index 71f6a272..136d5ed8 100644 --- a/frontend/deterministic-tests/src/deterministic-agent.ts +++ b/frontend/deterministic-tests/src/deterministic-agent.ts @@ -3,8 +3,7 @@ import { SyncClient, debugging, LogLevel } from "sync-client"; import { assert } from "./utils/assert"; import { sleep } from "./utils/sleep"; import { withTimeout } from "./utils/with-timeout"; -import { IS_SYNC_ENABLED_BY_DEFAULT, WAIT_TIMEOUT_MS, WEBSOCKET_CONNECT_TIMEOUT_MS, WEBSOCKET_POLL_INTERVAL_MS } from "./consts"; -import { ManagedWebSocketFactory } from "./managed-websocket"; +import { IS_SYNC_ENABLED_DEFAULT, WAIT_TIMEOUT_MS, WEBSOCKET_CONNECT_TIMEOUT_MS, WEBSOCKET_POLL_INTERVAL_MS } from "./consts"; @@ -16,10 +15,9 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem { settings: Partial; database: Partial; }> = {}; - private isSyncEnabled = IS_SYNC_ENABLED_BY_DEFAULT; + private isSyncEnabled = IS_SYNC_ENABLED_DEFAULT; private readonly syncErrors: Error[] = []; private readonly pendingSyncOperations = new Set>(); - private readonly wsFactory = new ManagedWebSocketFactory(); public constructor( clientId: number, @@ -34,6 +32,7 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem { public async init( fetchImplementation: typeof globalThis.fetch, + webSocketImplementation: typeof globalThis.WebSocket ): Promise { this.client = await SyncClient.create({ fs: this, @@ -42,7 +41,7 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem { save: async (data) => void (this.data = data) }, fetch: fetchImplementation, - webSocket: this.wsFactory.constructorFn + webSocket: webSocketImplementation }); this.client.logger.onLogEmitted.add((line) => { @@ -76,14 +75,68 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem { } } - public pauseWebSocket(): void { - this.log("Pausing WebSocket message delivery"); - this.wsFactory.pause(); + public async createFile(path: string, content: string): Promise { + this.log(`Creating file ${path} with content: ${content}`); + if (this.files.has(path)) { + throw new Error(`File ${path} already exists`); + } + const contentBytes = new TextEncoder().encode(content); + this.files.set(path, contentBytes); + + if (this.isSyncEnabled) { + this.enqueueSync(async () => + this.client.syncLocallyCreatedFile(path) + ); + } } - public resumeWebSocket(): void { - this.log("Resuming WebSocket message delivery"); - this.wsFactory.resume(); + public async updateFile(path: string, content: string): Promise { + this.log(`Updating file ${path} with content: ${content}`); + if (!this.files.has(path)) { + throw new Error( + `File ${path} does not exist on client ${this.clientId}` + ); + } + const contentBytes = new TextEncoder().encode(content); + this.files.set(path, contentBytes); + + if (this.isSyncEnabled) { + this.enqueueSync(async () => + this.client.syncLocallyUpdatedFile({ relativePath: path }) + ); + } + } + + public async renameFile(oldPath: string, newPath: string): Promise { + this.log(`Renaming file ${oldPath} to ${newPath}`); + const file = this.files.get(oldPath); + if (!file) { + throw new Error( + `File ${oldPath} does not exist on client ${this.clientId}` + ); + } + this.files.set(newPath, file); + if (oldPath !== newPath) { + this.files.delete(oldPath); + } + if (this.isSyncEnabled) { + this.enqueueSync(async () => + this.client.syncLocallyUpdatedFile({ + oldPath, + relativePath: newPath + }) + ); + } + } + + public async deleteFile(path: string): Promise { + this.log(`Deleting file ${path}`); + this.files.delete(path); + if (this.isSyncEnabled) { + this.enqueueSync(async () => + this.client.syncLocallyDeletedFile(path) + ); + } } public async waitForSync(): Promise { @@ -138,6 +191,9 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem { await this.waitForWebSocket(); } + public async getFiles(): Promise { + return this.listFilesRecursively(); + } public async getFileContent(path: string): Promise { const bytes = await this.read(path); @@ -170,6 +226,10 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem { this.log("Cleanup complete"); } + // Yield the event loop before each FS operation so that the SyncClient's + // async calls create real interleaving points, matching the behavior of + // actual disk I/O. Without this, all FS operations resolve in the same + // microtask, hiding concurrency bugs that only manifest with real latency. public override async read(path: RelativePath): Promise { await Promise.resolve(); return super.read(path); @@ -180,50 +240,33 @@ export class DeterministicAgent extends debugging.InMemoryFileSystem { content: Uint8Array ): Promise { await Promise.resolve(); - const isNew = !this.files.has(path); - await super.write(path, content); - - if (isNew) { - this.enqueueSync(async () => { this.client.syncLocallyCreatedFile(path); } - ); - } else { - this.enqueueSync(async () => { this.client.syncLocallyUpdatedFile({ relativePath: path }); } - ); - } + return super.write(path, content); } public override async atomicUpdateText( path: RelativePath, updater: (current: TextWithCursors) => TextWithCursors ): Promise { - const result = await super.atomicUpdateText(path, updater); - this.enqueueSync(async () => { this.client.syncLocallyUpdatedFile({ relativePath: path }); } - ); - return result; - + await Promise.resolve(); + return super.atomicUpdateText(path, updater); } + public override async exists(path: RelativePath): Promise { + await Promise.resolve(); + return super.exists(path); + } public override async delete(path: RelativePath): Promise { - await super.delete(path); - if (this.isSyncEnabled) { - this.enqueueSync(async () => { this.client.syncLocallyDeletedFile(path); } - ); - } + await Promise.resolve(); + return super.delete(path); } public override async rename( oldPath: RelativePath, newPath: RelativePath ): Promise { - await super.rename(oldPath, newPath); - this.enqueueSync(async () => { - this.client.syncLocallyUpdatedFile({ - oldPath, - relativePath: newPath - }); - } - ); + await Promise.resolve(); + return super.rename(oldPath, newPath); } private async waitForWebSocket(): Promise { diff --git a/frontend/deterministic-tests/src/managed-websocket.ts b/frontend/deterministic-tests/src/managed-websocket.ts deleted file mode 100644 index c09b44d7..00000000 --- a/frontend/deterministic-tests/src/managed-websocket.ts +++ /dev/null @@ -1,170 +0,0 @@ -/** - * A WebSocket wrapper that can pause and resume message delivery. - * When paused, incoming messages are buffered. When resumed, buffered - * messages are delivered in order via the onmessage handler. - */ -export class ManagedWebSocket implements WebSocket { - private readonly ws: WebSocket; - private paused = false; - private readonly bufferedMessages: MessageEvent[] = []; - private externalOnMessage: ((event: MessageEvent) => unknown) | null = null; - - public constructor(url: string | URL, protocols?: string | string[]) { - this.ws = new WebSocket(url, protocols); - - this.ws.onmessage = (event: MessageEvent): void => { - if (this.paused) { - this.bufferedMessages.push(event); - } else { - this.externalOnMessage?.(event); - } - }; - } - - public pause(): void { - this.paused = true; - } - - public resume(): void { - this.paused = false; - const messages = this.bufferedMessages.splice(0); - for (const msg of messages) { - this.externalOnMessage?.(msg); - } - } - - get readyState(): number { - return this.ws.readyState; - } - - get url(): string { - return this.ws.url; - } - - get protocol(): string { - return this.ws.protocol; - } - - get extensions(): string { - return this.ws.extensions; - } - - get bufferedAmount(): number { - return this.ws.bufferedAmount; - } - - get binaryType(): BinaryType { - return this.ws.binaryType; - } - - set binaryType(value: BinaryType) { - this.ws.binaryType = value; - } - - get onopen(): ((this: WebSocket, ev: Event) => unknown) | null { - return this.ws.onopen; - } - - set onopen(handler: ((this: WebSocket, ev: Event) => unknown) | null) { - this.ws.onopen = handler; - } - - get onclose(): ((this: WebSocket, ev: CloseEvent) => unknown) | null { - return this.ws.onclose; - } - - set onclose(handler: ((this: WebSocket, ev: CloseEvent) => unknown) | null) { - this.ws.onclose = handler; - } - - get onerror(): ((this: WebSocket, ev: Event) => unknown) | null { - return this.ws.onerror; - } - - set onerror(handler: ((this: WebSocket, ev: Event) => unknown) | null) { - this.ws.onerror = handler; - } - - get onmessage(): ((this: WebSocket, ev: MessageEvent) => unknown) | null { - return this.externalOnMessage; - } - - set onmessage( - handler: ((this: WebSocket, ev: MessageEvent) => unknown) | null - ) { - this.externalOnMessage = handler; - } - - public send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void { - this.ws.send(data); - } - - public close(code?: number, reason?: string): void { - this.ws.close(code, reason); - } - - public addEventListener( - ...args: Parameters - ): void { - this.ws.addEventListener(...args); - } - - public removeEventListener( - ...args: Parameters - ): void { - this.ws.removeEventListener(...args); - } - - public dispatchEvent(event: Event): boolean { - return this.ws.dispatchEvent(event); - } - - static readonly CONNECTING = WebSocket.CONNECTING; - static readonly OPEN = WebSocket.OPEN; - static readonly CLOSING = WebSocket.CLOSING; - static readonly CLOSED = WebSocket.CLOSED; - - readonly CONNECTING = WebSocket.CONNECTING; - readonly OPEN = WebSocket.OPEN; - readonly CLOSING = WebSocket.CLOSING; - readonly CLOSED = WebSocket.CLOSED; -} - -/** - * Factory that creates ManagedWebSocket instances and tracks them - * for pause/resume control from the test harness - */ -export class ManagedWebSocketFactory { - private readonly instances: ManagedWebSocket[] = []; - - public get constructorFn(): typeof globalThis.WebSocket { - const factory = this; - const ctor = function ManagedWS( - url: string | URL, - protocols?: string | string[] - ): ManagedWebSocket { - const ws = new ManagedWebSocket(url, protocols); - factory.instances.push(ws); - return ws; - } as unknown as typeof globalThis.WebSocket; - - Object.defineProperty(ctor, "CONNECTING", { value: WebSocket.CONNECTING }); - Object.defineProperty(ctor, "OPEN", { value: WebSocket.OPEN }); - Object.defineProperty(ctor, "CLOSING", { value: WebSocket.CLOSING }); - Object.defineProperty(ctor, "CLOSED", { value: WebSocket.CLOSED }); - - return ctor; - } - - public pause(): void { - for (const ws of this.instances) { - ws.pause(); - } - } - - public resume(): void { - for (const ws of this.instances) { - ws.resume(); - } - } -} diff --git a/frontend/deterministic-tests/src/server-control.ts b/frontend/deterministic-tests/src/server-control.ts index de0dbe4b..c2d353db 100644 --- a/frontend/deterministic-tests/src/server-control.ts +++ b/frontend/deterministic-tests/src/server-control.ts @@ -40,10 +40,8 @@ export class ServerControl { 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(); this.tempDir = fs.mkdtempSync( - path.join(tmpBase, "vault-link-test-") + path.join(os.tmpdir(), "vault-link-test-") ); const tempConfigPath = path.join(this.tempDir, "config.yml"); const dbDir = path.join(this.tempDir, "databases"); diff --git a/frontend/deterministic-tests/src/test-definition.ts b/frontend/deterministic-tests/src/test-definition.ts index 826c6014..f8dac1fe 100644 --- a/frontend/deterministic-tests/src/test-definition.ts +++ b/frontend/deterministic-tests/src/test-definition.ts @@ -16,9 +16,7 @@ export type TestStep = | { type: "pause-server" } | { type: "resume-server" } | { type: "barrier" } - | { type: "assert-consistent"; verify?: (state: AssertableState) => void } - | { type: "pause-websocket"; client: number } - | { type: "resume-websocket"; client: number }; + | { type: "assert-consistent"; verify?: (state: AssertableState) => void }; export interface TestDefinition { description?: string; diff --git a/frontend/deterministic-tests/src/test-registry.ts b/frontend/deterministic-tests/src/test-registry.ts index 4a16db2b..0785926b 100644 --- a/frontend/deterministic-tests/src/test-registry.ts +++ b/frontend/deterministic-tests/src/test-registry.ts @@ -49,7 +49,7 @@ 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 { updateSurvivesRemoteDeleteTest } from "./tests/update-survives-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"; @@ -65,33 +65,6 @@ 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 { 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"; -import { rapidEditDeleteOnlineConvergenceTest } from "./tests/rapid-edit-delete-online-convergence.test"; -import { serverPauseDeleteRecreateTest } from "./tests/server-pause-delete-recreate.test"; -import { onlineBothCreateSamePathDeconflictTest } from "./tests/online-both-create-same-path-deconflict.test"; -import { onlineCreateUpdateWhileOtherCreatesSamePathTest } from "./tests/online-create-update-while-other-creates-same-path.test"; -import { displacedFileNotMarkedDeletedTest } from "./tests/displaced-file-not-marked-deleted.test"; export const TESTS: Partial> = { "rename-create-conflict": renameCreateConflictTest, @@ -144,7 +117,7 @@ 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-survives-remote-delete": updateSurvivesRemoteDeleteTest, "move-preserves-remote-update": movePreservesRemoteUpdateTest, "recently-deleted-cleared-on-reconnect": recentlyDeletedClearedOnReconnectTest, "migrate-key-preserves-existing": migrateKeyPreservesExistingTest, @@ -160,31 +133,4 @@ export const TESTS: Partial> = { "online-create-rename-concurrent-create-orphan": onlineCreateRenameConcurrentCreateOrphanTest, "concurrent-rename-first-wins": concurrentRenameFirstWinsTest, "binary-to-text-transition": binaryToTextTransitionTest, - "text-pending-create-not-displaced": textPendingCreateNotDisplacedTest, - "binary-pending-create-not-displaced": binaryPendingCreateNotDisplacedTest, - "coalesce-update-remote-update-data-loss": coalesceUpdateRemoteUpdateDataLossTest, - "coalesced-remote-update-watermark-loss": coalescedRemoteUpdateWatermarkLossTest, - "concurrent-delete-during-remote-update": concurrentDeleteDuringRemoteUpdateTest, - "concurrent-edit-exact-same-position": concurrentEditExactSamePositionTest, - "concurrent-rename-and-create-at-target-rename-first": concurrentRenameAndCreateAtTargetRenameFirstTest, - "concurrent-rename-and-create-at-target-create-first": concurrentRenameAndCreateAtTargetCreateFirstTest, - "concurrent-rename-same-target": concurrentRenameSameTargetTest, - "concurrent-update-diff-consistency": concurrentUpdateDiffConsistencyTest, - "user-parenthesized-file-not-deleted": userParenthesizedFileNotDeletedTest, - "create-delete-noop": createDeleteNoopTest, - "create-merge-delete": createMergeDeleteTest, - "move-identical-content-ambiguity": moveIdenticalContentAmbiguityTest, - "create-update-coalesce-server-pause": createUpdateCoalesceServerPauseTest, - "create-during-reconciliation": createDuringReconciliationTest, - "create-merge-preserves-renamed-update": createMergePreservesRenamedUpdateTest, - "create-rename-create-same-path": createRenameCreateSamePathTest, - "move-chain-three-files": moveChainThreeFilesTest, - "delete-by-other-client-then-recreate": deleteByOtherClientThenRecreateTest, - "online-delete-recreate-rapid-cycle": onlineDeleteRecreateRapidCycleTest, - "online-edit-vs-delete-convergence": onlineEditVsDeleteConvergenceTest, - "rapid-edit-delete-online-convergence": rapidEditDeleteOnlineConvergenceTest, - "server-pause-delete-recreate": serverPauseDeleteRecreateTest, - "online-both-create-same-path-deconflict": onlineBothCreateSamePathDeconflictTest, - "online-create-update-while-other-creates-same-path": onlineCreateUpdateWhileOtherCreatesSamePathTest, - "displaced-file-not-marked-deleted": displacedFileNotMarkedDeletedTest, }; diff --git a/frontend/deterministic-tests/src/test-runner.ts b/frontend/deterministic-tests/src/test-runner.ts index 2d469fa2..05ac1611 100644 --- a/frontend/deterministic-tests/src/test-runner.ts +++ b/frontend/deterministic-tests/src/test-runner.ts @@ -14,7 +14,7 @@ import { CONVERGENCE_TIMEOUT_MS, CONVERGENCE_RETRY_DELAY_MS, AGENT_INIT_TIMEOUT_MS, - IS_SYNC_ENABLED_BY_DEFAULT + IS_SYNC_ENABLED_DEFAULT } from "./consts"; import { randomUUID } from "node:crypto"; @@ -100,7 +100,7 @@ export class TestRunner { for (let i = 0; i < count; i++) { const settings: Partial = { - isSyncEnabled: IS_SYNC_ENABLED_BY_DEFAULT, + isSyncEnabled: IS_SYNC_ENABLED_DEFAULT, token: this.token, vaultName, remoteUri: this.remoteUri @@ -115,6 +115,8 @@ export class TestRunner { await withTimeout( agent.init( fetch, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + WebSocket as unknown as typeof globalThis.WebSocket ), AGENT_INIT_TIMEOUT_MS, `Client ${i} init timed out after ${AGENT_INIT_TIMEOUT_MS}ms` @@ -136,22 +138,28 @@ export class TestRunner { private async executeStep(step: TestStep): Promise { switch (step.type) { case "create": - case "update": - await this.getAgent(step.client).write( + await this.getAgent(step.client).createFile( step.path, - new TextEncoder().encode(step.content) + step.content + ); + break; + + case "update": + await this.getAgent(step.client).updateFile( + step.path, + step.content ); break; case "rename": - await this.getAgent(step.client).rename( + await this.getAgent(step.client).renameFile( step.oldPath, step.newPath ); break; case "delete": - await this.getAgent(step.client).delete(step.path); + await this.getAgent(step.client).deleteFile(step.path); break; case "sync": @@ -191,14 +199,6 @@ export class TestRunner { await this.assertConsistent(step.verify); break; - case "pause-websocket": - this.getAgent(step.client).pauseWebSocket(); - break; - - case "resume-websocket": - this.getAgent(step.client).resumeWebSocket(); - break; - default: { const unknownStep = step as { type: string }; throw new Error(`Unknown step type: ${unknownStep.type}`); @@ -282,7 +282,7 @@ export class TestRunner { // where background sync could mutate state between reads. const clientFiles: Map[] = []; for (const agent of this.agents) { - const sortedFiles = (await agent.listFilesRecursively()).sort(); + const sortedFiles = (await agent.getFiles()).sort(); const fileMap = new Map(); for (const file of sortedFiles) { const content = await agent.getFileContent(file); diff --git a/frontend/deterministic-tests/src/tests/1-text-pending-create-not-displaced.test.ts b/frontend/deterministic-tests/src/tests/1-text-pending-create-not-displaced.test.ts index fced7c5f..77f053ff 100644 --- a/frontend/deterministic-tests/src/tests/1-text-pending-create-not-displaced.test.ts +++ b/frontend/deterministic-tests/src/tests/1-text-pending-create-not-displaced.test.ts @@ -10,19 +10,19 @@ export const textPendingCreateNotDisplacedTest: TestDefinition = { type: "create", client: 0, path: "data.txt", - content: "text data from client-0" + content: "text data from client 0" }, { type: "create", client: 1, path: "data.txt", - content: "text data from client-1" + content: "text data from client 1" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "barrier" }, - { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertFileExists("data.txt").assertAnyFileContains("client-0", "client-1") } + { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertFileExists("data.txt").assertAnyFileContains("data from client 0", "data from client 1") } ] }; diff --git a/frontend/deterministic-tests/src/tests/binary-to-text-transition.test.ts b/frontend/deterministic-tests/src/tests/binary-to-text-transition.test.ts index f6e14152..d6e9d43f 100644 --- a/frontend/deterministic-tests/src/tests/binary-to-text-transition.test.ts +++ b/frontend/deterministic-tests/src/tests/binary-to-text-transition.test.ts @@ -2,10 +2,9 @@ import type { TestDefinition } from "../test-definition"; export const binaryToTextTransitionTest: TestDefinition = { description: - "A .bin file is created and synced. Both clients edit it offline " + - "(binary last-write-wins), then client 0 renames it to .md and " + - "writes a clean text baseline. Both clients edit different sections " + - "offline. The text merge should preserve both edits.", + "A .bin file is created and synced. Both clients edit it offline, " + + "then it is renamed to .md. Both clients edit different sections " + + "offline again. The second merge should preserve both edits.", clients: 2, steps: [ { type: "create", client: 0, path: "data.bin", content: "original content" }, @@ -17,33 +16,32 @@ export const binaryToTextTransitionTest: TestDefinition = { { type: "disable-sync", client: 0 }, { type: "disable-sync", client: 1 }, - { type: "update", client: 0, path: "data.bin", content: "version A" }, - { type: "update", client: 1, path: "data.bin", content: "version B" }, + { type: "update", client: 0, path: "data.bin", content: "version A from client 0" }, + { type: "update", client: 1, path: "data.bin", content: "version B from client 1" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "barrier" }, - { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContainsAny("data.bin", "version A", "version B") }, + { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContainsAny("data.bin", "version A from client 0", "version B from client 1") }, { type: "disable-sync", client: 1 }, { type: "rename", client: 0, oldPath: "data.bin", newPath: "data.md" }, - { type: "update", client: 0, path: "data.md", content: "top line\nmiddle line\nbottom line" }, { type: "sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "barrier" }, - { type: "assert-consistent", verify: (s) => s.assertContent("data.md", "top line\nmiddle line\nbottom line") }, + { type: "assert-consistent", verify: (s) => s.assertFileExists("data.md") }, { type: "disable-sync", client: 0 }, { type: "disable-sync", client: 1 }, - { type: "update", client: 0, path: "data.md", content: "alpha\nmiddle line\nbottom line" }, - { type: "update", client: 1, path: "data.md", content: "top line\nmiddle line\nbeta" }, + { type: "update", client: 0, path: "data.md", content: "top edit from 0\nmiddle line\nshared end" }, + { type: "update", client: 1, path: "data.md", content: "shared start\nmiddle line\nbottom edit from 1" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "barrier" }, - { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContains("data.md", "alpha", "beta") }, + { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContains("data.md", "top edit from 0", "bottom edit from 1") }, ], }; diff --git a/frontend/deterministic-tests/src/tests/delete-recreate-different-content.test.ts b/frontend/deterministic-tests/src/tests/delete-recreate-different-content.test.ts index 02197b8d..fd483419 100644 --- a/frontend/deterministic-tests/src/tests/delete-recreate-different-content.test.ts +++ b/frontend/deterministic-tests/src/tests/delete-recreate-different-content.test.ts @@ -41,6 +41,6 @@ export const deleteRecreateDifferentContentTest: TestDefinition = { { type: "sync" }, { type: "barrier" }, - { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContains("A.md", "brand new", "client 1") } + { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContains("A.md", "brand new content", "edit from client 1") } ] }; diff --git a/frontend/deterministic-tests/src/tests/displaced-file-not-marked-deleted.test.ts b/frontend/deterministic-tests/src/tests/displaced-file-not-marked-deleted.test.ts deleted file mode 100644 index 99d5f716..00000000 --- a/frontend/deterministic-tests/src/tests/displaced-file-not-marked-deleted.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { TestDefinition } from "../test-definition"; - -export const displacedFileNotMarkedDeletedTest: TestDefinition = { - description: - "Client 0 creates a new file at path B.md while client 1 renames " + - "A.md to B.md. The remote download of B.md displaces client 1's " + - "renamed file. The displaced document must not be permanently " + - "marked as recently deleted, so it can still be synced.", - clients: 2, - steps: [ - { type: "create", client: 0, path: "A.md", content: "content of A" }, - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "barrier" }, - - { type: "disable-sync", client: 1 }, - - { type: "create", client: 0, path: "B.md", content: "new file B" }, - { type: "rename", client: 0, oldPath: "A.md", newPath: "C.md" }, - { type: "sync", client: 0 }, - - { type: "rename", client: 1, oldPath: "A.md", newPath: "B.md" }, - { type: "update", client: 1, path: "B.md", content: "edited A content" }, - { type: "enable-sync", client: 1 }, - - { type: "barrier" }, - - { - type: "assert-consistent", - verify: (state) => { - state - .assertFileNotExists("A.md") - .assertFileExists("B.md") - .assertContains("B.md", "new file B") - .assertFileExists("C.md") - .assertContains("C.md", "edited A content"); - } - } - ] -}; diff --git a/frontend/deterministic-tests/src/tests/local-edit-lost-during-create-merge.test.ts b/frontend/deterministic-tests/src/tests/local-edit-lost-during-create-merge.test.ts index 66c832db..94d82baa 100644 --- a/frontend/deterministic-tests/src/tests/local-edit-lost-during-create-merge.test.ts +++ b/frontend/deterministic-tests/src/tests/local-edit-lost-during-create-merge.test.ts @@ -2,18 +2,26 @@ import type { TestDefinition } from "../test-definition"; export const localEditLostDuringCreateMergeTest: TestDefinition = { description: - "Both clients create doc.md with different content while offline. " + - "Client 0 also edits the file before syncing. After both connect, " + - "the merged result should contain content from both clients.", + "Client 1 creates doc.md. Client 0 creates the same file offline, then connects with the server paused. " + + "Client 0 edits the file while the create is stalled. After resume, both clients' content must be merged.", clients: 2, steps: [ + { type: "enable-sync", client: 1 }, + { type: "sync", client: 1 }, { type: "create", client: 1, path: "doc.md", content: "from-client-1" }, + { type: "sync", client: 1 }, + { type: "create", client: 0, path: "doc.md", content: "from-client-0" }, + + { type: "pause-server" }, + + { type: "enable-sync", client: 0 }, + { type: "update", client: 0, @@ -21,19 +29,12 @@ export const localEditLostDuringCreateMergeTest: TestDefinition = { content: "local-edit-during-create" }, - { type: "enable-sync", client: 1 }, - { type: "sync", client: 1 }, - { type: "enable-sync", client: 0 }, + { type: "resume-server" }, + + { type: "sync" }, + { type: "sync" }, { type: "barrier" }, - { - type: "assert-consistent", - verify: (s) => - s.assertFileCount(1).assertContains( - "doc.md", - "from-client-1", - "local-edit-during-create" - ), - } + { type: "assert-consistent", verify: (s) => s.assertFileCount(1).assertContains("doc.md", "from-client-1", "local-edit-during-create") } ] }; diff --git a/frontend/deterministic-tests/src/tests/offline-delete-remote-rename.test.ts b/frontend/deterministic-tests/src/tests/offline-delete-remote-rename.test.ts index ed242b20..bf144048 100644 --- a/frontend/deterministic-tests/src/tests/offline-delete-remote-rename.test.ts +++ b/frontend/deterministic-tests/src/tests/offline-delete-remote-rename.test.ts @@ -7,8 +7,10 @@ export const offlineDeleteRemoteRenameTest: TestDefinition = { clients: 2, steps: [ { type: "create", client: 0, path: "A.md", content: "content-a" }, + { type: "create", client: 0, path: "B.md", content: "content-b" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, + { type: "sync" }, { type: "barrier" }, { type: "disable-sync", client: 0 }, @@ -23,13 +25,17 @@ export const offlineDeleteRemoteRenameTest: TestDefinition = { { type: "sync", client: 1 }, { type: "enable-sync", client: 0 }, + { type: "sync" }, { type: "barrier" }, { type: "assert-consistent", verify: (s) => { s.assertFileNotExists("A.md") - .assertFileNotExists("A_renamed.md"); + .assertContent("B.md", "content-b"); + s.ifFileExists("A_renamed.md", (s) => + s.assertContent("A_renamed.md", "content-a") + ); } } ] diff --git a/frontend/deterministic-tests/src/tests/online-both-create-same-path-deconflict.test.ts b/frontend/deterministic-tests/src/tests/online-both-create-same-path-deconflict.test.ts deleted file mode 100644 index 1639ed90..00000000 --- a/frontend/deterministic-tests/src/tests/online-both-create-same-path-deconflict.test.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { TestDefinition } from "../test-definition"; - -export const onlineBothCreateSamePathDeconflictTest: TestDefinition = { - description: - "Both clients create a file at the same path while online. " + - "One client's create gets deconflicted by the server. " + - "Both files must exist on both clients after convergence.", - clients: 2, - steps: [ - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - { type: "barrier" }, - - { type: "pause-websocket", client: 1 }, - { type: "create", client: 0, path: "A.md", content: " from-client-0 " }, - { type: "update", client: 0, path: "A.md", content: " updated-by-0 " }, - { type: "sync" }, - - { type: "create", client: 1, path: "A.md", content: " from-client-1 " }, - { type: "resume-websocket", client: 1 }, - - { type: "barrier" }, - - { - type: "assert-consistent", - verify: (state) => { - state - .assertFileCount(1) - .assertContains("A.md", "updated-by-0", "from-client-1 "); - } - } - ] -}; diff --git a/frontend/deterministic-tests/src/tests/online-create-update-while-other-creates-same-path.test.ts b/frontend/deterministic-tests/src/tests/online-create-update-while-other-creates-same-path.test.ts deleted file mode 100644 index f59a92e3..00000000 --- a/frontend/deterministic-tests/src/tests/online-create-update-while-other-creates-same-path.test.ts +++ /dev/null @@ -1,29 +0,0 @@ -import type { TestDefinition } from "../test-definition"; - -export const onlineCreateUpdateWhileOtherCreatesSamePathTest: TestDefinition = { - description: - "Client 0 creates a binary file and updates it while client 1 also " + - "creates a binary file at the same path. Both clients are online. " + - "Both clients must end up with the same file set.", - clients: 2, - steps: [ - { type: "enable-sync", client: 0 }, - { type: "enable-sync", client: 1 }, - - { type: "pause-websocket", client: 1 }, - { type: "create", client: 0, path: "data.bin", content: "BINARY:content-v1" }, - { type: "update", client: 0, path: "data.bin", content: "BINARY:content-v2" }, - { type: "create", client: 1, path: "data.bin", content: "BINARY:other-content" }, - { type: "resume-websocket", client: 1 }, - - { type: "barrier" }, - - { - type: "assert-consistent", verify: (state) => { - state.assertFileCount(2) - .assertContains("data.bin", "content-v2") - .assertContains("data (1).bin", "other-content"); - } - } - ] -}; diff --git a/frontend/deterministic-tests/src/tests/queue-reset-loses-coalesced-local-edit.test.ts b/frontend/deterministic-tests/src/tests/queue-reset-loses-coalesced-local-edit.test.ts index ecf58d05..181f256c 100644 --- a/frontend/deterministic-tests/src/tests/queue-reset-loses-coalesced-local-edit.test.ts +++ b/frontend/deterministic-tests/src/tests/queue-reset-loses-coalesced-local-edit.test.ts @@ -2,29 +2,31 @@ import type { TestDefinition } from "../test-definition"; export const queueResetLosesCoalescedLocalEditTest: TestDefinition = { description: - "Client 0 goes offline, both clients edit doc.md concurrently, " + - "then client 0 reconnects. Both edits must be preserved.", + "Client 1 edits a shared file, then client 0 also edits it and immediately disconnects. " + + "After client 0 reconnects, both edits must be preserved.", clients: 2, steps: [ { type: "create", client: 0, path: "doc.md", content: "original" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, + { type: "sync" }, { type: "barrier" }, + { type: "update", client: 1, path: "doc.md", content: "from client 1" }, + { type: "sync", client: 1 }, + + { type: "update", client: 0, path: "doc.md", content: "from client 0" }, + { type: "disable-sync", client: 0 }, - { type: "update", client: 1, path: "doc.md", content: "alpha bravo" }, - { type: "sync", client: 1 }, - - { type: "update", client: 0, path: "doc.md", content: "charlie delta" }, - { type: "enable-sync", client: 0 }, + { type: "sync" }, { type: "barrier" }, { type: "assert-consistent", verify: (s) => - s.assertFileCount(1).assertContains("doc.md", "alpha", "charlie"), + s.assertFileCount(1).assertContains("doc.md", "from client 0", "from client 1"), } ] }; diff --git a/frontend/deterministic-tests/src/tests/rapid-create-update-delete-cycle.test.ts b/frontend/deterministic-tests/src/tests/rapid-create-update-delete-cycle.test.ts index 45f90144..cc011dc0 100644 --- a/frontend/deterministic-tests/src/tests/rapid-create-update-delete-cycle.test.ts +++ b/frontend/deterministic-tests/src/tests/rapid-create-update-delete-cycle.test.ts @@ -27,9 +27,6 @@ export const rapidCreateUpdateDeleteCycleTest: TestDefinition = { }, { type: "delete", client: 0, path: "cycle.md" }, - { type: "resume-server" }, - { type: "sync" }, - { type: "create", client: 0, @@ -37,6 +34,8 @@ export const rapidCreateUpdateDeleteCycleTest: TestDefinition = { content: "final creation" }, + { type: "resume-server" }, + { type: "sync" }, { type: "barrier" }, { diff --git a/frontend/deterministic-tests/src/tests/recently-deleted-cleared-on-reconnect.test.ts b/frontend/deterministic-tests/src/tests/recently-deleted-cleared-on-reconnect.test.ts index 128cd90e..d8d0cf21 100644 --- a/frontend/deterministic-tests/src/tests/recently-deleted-cleared-on-reconnect.test.ts +++ b/frontend/deterministic-tests/src/tests/recently-deleted-cleared-on-reconnect.test.ts @@ -9,21 +9,24 @@ export const recentlyDeletedClearedOnReconnectTest: TestDefinition = { steps: [ { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, + { type: "sync" }, + { type: "barrier" }, { type: "create", client: 0, path: "doc.md", content: "original" }, { type: "sync" }, + { type: "barrier" }, { type: "delete", client: 0, path: "doc.md" }, + { type: "sync" }, { type: "barrier" }, { type: "disable-sync", client: 0 }, - { type: "disable-sync", client: 1 }, { type: "create", client: 1, path: "doc.md", content: "new content from client 1" }, - - { type: "enable-sync", client: 1 }, { type: "sync", client: 1 }, + { type: "enable-sync", client: 0 }, + { type: "sync" }, { type: "barrier" }, { diff --git a/frontend/deterministic-tests/src/tests/rename-circular.test.ts b/frontend/deterministic-tests/src/tests/rename-circular.test.ts index 5c85ca71..233b5c86 100644 --- a/frontend/deterministic-tests/src/tests/rename-circular.test.ts +++ b/frontend/deterministic-tests/src/tests/rename-circular.test.ts @@ -2,7 +2,7 @@ import type { TestDefinition } from "../test-definition"; export const renameCircularTest: TestDefinition = { description: - "Client 0 creates three files, syncs, then goes offline and performs a circular rename via a temp file (A->temp, C->A, B->C, temp->B). After reconnecting, all three contents should exist across three files but paths may be deconflicted.", + "Client 0 creates three files, syncs, then goes offline and performs a circular rename via a temp file (A->temp, C->A, B->C, temp->B). After reconnecting, both clients should have rotated content with no temp file remaining.", clients: 2, steps: [ { type: "create", client: 0, path: "A.md", content: "content-a" }, @@ -10,6 +10,7 @@ export const renameCircularTest: TestDefinition = { { type: "create", client: 0, path: "C.md", content: "content-c" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, + { type: "sync" }, { type: "barrier" }, { type: "assert-consistent", diff --git a/frontend/deterministic-tests/src/tests/rename-swap.test.ts b/frontend/deterministic-tests/src/tests/rename-swap.test.ts index 18489f33..1cd9c93c 100644 --- a/frontend/deterministic-tests/src/tests/rename-swap.test.ts +++ b/frontend/deterministic-tests/src/tests/rename-swap.test.ts @@ -4,14 +4,15 @@ export const renameSwapTest: TestDefinition = { description: "Client 0 has A.md and B.md synced. Goes offline and swaps them using " + "a temp file: A.md -> temp.md, B.md -> A.md, temp.md -> B.md. " + - "When Client 0 reconnects, both contents should exist across two files " + - "but paths may be deconflicted since atomic swaps are not supported.", + "When Client 0 reconnects, both clients should have swapped content. " + + "The temp file should not exist on either client.", clients: 2, steps: [ { type: "create", client: 0, path: "A.md", content: "content-a" }, { type: "create", client: 0, path: "B.md", content: "content-b" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, + { type: "sync" }, { type: "barrier" }, { type: "assert-consistent", @@ -25,6 +26,7 @@ export const renameSwapTest: TestDefinition = { { type: "rename", client: 0, oldPath: "temp.md", newPath: "B.md" }, { type: "enable-sync", client: 0 }, + { type: "sync" }, { type: "barrier" }, { @@ -32,7 +34,6 @@ export const renameSwapTest: TestDefinition = { verify: (s) => s .assertFileNotExists("temp.md") - .assertFileCount(2) .assertContent("A.md", "content-b") .assertContent("B.md", "content-a"), } diff --git a/frontend/deterministic-tests/src/tests/rename-to-path-of-unconfirmed-delete.test.ts b/frontend/deterministic-tests/src/tests/rename-to-path-of-unconfirmed-delete.test.ts index b5745e3b..543599bb 100644 --- a/frontend/deterministic-tests/src/tests/rename-to-path-of-unconfirmed-delete.test.ts +++ b/frontend/deterministic-tests/src/tests/rename-to-path-of-unconfirmed-delete.test.ts @@ -2,10 +2,7 @@ import type { TestDefinition } from "../test-definition"; export const renameToPathOfUnconfirmedDeleteTest: TestDefinition = { description: - "Client 0 deletes A.md then renames B.md to A.md. After syncing, " + - "B's content should exist and the old A.md content should be gone. " + - "The server may deconflict the path if the delete and move arrive " + - "in the same transaction.", + "Client 0 deletes A.md and renames B.md to A.md while offline. After reconnecting, A.md should exist with B's content and B.md should be gone.", clients: 2, steps: [ { @@ -23,19 +20,24 @@ export const renameToPathOfUnconfirmedDeleteTest: TestDefinition = { { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "sync" }, - - { type: "delete", client: 0, path: "A.md" }, { type: "barrier" }, + { type: "disable-sync", client: 0 }, + + { type: "delete", client: 0, path: "A.md" }, { type: "rename", client: 0, oldPath: "B.md", newPath: "A.md" }, + + { type: "enable-sync", client: 0 }, + { type: "sync" }, { type: "barrier" }, { type: "assert-consistent", verify: (s) => s + .assertFileCount(1) .assertFileNotExists("B.md") - .assertContains("A.md", "content B"), + .assertContent("A.md", "content B"), } ] }; diff --git a/frontend/deterministic-tests/src/tests/three-client-rename-create-delete.test.ts b/frontend/deterministic-tests/src/tests/three-client-rename-create-delete.test.ts index 174bcdc4..d434dde3 100644 --- a/frontend/deterministic-tests/src/tests/three-client-rename-create-delete.test.ts +++ b/frontend/deterministic-tests/src/tests/three-client-rename-create-delete.test.ts @@ -2,7 +2,7 @@ import type { TestDefinition } from "../test-definition"; export const threeClientRenameCreateDeleteTest: TestDefinition = { description: - "Client 0 renames X -> Y, Client 1 deletes X, Client 2 creates Y. " + + "Client 0 renames X→Y, Client 1 deletes X, Client 2 creates Y. " + "All three operations happen while the other clients are offline. " + "Tests that the system handles the three-way conflict and converges.", clients: 3, @@ -16,6 +16,7 @@ export const threeClientRenameCreateDeleteTest: TestDefinition = { { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, { type: "enable-sync", client: 2 }, + { type: "sync" }, { type: "barrier" }, { type: "disable-sync", client: 0 }, @@ -40,6 +41,7 @@ export const threeClientRenameCreateDeleteTest: TestDefinition = { { type: "sync", client: 1 }, { type: "enable-sync", client: 2 }, + { type: "sync" }, { type: "barrier" }, { @@ -47,7 +49,7 @@ export const threeClientRenameCreateDeleteTest: TestDefinition = { verify: (s) => s .assertFileNotExists("X.md") - .assertAnyFileContains("new from C"), + .assertContains("Y.md", "original from A", "new from C"), } ] }; diff --git a/frontend/deterministic-tests/src/tests/update-survives-remote-delete.test.ts b/frontend/deterministic-tests/src/tests/update-survives-remote-delete.test.ts index 09ec9427..5bc713ba 100644 --- a/frontend/deterministic-tests/src/tests/update-survives-remote-delete.test.ts +++ b/frontend/deterministic-tests/src/tests/update-survives-remote-delete.test.ts @@ -1,13 +1,14 @@ import type { TestDefinition } from "../test-definition"; -export const updateDoesNotSurvivesRemoteDeleteTest: TestDefinition = { +export const updateSurvivesRemoteDeleteTest: TestDefinition = { description: - "Client 0 deletes a file while client 1 edits it offline. Client 0 syncs the delete first, then client 1 reconnects. Deletes always win.", + "Client 0 deletes a file while client 1 edits it offline. Client 0 syncs the delete first, then client 1 reconnects. The edited file should survive on both clients.", clients: 2, steps: [ { type: "create", client: 0, path: "doc.md", content: "original" }, { type: "enable-sync", client: 0 }, { type: "enable-sync", client: 1 }, + { type: "sync" }, { type: "barrier" }, { type: "disable-sync", client: 0 }, @@ -17,13 +18,16 @@ export const updateDoesNotSurvivesRemoteDeleteTest: TestDefinition = { { type: "update", client: 1, path: "doc.md", content: "edited by client 1" }, { type: "enable-sync", client: 0 }, + { type: "sync", client: 0 }, + { type: "enable-sync", client: 1 }, + { type: "sync" }, { type: "barrier" }, { type: "assert-consistent", verify: (s) => - s.assertFileCount(0) + s.assertFileCount(1).assertContains("doc.md", "edited by client 1"), }, ], }; diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index 61a8bade..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,7 +17,6 @@ export default [ }, extends: [eslint.configs.recommended, tseslint.configs.all], rules: { - "no-console": "error", "no-unused-vars": "off", "@typescript-eslint/restrict-template-expressions": "off", "@typescript-eslint/no-unused-vars": "off", 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 c1c82c29..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 807b4f8d..00000000 --- a/frontend/history-ui/src/components/Dashboard.svelte +++ /dev/null @@ -1,511 +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 556a5e8d..00000000 --- a/frontend/history-ui/src/components/DocumentDetail.svelte +++ /dev/null @@ -1,712 +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 ec72cbf9..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 79e9e5de..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 1bd64165..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 6d52a0f7..00000000 --- a/frontend/history-ui/src/lib/api.ts +++ /dev/null @@ -1,121 +0,0 @@ -import type { - DocumentVersion, - DocumentVersionWithoutContent, - FetchLatestDocumentsResponse, - ListVaultsResponse, - PingResponse, - VaultHistoryResponse -} from "./types"; - -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: this.headers() } - ); - 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}` : ""}` - ); - } - - async restoreVersion( - documentId: string, - vaultUpdateId: number - ): Promise { - return this.fetchJson( - `${this.baseUrl}/documents/${documentId}/restore`, - { - method: "POST", - headers: { - "Content-Type": "application/json" - }, - body: JSON.stringify({ vaultUpdateId }) - } - ); - } -} 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 fcba5340..00000000 --- a/frontend/history-ui/src/lib/stores.svelte.ts +++ /dev/null @@ -1,305 +0,0 @@ -import { ApiClient } from "./api"; -import type { - DocumentVersionWithoutContent, - VaultInfo, - VersionEvent, - ActionType, - TreeNode -} from "./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 -export 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 bb629100..00000000 --- a/frontend/history-ui/src/lib/types/ClientCursors.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 { DocumentWithCursors } from "./DocumentWithCursors"; - -export type ClientCursors = { userName: string, deviceId: string, documentsWithCursors: Array, }; diff --git a/frontend/history-ui/src/lib/types/CreateDocumentVersion.ts b/frontend/history-ui/src/lib/types/CreateDocumentVersion.ts deleted file mode 100644 index 86ba60f3..00000000 --- a/frontend/history-ui/src/lib/types/CreateDocumentVersion.ts +++ /dev/null @@ -1,3 +0,0 @@ -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. - -export type CreateDocumentVersion = { relative_path: string, content: 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 60b48e5e..00000000 --- a/frontend/history-ui/src/lib/types/CursorPositionFromClient.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 { 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 c8444892..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/CursorSpan.ts b/frontend/history-ui/src/lib/types/CursorSpan.ts deleted file mode 100644 index d0bce6ea..00000000 --- a/frontend/history-ui/src/lib/types/CursorSpan.ts +++ /dev/null @@ -1,3 +0,0 @@ -// 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, }; 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 418117e6..00000000 --- a/frontend/history-ui/src/lib/types/DocumentUpdateResponse.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 { DocumentVersion } from "./DocumentVersion"; -import type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent"; - -/** - * Response to an 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 37bd32ca..00000000 --- a/frontend/history-ui/src/lib/types/DocumentVersion.ts +++ /dev/null @@ -1,3 +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 03be2f63..00000000 --- a/frontend/history-ui/src/lib/types/DocumentVersionWithoutContent.ts +++ /dev/null @@ -1,3 +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, }; 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 38857a35..00000000 --- a/frontend/history-ui/src/lib/types/DocumentWithCursors.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 { CursorSpan } from "./CursorSpan"; - -export type DocumentWithCursors = { vault_update_id: number | null, document_id: string, relative_path: 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 ce572684..00000000 --- a/frontend/history-ui/src/lib/types/FetchLatestDocumentsResponse.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 { 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 92b2b3e0..00000000 --- a/frontend/history-ui/src/lib/types/ListVaultsResponse.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 { 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 c38845d2..00000000 --- a/frontend/history-ui/src/lib/types/PingResponse.ts +++ /dev/null @@ -1,24 +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 5e3fa9b9..00000000 --- a/frontend/history-ui/src/lib/types/SerializedError.ts +++ /dev/null @@ -1,3 +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 458fc2bb..00000000 --- a/frontend/history-ui/src/lib/types/UpdateTextDocumentVersion.ts +++ /dev/null @@ -1,3 +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, 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 ae91b480..00000000 --- a/frontend/history-ui/src/lib/types/VaultHistoryResponse.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 { 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 32373346..00000000 --- a/frontend/history-ui/src/lib/types/VaultInfo.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. - -/** - * 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 5765a0d0..00000000 --- a/frontend/history-ui/src/lib/types/WebSocketClientMessage.ts +++ /dev/null @@ -1,5 +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 85c2cf0d..00000000 --- a/frontend/history-ui/src/lib/types/WebSocketHandshake.ts +++ /dev/null @@ -1,3 +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 45e37358..00000000 --- a/frontend/history-ui/src/lib/types/WebSocketServerMessage.ts +++ /dev/null @@ -1,5 +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 b627ac3c..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 = { documents: Array, isInitialSync: boolean, }; diff --git a/frontend/history-ui/src/lib/types/index.ts b/frontend/history-ui/src/lib/types/index.ts deleted file mode 100644 index a2c2b346..00000000 --- a/frontend/history-ui/src/lib/types/index.ts +++ /dev/null @@ -1,28 +0,0 @@ -export type { DocumentVersion } from "./DocumentVersion"; -export type { DocumentVersionWithoutContent } from "./DocumentVersionWithoutContent"; -export type { FetchLatestDocumentsResponse } from "./FetchLatestDocumentsResponse"; -export type { ListVaultsResponse } from "./ListVaultsResponse"; -export type { PingResponse } from "./PingResponse"; -export type { VaultInfo } from "./VaultInfo"; -export type { VaultHistoryResponse } from "./VaultHistoryResponse"; - -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/file-watcher.ts b/frontend/local-client-cli/src/file-watcher.ts index 2e70df02..16f397c5 100644 --- a/frontend/local-client-cli/src/file-watcher.ts +++ b/frontend/local-client-cli/src/file-watcher.ts @@ -69,23 +69,47 @@ export class FileWatcher { } private handleCreate(relativePath: RelativePath): void { - this.client.syncLocallyCreatedFile(relativePath); + this.client + .syncLocallyCreatedFile(relativePath) + .catch((err: unknown) => { + this.client.logger.error( + `Failed to sync created file ${relativePath}: ${this.formatError(err)}` + ); + }); } private handleChange(relativePath: RelativePath): void { - this.client.syncLocallyUpdatedFile({ relativePath }); + this.client + .syncLocallyUpdatedFile({ relativePath }) + .catch((err: unknown) => { + this.client.logger.error( + `Failed to sync updated file ${relativePath}: ${this.formatError(err)}` + ); + }); } private handleDelete(relativePath: RelativePath): void { - this.client.syncLocallyDeletedFile(relativePath); + this.client + .syncLocallyDeletedFile(relativePath) + .catch((err: unknown) => { + this.client.logger.error( + `Failed to sync deleted file ${relativePath}: ${this.formatError(err)}` + ); + }); } private handleRename(oldPath: RelativePath, newPath: RelativePath): void { this.client.logger.info(`File renamed: ${oldPath} -> ${newPath}`); - this.client.syncLocallyUpdatedFile({ - oldPath, - relativePath: newPath - }); + this.client + .syncLocallyUpdatedFile({ + oldPath, + relativePath: newPath + }) + .catch((err: unknown) => { + this.client.logger.error( + `Failed to sync renamed file ${oldPath} -> ${newPath}: ${this.formatError(err)}` + ); + }); } private toRelativePath(absolutePath: string): RelativePath { diff --git a/frontend/obsidian-plugin/README.md b/frontend/obsidian-plugin/README.md index 68e10a83..93c2cba7 100644 --- a/frontend/obsidian-plugin/README.md +++ b/frontend/obsidian-plugin/README.md @@ -8,7 +8,6 @@ The repo depends on the latest plugin API (obsidian.d.ts) in TypeScript Definiti **Note:** The Obsidian API is still in early alpha and is subject to change at any time! This sample plugin demonstrates some of the basic functionality the plugin API can do. - - Adds a ribbon icon, which shows a Notice when clicked. - Adds a command "Open Sample Modal" which opens a Modal. - Adds a plugin setting tab to the settings page. @@ -58,6 +57,31 @@ Quick starting guide for new plugin devs: - Copy over `main.js`, `styles.css`, `manifest.json` to your vault `VaultFolder/.obsidian/plugins/your-plugin-id/`. + +## Funding URL + +You can include funding URLs where people who use your plugin can financially support it. + +The simple way is to set the `fundingUrl` field to your link in your `manifest.json` file: + +```json +{ + "fundingUrl": "https://buymeacoffee.com" +} +``` + +If you have multiple URLs, you can also do: + +```json +{ + "fundingUrl": { + "Buy Me a Coffee": "https://buymeacoffee.com", + "GitHub Sponsor": "https://github.com/sponsors", + "Patreon": "https://www.patreon.com/" + } +} +``` + ## API Documentation See https://github.com/obsidianmd/obsidian-api diff --git a/frontend/obsidian-plugin/package.json b/frontend/obsidian-plugin/package.json index d24e537b..b7ae4909 100644 --- a/frontend/obsidian-plugin/package.json +++ b/frontend/obsidian-plugin/package.json @@ -13,25 +13,25 @@ "author": "", "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" } } diff --git a/frontend/obsidian-plugin/src/vault-link-plugin.ts b/frontend/obsidian-plugin/src/vault-link-plugin.ts index 0291e646..7d91b9f5 100644 --- a/frontend/obsidian-plugin/src/vault-link-plugin.ts +++ b/frontend/obsidian-plugin/src/vault-link-plugin.ts @@ -135,14 +135,14 @@ export default class VaultLinkPlugin extends Plugin { nativeLineEndings: Platform.isWin ? "\r\n" : "\n", ...(IS_DEBUG_BUILD ? { - fetch: debugging.slowFetchFactory(1), - webSocket: debugging.slowWebSocketFactory(1, new Logger()) - } + fetch: debugging.slowFetchFactory(1), + webSocket: debugging.slowWebSocketFactory(1, new Logger()) + } : {}) }); if (IS_DEBUG_BUILD) { - debugging.logToConsole(client.logger); + debugging.logToConsole(client); } return client; @@ -269,9 +269,9 @@ export default class VaultLinkPlugin extends Plugin { path, rateLimit( async () => - { client.syncLocallyUpdatedFile({ + client.syncLocallyUpdatedFile({ relativePath: path - }); }, + }), MIN_WAIT_BETWEEN_UPDATES_IN_MS ) ); diff --git a/frontend/obsidian-plugin/src/views/cursors/remote-cursors-plugin.ts b/frontend/obsidian-plugin/src/views/cursors/remote-cursors-plugin.ts index d6650dcb..1191d9a2 100644 --- a/frontend/obsidian-plugin/src/views/cursors/remote-cursors-plugin.ts +++ b/frontend/obsidian-plugin/src/views/cursors/remote-cursors-plugin.ts @@ -132,8 +132,7 @@ export class RemoteCursorsPluginValue implements PluginValue { ] ) }, - edited, - "Markdown" + edited ); reconciled.cursors.forEach(({ id, position }) => { diff --git a/frontend/obsidian-plugin/src/views/settings/settings-tab.ts b/frontend/obsidian-plugin/src/views/settings/settings-tab.ts index a0c81522..213c0d2c 100644 --- a/frontend/obsidian-plugin/src/views/settings/settings-tab.ts +++ b/frontend/obsidian-plugin/src/views/settings/settings-tab.ts @@ -266,8 +266,9 @@ export class SyncSettingsTab extends PluginSettingTab { new Notice("Checking connection to the server..."); new Notice( - (await this.syncClient.checkConnection()) - .serverMessage + ( + await this.syncClient.checkConnection() + ).serverMessage ); await this.statusDescription.updateConnectionState(); } else { @@ -350,6 +351,22 @@ export class SyncSettingsTab extends PluginSettingTab { }) ); + new Setting(containerEl) + .setName("Sync concurrency") + .setDesc( + "How many concurrent sync operations to run. Setting this value higher may increase the overall performance, however, it will require more memory as well. If you notice frequent crashes, especially on mobile, set this to 1." + ) + .addSlider((text) => + text + .setLimits(1, 16, 1) + .setDynamicTooltip() + .setInstant(false) + .setValue(this.syncClient.getSettings().syncConcurrency) + .onChange(async (value) => + this.syncClient.setSetting("syncConcurrency", value) + ) + ); + new Setting(containerEl) .setName("Maximum file size to be uploaded (MB)") .setDesc( 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..6d957652 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,9 +5,7 @@ "sync-client", "obsidian-plugin", "test-client", - "deterministic-tests", - "local-client-cli", - "history-ui" + "local-client-cli" ], "prettier": { "trailingComma": "none", @@ -31,15 +29,16 @@ "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" + "lint": "eslint --fix sync-client obsidian-plugin test-client local-client-cli && prettier --write \"**/*.ts\"", + "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 9e4fa7d2..da70ba47 100644 --- a/frontend/sync-client/src/consts.ts +++ b/frontend/sync-client/src/consts.ts @@ -2,6 +2,5 @@ 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/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 b5851a3e..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,6 +1,9 @@ import { describe, it } from "node:test"; -import type { DocumentId, DocumentRecord, RelativePath } from "../sync-operations/types"; -import type { SyncEventQueue } from "../sync-operations/sync-event-queue"; +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"; @@ -18,18 +21,19 @@ class MockServerConfig implements Pick { } } -class MockQueue implements Pick { - public getDocumentByPath( - _path: RelativePath +class MockDatabase implements Partial { + public getLatestDocumentByRelativePath( + _find: RelativePath ): DocumentRecord | undefined { + // no-op return undefined; } - public moveDocument( - _oldPath: RelativePath, - _newPath: RelativePath - ): DocumentId | undefined { - return undefined; + public move( + _oldRelativePath: RelativePath, + _newRelativePath: RelativePath + ): void { + // no-op } } @@ -85,7 +89,7 @@ describe("File operations", () => { const fileSystemOperations = new FakeFileSystemOperations(); const fileOperations = new FileOperations( new Logger(), - new MockQueue() as SyncEventQueue, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + 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 ); @@ -115,7 +119,7 @@ describe("File operations", () => { const fileSystemOperations = new FakeFileSystemOperations(); const fileOperations = new FileOperations( new Logger(), - new MockQueue() as SyncEventQueue, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + 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 ); @@ -155,7 +159,7 @@ describe("File operations", () => { const fileSystemOperations = new FakeFileSystemOperations(); const fileOperations = new FileOperations( new Logger(), - new MockQueue() as SyncEventQueue, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + 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 ); @@ -174,7 +178,7 @@ describe("File operations", () => { const fileSystemOperations = new FakeFileSystemOperations(); const fileOperations = new FileOperations( new Logger(), - new MockQueue() as SyncEventQueue, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + 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 ); @@ -203,7 +207,7 @@ describe("File operations", () => { const fileSystemOperations = new FakeFileSystemOperations(); const fileOperations = new FileOperations( new Logger(), - new MockQueue() as SyncEventQueue, // eslint-disable-line @typescript-eslint/no-unsafe-type-assertion + 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 ); diff --git a/frontend/sync-client/src/file-operations/file-operations.ts b/frontend/sync-client/src/file-operations/file-operations.ts index 8a9b10ba..2864bd20 100644 --- a/frontend/sync-client/src/file-operations/file-operations.ts +++ b/frontend/sync-client/src/file-operations/file-operations.ts @@ -1,7 +1,6 @@ import type { Logger } from "../tracing/logger"; import type { FileSystemOperations } from "./filesystem-operations"; -import type { RelativePath } from "../sync-operations/types"; -import type { SyncEventQueue } from "../sync-operations/sync-event-queue"; +import type { Database, RelativePath } from "../persistence/database"; import { SafeFileSystemOperations } from "./safe-filesystem-operations"; import type { TextWithCursors } from "reconcile-text"; import { reconcile } from "reconcile-text"; @@ -15,7 +14,7 @@ export class FileOperations { public constructor( private readonly logger: Logger, - private readonly queue: SyncEventQueue, + private readonly database: Database, fs: FileSystemOperations, private readonly serverConfig: ServerConfig, private readonly nativeLineEndings = "\n" @@ -46,11 +45,11 @@ export class FileOperations { } /** - * 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. - */ + * 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 @@ -59,10 +58,7 @@ export class FileOperations { return this.fs.write(path, this.toNativeLineEndings(newContent)); } - // Returns the deconflicted path if a file was moved, undefined otherwise - public async ensureClearPath( - path: RelativePath - ): Promise { + public async ensureClearPath(path: RelativePath): Promise { if (await this.fs.exists(path)) { const deconflictedPath = await this.deconflictPath(path); try { @@ -70,24 +66,22 @@ export class FileOperations { `Didn't expect ${path} to exist, deconflicting by moving it to '${deconflictedPath}'` ); - this.queue.moveDocument(path, deconflictedPath); + this.database.move(path, deconflictedPath); await this.fs.rename(path, deconflictedPath, true); - return deconflictedPath; } finally { this.fs.unlock(deconflictedPath); } } else { await this.createParentDirectories(path); } - return undefined; } /** - * 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, @@ -166,24 +160,21 @@ export class FileOperations { return this.fs.exists(path); } - // Returns the deconflicted path if a file at the target was displaced public async move( oldPath: RelativePath, newPath: RelativePath - ): Promise { + ): Promise { if (oldPath === newPath) { - return undefined; + return; } - const deconflictedPath = await this.ensureClearPath(newPath); - this.queue.moveDocument(oldPath, newPath); + await this.ensureClearPath(newPath); + + this.database.move(oldPath, newPath); await this.fs.rename(oldPath, newPath); - await this.deletingEmptyParentDirectoriesOfDeletedFile(oldPath); - return deconflictedPath; } - public reset(): void { this.fs.reset(); } @@ -248,12 +239,12 @@ 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 - */ + * 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); @@ -283,15 +274,17 @@ export class FileOperations { newName = `${directory}${stem} (${currentCount})${extension}`; // Avoid multiple deconflictPath calls returning the same path - await this.fs.waitForLock(newName); - const existingRecord = this.queue.getSettledDocumentByPath(newName); - if ( - existingRecord !== undefined || // 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; + 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 8c297920..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,8 +1,8 @@ -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 { Locks } from "../utils/data-structures/locks"; -import { FileNotFoundError } from "../errors/file-not-found-error"; +import { FileNotFoundError } from "./file-not-found-error"; import type { TextWithCursors } from "reconcile-text"; /** @@ -17,7 +17,7 @@ export class SafeFileSystemOperations implements FileSystemOperations { private readonly fs: FileSystemOperations, private readonly logger: Logger ) { - this.locks = new Locks(SafeFileSystemOperations.name, logger); + this.locks = new Locks(logger); } public async listFilesRecursively( @@ -135,10 +135,10 @@ export class SafeFileSystemOperations implements FileSystemOperations { } /** - * 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, diff --git a/frontend/sync-client/src/index.ts b/frontend/sync-client/src/index.ts index da69f446..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,14 +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 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"; @@ -38,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 index 72f15fbd..86b2845c 100644 --- a/frontend/sync-client/src/persistence/database.ts +++ b/frontend/sync-client/src/persistence/database.ts @@ -1,2 +1,374 @@ -// This file is intentionally empty -// All document tracking has been moved to sync-event-queue.ts +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 9771b7f1..d78170e6 100644 --- a/frontend/sync-client/src/persistence/settings.ts +++ b/frontend/sync-client/src/persistence/settings.ts @@ -38,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, @@ -50,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/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 cf857dcd..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,35 +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); } } @@ -56,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()); @@ -69,32 +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 da804b2f..309c637c 100644 --- a/frontend/sync-client/src/services/server-config.ts +++ b/frontend/sync-client/src/services/server-config.ts @@ -1,6 +1,6 @@ import { SUPPORTED_API_VERSION } from "../consts"; -import { AuthenticationError } from "../errors/authentication-error"; -import { ServerVersionMismatchError } from "../errors/server-version-mismatch-error"; +import { AuthenticationError } from "./authentication-error"; +import { ServerVersionMismatchError } from "./server-version-mismatch-error"; import type { SyncService } from "./sync-service"; import type { PingResponse } from "./types/PingResponse"; @@ -34,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; 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 ad268814..8190a638 100644 --- a/frontend/sync-client/src/services/sync-service.ts +++ b/frontend/sync-client/src/services/sync-service.ts @@ -2,14 +2,13 @@ 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"; @@ -67,15 +66,19 @@ export class SyncService { } public async create({ + documentId, relativePath, contentBytes }: { + documentId?: DocumentId; relativePath: RelativePath; 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( "content", @@ -83,7 +86,7 @@ export class SyncService { ); 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"), { @@ -100,8 +103,8 @@ export class SyncService { ); } - 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)}`); @@ -140,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 @@ -187,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 @@ -402,10 +417,8 @@ export class SyncService { 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; } @@ -418,16 +431,4 @@ export class SyncService { } } } - - private static async throwIfNotOk( - response: Response, - operation: string - ): Promise { - if (response.ok) return; - const message = `Failed to ${operation}: ${await SyncService.errorFromResponse(response)}`; - if (response.status >= 400 && response.status < 500) { - throw new HttpClientError(response.status, message); - } - throw new Error(message); - } } diff --git a/frontend/sync-client/src/services/types/DeleteDocumentVersion.ts b/frontend/sync-client/src/services/types/DeleteDocumentVersion.ts index 99ecc9e7..f160406f 100644 --- a/frontend/sync-client/src/services/types/DeleteDocumentVersion.ts +++ b/frontend/sync-client/src/services/types/DeleteDocumentVersion.ts @@ -1,5 +1,3 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -export interface DeleteDocumentVersion { - relativePath: string; -} +export type DeleteDocumentVersion = Record; 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 85928d89..00000000 --- a/frontend/sync-client/src/services/types/ListVaultsResponse.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 { 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/VaultInfo.ts b/frontend/sync-client/src/services/types/VaultInfo.ts deleted file mode 100644 index 921645f3..00000000 --- a/frontend/sync-client/src/services/types/VaultInfo.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. - -/** - * 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/websocket-manager.test.ts b/frontend/sync-client/src/services/websocket-manager.test.ts index 3b61b5a1..fef901e7 100644 --- a/frontend/sync-client/src/services/websocket-manager.test.ts +++ b/frontend/sync-client/src/services/websocket-manager.test.ts @@ -4,6 +4,8 @@ import assert from "node:assert"; import { WebSocketManager } from "./websocket-manager"; import type { Logger } from "../tracing/logger"; import type { Settings } from "../persistence/settings"; +// eslint-disable-next-line @typescript-eslint/no-require-imports +const WebSocket = require("ws") as typeof globalThis.WebSocket; class MockCloseEvent extends Event { public code: number; @@ -89,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 }; @@ -112,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 @@ -141,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 @@ -170,6 +176,7 @@ describe("WebSocketManager", () => { it("logs handshake send errors", async () => { const manager = new WebSocketManager( + deviceId, mockLogger, mockSettings, MockWebSocket as unknown as typeof WebSocket @@ -198,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 @@ -212,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 @@ -248,6 +257,7 @@ describe("WebSocketManager", () => { 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 5cceec72..09787bce 100644 --- a/frontend/sync-client/src/services/websocket-manager.ts +++ b/frontend/sync-client/src/services/websocket-manager.ts @@ -4,11 +4,9 @@ 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"; @@ -29,20 +27,31 @@ export class WebSocketManager { private isStopped = true; private resolveDisconnectingPromise: null | (() => unknown) = 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 { @@ -58,7 +67,7 @@ export class WebSocketManager { } public async stop(): Promise { - const { promise, resolve } = Promise.withResolvers(); + const [promise, resolve] = createPromise(); this.resolveDisconnectingPromise = resolve; this.isStopped = true; @@ -68,11 +77,6 @@ export class WebSocketManager { 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 @@ -81,10 +85,10 @@ export class WebSocketManager { timeoutId = setTimeout(() => { reject( new Error( - `Timeout waiting for WebSocket to close after ${WEBSOCKET_DISCONNECT_TIMEOUT_IN_SECONDS} seconds` + `Timeout waiting for WebSocket to close after ${WEBSOCKET_DISCONNECT_TIMEOUT_IN_S} seconds` ) ); - }, WEBSOCKET_DISCONNECT_TIMEOUT_IN_SECONDS * 1000); + }, WEBSOCKET_DISCONNECT_TIMEOUT_IN_S * 1000); }); try { @@ -167,10 +171,7 @@ 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}` @@ -186,22 +187,7 @@ export class WebSocketManager { this.webSocket = new this.webSocketFactoryImplementation(wsUri); - // Set connection timeout to handle cases where server is down and the WebSocket connection won't open - 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 - this.webSocket?.close(1000, "Connection timeout"); - }, WEBSOCKET_CONNECTION_TIMEOUT_IN_SECONDS * 1000); - this.webSocket.onopen = (): void => { - if (this.connectionTimeoutId !== undefined) { - clearTimeout(this.connectionTimeoutId); - this.connectionTimeoutId = undefined; - } - // Check if we've been stopped while connecting if (this.isStopped) { this.webSocket?.close( @@ -245,18 +231,7 @@ export class WebSocketManager { } }; - this.webSocket.onerror = (error): void => { - this.logger.warn( - `WebSocket error occurred: ${error instanceof ErrorEvent ? error.message : "Unknown error"}` - ); - }; - this.webSocket.onclose = (event): void => { - if (this.connectionTimeoutId !== undefined) { - clearTimeout(this.connectionTimeoutId); - this.connectionTimeoutId = undefined; - } - this.logger.warn( `WebSocket closed with code ${event.code} (${event.reason == "" ? "unknown reason" : event.reason})` ); @@ -266,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); } }; } diff --git a/frontend/sync-client/src/sync-client.ts b/frontend/sync-client/src/sync-client.ts index 1a88c269..2a272c86 100644 --- a/frontend/sync-client/src/sync-client.ts +++ b/frontend/sync-client/src/sync-client.ts @@ -2,8 +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 { 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"; @@ -12,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"; @@ -28,6 +29,7 @@ import { ServerConfig } from "./services/server-config"; import type { EventListeners } from "./utils/data-structures/event-listeners"; export class SyncClient { + private hasStartedOfflineSync = false; private hasFinishedOfflineSync = false; private hasStarted = false; private hasBeenDestroyed = false; @@ -36,12 +38,12 @@ export class SyncClient { private readonly eventUnsubscribers: (() => void)[] = []; 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, @@ -51,13 +53,13 @@ export class SyncClient { private readonly persistence: PersistenceProvider< Partial<{ settings: Partial; - database: Partial; + database: Partial; }> > - ) { } + ) {} public get documentCount(): number { - return this.syncEventQueue.documentCount; + return this.database.length; } public get isWebSocketConnected(): boolean { @@ -110,7 +112,7 @@ export class SyncClient { persistence: PersistenceProvider< Partial<{ settings: Partial; - database: Partial; + database: Partial; }> >; fetch?: typeof globalThis.fetch; @@ -135,6 +137,8 @@ export class SyncClient { 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); } ); @@ -144,8 +148,7 @@ export class SyncClient { () => settings.getSettings().minimumSaveIntervalMs ); - const syncEventQueue = new SyncEventQueue( - settings, + const database = new Database( logger, state.database, async (data): Promise => { @@ -171,7 +174,7 @@ export class SyncClient { const fileOperations = new FileOperations( logger, - syncEventQueue, + database, fs, serverConfig, nativeLineEndings @@ -180,8 +183,19 @@ export class SyncClient { const contentCache = new FixedSizeDocumentCache( 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 @@ -190,31 +204,28 @@ 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, @@ -274,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"); @@ -309,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"); @@ -321,16 +332,16 @@ export class SyncClient { ); await this.pause(); + // clear all local state this.logger.info("Resetting SyncClient's local state"); - this.syncEventQueue.resetState(); - 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 { @@ -352,47 +363,40 @@ export class SyncClient { await this.settings.setSettings(value); } - public syncLocallyCreatedFile( + public async syncLocallyCreatedFile( relativePath: RelativePath - ): void { + ): Promise { this.checkIfDestroyed("syncLocallyCreatedFile"); this.fileChangeNotifier.notifyOfFileChange(relativePath); - this.syncer.syncLocallyCreatedFile(relativePath); + return this.syncer.syncLocallyCreatedFile(relativePath); } - public syncLocallyDeletedFile( + public async syncLocallyDeletedFile( relativePath: RelativePath - ): void { + ): Promise { this.checkIfDestroyed("syncLocallyDeletedFile"); this.fileChangeNotifier.notifyOfFileChange(relativePath); - this.syncer.syncLocallyDeletedFile(relativePath); + return this.syncer.syncLocallyDeletedFile(relativePath); } - public syncLocallyUpdatedFile({ + public async syncLocallyUpdatedFile({ oldPath, relativePath }: { oldPath?: RelativePath; relativePath: RelativePath; - }): void { + }): Promise { this.checkIfDestroyed("syncLocallyUpdatedFile"); this.fileChangeNotifier.notifyOfFileChange(relativePath); - this.syncer.syncLocallyUpdatedFile({ + return this.syncer.syncLocallyUpdatedFile({ oldPath, relativePath }); } - public get hasPendingWork(): boolean { - return ( - this.syncEventQueue.size > 0 || - this.webSocketManager.hasOutstandingWork - ); - } - public getDocumentSyncingStatus( relativePath: RelativePath ): DocumentSyncStatus { @@ -406,7 +410,12 @@ export class SyncClient { return DocumentSyncStatus.SYNCING; } - return this.syncer.hasPendingOperationsForDocument(relativePath) + const document = + this.database.getLatestDocumentByRelativePath(relativePath); + if (document === undefined) { + return DocumentSyncStatus.SYNCING; + } + return document.updates.length > 0 ? DocumentSyncStatus.SYNCING : DocumentSyncStatus.UP_TO_DATE; } @@ -423,16 +432,17 @@ export class SyncClient { this.checkIfDestroyed("waitUntilIdle"); await this.syncer.waitUntilFinished(); await this.webSocketManager.waitUntilFinished(); - await this.syncEventQueue.save(); + 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 { this.checkIfDestroyed("destroy"); + // Prevent concurrent destroy calls if (this.isDestroying) { this.logger.warn( "destroy() called while already destroying, ignoring" @@ -441,12 +451,14 @@ export class SyncClient { } this.isDestroying = true; + // cancel everything that's in progress await this.pause(); this.hasBeenDestroyed = true; this.resetInMemoryState(); + // Clean up event listeners to prevent memory leaks this.eventUnsubscribers.forEach((unsubscribe) => { unsubscribe(); }); @@ -461,16 +473,18 @@ export class SyncClient { this.checkIfDestroyed("startSyncing"); this.fetchController.finishReset(); - await this.serverConfig.getConfig(); - - await this.syncer.scheduleSyncForOfflineChanges(); + 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.fetchController.startReset(); await this.webSocketManager.stop(); await this.waitUntilFinished(); @@ -479,6 +493,7 @@ export class SyncClient { private resetInMemoryState(): void { this.history.reset(); this.contentCache.reset(); + // don't reset the logger this.cursorTracker.reset(); this.syncer.reset(); this.fileOperations.reset(); diff --git a/frontend/sync-client/src/sync-operations/cursor-tracker.ts b/frontend/sync-client/src/sync-operations/cursor-tracker.ts index 703f654c..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,7 +22,7 @@ export class CursorTracker { (cursors: MaybeOutdatedClientCursors[]) => unknown >(); - private readonly updateLock: Lock; + private readonly updateLock = new Lock(); private knownRemoteCursors: (ClientCursors & { upToDateness: DocumentUpToDateness; @@ -35,14 +33,11 @@ export class CursorTracker { []; public constructor( - private readonly 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 () => { @@ -105,16 +100,21 @@ export class CursorTracker { for (const [relativePath, cursors] of Object.entries( documentToCursors )) { - const record = this.queue.getSettledDocumentByPath(relativePath); + const record = + this.database.getLatestDocumentByRelativePath(relativePath); if (!record) { continue; // Let's wait for the file to be created before sending cursors } + if (!record.metadata) { + continue; // this is a new document, no need to sync the cursors + } + documentsWithCursors.push({ relative_path: relativePath, document_id: record.documentId, - vault_update_id: record.parentVersionId, + vault_update_id: record.metadata.parentVersionId, cursors: cursors.map(({ start, end }) => ({ start: Math.min(start, end), end: Math.max(start, end) @@ -135,8 +135,10 @@ export class CursorTracker { const readContent = await this.fileOperations.read( doc.relative_path ); - const record = this.queue.getSettledDocumentByPath(doc.relative_path); - if (record?.remoteHash !== (await hash(readContent))) { + const record = this.database.getLatestDocumentByRelativePath( + doc.relative_path + ); + if (record?.metadata?.hash !== hash(readContent)) { doc.vault_update_id = null; } } @@ -221,7 +223,9 @@ export class CursorTracker { private async getDocumentUpToDateness( document: DocumentWithCursors ): Promise { - const record = this.queue.getSettledDocumentByPath(document.relative_path); + const record = this.database.getLatestDocumentByRelativePath( + document.relative_path + ); if (!record) { // the document of the cursor must be from the future @@ -229,11 +233,13 @@ export class CursorTracker { } if ( - record.parentVersionId < (document.vault_update_id ?? 0) + (record.metadata?.parentVersionId ?? 0) < + (document.vault_update_id ?? 0) ) { return DocumentUpToDateness.Later; } else if ( - (document.vault_update_id ?? 0) < record.parentVersionId + (document.vault_update_id ?? 0) < + (record.metadata?.parentVersionId ?? 0) ) { // the document of the cursor must be from the past return DocumentUpToDateness.Prior; @@ -243,8 +249,9 @@ export class CursorTracker { document.relative_path ); - const currentRecord = this.queue.getSettledDocumentByPath(document.relative_path); - 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/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.ts b/frontend/sync-client/src/sync-operations/offline-change-detector.ts deleted file mode 100644 index 96b1126d..00000000 --- a/frontend/sync-client/src/sync-operations/offline-change-detector.ts +++ /dev/null @@ -1,247 +0,0 @@ -import type { DocumentRecord, RelativePath } from "./types"; -import { SyncEventType } 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 { FileNotFoundError } from "../errors/file-not-found-error"; -import type { SyncEventQueue } from "./sync-event-queue"; - -interface DocumentWithPath { - path: RelativePath; - record: DocumentRecord; -} - -interface SyncInstruction { - type: "update" | "create"; - relativePath: string; - oldPath?: string; -} - -interface OfflineChangeDetectorDeps { - logger: Logger; - operations: FileOperations; - queue: SyncEventQueue; -} - -/** - * 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. - */ -export async function scheduleOfflineChanges( - deps: OfflineChangeDetectorDeps, - enqueueCreate: (path: RelativePath) => void, - enqueueUpdate: (args: { oldPath?: RelativePath; relativePath: RelativePath }) => void, - enqueueDelete: (path: RelativePath) => void, -): Promise { - const { logger, operations, queue } = deps; - - const allLocalFiles = await operations.listFilesRecursively(); - logger.info(`Scheduling sync for ${allLocalFiles.length} local files`); - - queue.clear(); - - const allDocuments = new Map(queue.allSettledDocuments()); - const locallyRenamedPaths = enqueueRenamedDocuments(deps, allDocuments); - - let deletedCandidates = await findLocallyDeletedFiles(operations, allDocuments); - - const instructions = await buildSyncInstructions( - deps, - allLocalFiles, - locallyRenamedPaths, - deletedCandidates, - ); - - // Enqueue deletes first - for (const { path } of deletedCandidates) { - logger.debug(`Document ${path} has been deleted locally, scheduling sync to delete it`); - enqueueDelete(path); - } - - // Then updates/moves - for (const instruction of instructions) { - if (instruction.type === "update") { - enqueueUpdate({ - oldPath: instruction.oldPath, - relativePath: instruction.relativePath, - }); - } - } - - // Creates last so the server can merge with existing documents - for (const instruction of instructions) { - if (instruction.type === "create") { - enqueueCreate(instruction.relativePath); - } - } -} - -function enqueueRenamedDocuments( - { queue, logger }: OfflineChangeDetectorDeps, - allDocuments: Map, -): Set { - const locallyRenamedPaths = new Set(); - - for (const [path, record] of allDocuments) { - const remoteRelPath = record.remoteRelativePath; - const hasLocalRename = remoteRelPath !== undefined && remoteRelPath !== path; - - if (hasLocalRename) { - queue.enqueue({ type: SyncEventType.SyncLocal, path }); - locallyRenamedPaths.add(path); - logger.debug(`Document ${path} was renamed locally (from ${remoteRelPath}), scheduling sync`); - } - } - - return locallyRenamedPaths; -} - -async function findLocallyDeletedFiles( - operations: FileOperations, - allDocuments: Map, -): Promise { - const result: DocumentWithPath[] = []; - - for (const [path, record] of allDocuments) { - if (!(await operations.exists(path))) { - result.push({ path, record }); - } - } - - return result; -} - -async function buildSyncInstructions( - deps: OfflineChangeDetectorDeps, - allLocalFiles: RelativePath[], - locallyRenamedPaths: Set, - deletedCandidates: DocumentWithPath[], -): Promise { - const { logger, operations, queue } = deps; - const instructions: SyncInstruction[] = []; - - for (const relativePath of allLocalFiles) { - if (locallyRenamedPaths.has(relativePath)) { - continue; - } - - const existingRecord = queue.getSettledDocumentByPath(relativePath); - - if (existingRecord !== undefined) { - const result = await handleExistingDocument( - deps, - relativePath, - existingRecord, - deletedCandidates, - ); - if (result !== undefined) { - if (result.updatedDeletedCandidates !== undefined) { - deletedCandidates = result.updatedDeletedCandidates; - } - if (result.instruction !== undefined) { - instructions.push(result.instruction); - } - continue; - } - - logger.debug( - `Document ${relativePath} might have been updated locally, scheduling sync to validate and update it`, - ); - instructions.push({ type: "update", relativePath }); - continue; - } - - const result = await handleNewFile(deps, relativePath, deletedCandidates); - if (result.updatedDeletedCandidates !== undefined) { - deletedCandidates = result.updatedDeletedCandidates; - } - instructions.push(result.instruction); - } - - return instructions; -} - -async function handleExistingDocument( - { logger, operations }: OfflineChangeDetectorDeps, - relativePath: RelativePath, - existingRecord: DocumentRecord, - deletedCandidates: DocumentWithPath[], -): Promise< - | { instruction?: SyncInstruction; updatedDeletedCandidates?: DocumentWithPath[] } - | undefined -> { - if (deletedCandidates.length === 0) { - return undefined; - } - - let contentHash: string | undefined; - try { - const bytes = await operations.read(relativePath); - contentHash = await hash(bytes); - } catch (e) { - if (e instanceof FileNotFoundError) return { instruction: undefined }; - throw e; - } - - if (contentHash === existingRecord.remoteHash) { - return undefined; - } - - const originalFile = await findMatchingFile(contentHash, deletedCandidates); - if (originalFile === undefined) { - return undefined; - } - - // This file was moved here from a different path, displacing the existing document - const updatedDeletedCandidates = [ - ...deletedCandidates.filter((item) => item.path !== originalFile.path), - { path: relativePath, record: existingRecord }, - ]; - - logger.debug( - `Document '${originalFile.path}' was moved to ${relativePath} (displacing existing document), scheduling sync to move it`, - ); - - return { - instruction: { type: "update", oldPath: originalFile.path, relativePath }, - updatedDeletedCandidates, - }; -} - -async function handleNewFile( - { logger, operations }: OfflineChangeDetectorDeps, - relativePath: RelativePath, - deletedCandidates: DocumentWithPath[], -): Promise<{ instruction: SyncInstruction; updatedDeletedCandidates?: DocumentWithPath[] }> { - let contentHash: string | undefined; - try { - const contentBytes = await operations.read(relativePath); - contentHash = await hash(contentBytes); - } catch (e) { - if (e instanceof FileNotFoundError) { - return { instruction: { type: "create", relativePath } }; - } - throw e; - } - - const originalFile = await findMatchingFile(contentHash, deletedCandidates); - if (originalFile !== undefined) { - const updatedDeletedCandidates = deletedCandidates.filter( - (item) => item.path !== originalFile.path, - ); - - logger.debug( - `Document '${originalFile.path}' was not found under its current path in the database but was found under a different path (${relativePath}), scheduling sync to move it`, - ); - - return { - instruction: { type: "update", oldPath: originalFile.path, relativePath }, - updatedDeletedCandidates, - }; - } - - logger.debug(`Document ${relativePath} not found in database, scheduling sync to create it`); - return { instruction: { type: SyncEventType.Create, relativePath } }; -} 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 aae010b6..00000000 --- a/frontend/sync-client/src/sync-operations/sync-event-queue.test.ts +++ /dev/null @@ -1,514 +0,0 @@ -import { describe, it } from "node:test"; -import assert from "node:assert"; -import { 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"; - -function createQueue(ignorePatterns: string[] = []): SyncEventQueue { - const logger = new Logger(); - const settings = new Settings(logger, { ignorePatterns }, async () => { }); - return new SyncEventQueue(settings, logger, undefined, async () => { }); -} - -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, - ...overrides - }; -} - -describe("SyncEventQueue", () => { - it("sync-local followed by delete for the same document returns only the delete", async () => { - const queue = createQueue(); - queue.setDocument("a.md", { - documentId: "A", - parentVersionId: 1, - remoteHash: "hash-a" - }); - - queue.enqueue({ type: SyncEventType.SyncLocal, path: "a.md" }); - queue.enqueue({ type: SyncEventType.SyncLocal, path: "a.md" }); - queue.enqueue({ type: SyncEventType.Delete, path: "a.md" }); - - const event = await queue.next(); - assert.strictEqual(event?.type, SyncEventType.Delete); - if (event?.type === SyncEventType.Delete) { - assert.strictEqual(event.documentId, "A"); - } - assert.strictEqual(await queue.next(), undefined); - }); - - it("sync-local events for the same document coalesce to one", async () => { - const queue = createQueue(); - queue.setDocument("a.md", { - documentId: "A", - parentVersionId: 1, - remoteHash: "hash-a" - }); - - queue.enqueue({ type: SyncEventType.SyncLocal, path: "a.md" }); - queue.enqueue({ type: SyncEventType.SyncLocal, path: "a.md" }); - queue.enqueue({ type: SyncEventType.SyncLocal, path: "a.md" }); - - const event = await queue.next(); - assert.strictEqual(event?.type, SyncEventType.SyncLocal); - assert.strictEqual(await queue.next(), undefined); - }); - - it("sync-remote events for the same documentId coalesce to the last one", async () => { - const queue = createQueue(); - - queue.enqueue({ - type: SyncEventType.SyncRemote, - remoteVersion: fakeRemoteVersion("A", { vaultUpdateId: 1 }) - }); - queue.enqueue({ - type: SyncEventType.SyncRemote, - remoteVersion: fakeRemoteVersion("A", { vaultUpdateId: 2 }) - }); - queue.enqueue({ - type: SyncEventType.SyncRemote, - remoteVersion: fakeRemoteVersion("A", { vaultUpdateId: 3 }) - }); - - const event = await queue.next(); - assert.strictEqual(event?.type, SyncEventType.SyncRemote); - if (event?.type === SyncEventType.SyncRemote) { - assert.strictEqual(event.remoteVersion.vaultUpdateId, 3); - } - assert.strictEqual(await queue.next(), undefined); - }); - - it("create events are returned FIFO", async () => { - const queue = createQueue(); - queue.enqueue({ type: SyncEventType.Create, path: "a.md" }); - queue.enqueue({ type: SyncEventType.Create, path: "b.md" }); - - const first = await queue.next(); - assert.strictEqual(first?.type, SyncEventType.Create); - if (first?.type === SyncEventType.Create) { - assert.strictEqual(first.path, "a.md"); - } - - const second = await queue.next(); - assert.strictEqual(second?.type, SyncEventType.Create); - if (second?.type === SyncEventType.Create) { - assert.strictEqual(second.path, "b.md"); - } - }); - - it("delete resolves documentId from path", async () => { - const queue = createQueue(); - queue.setDocument("a.md", { - documentId: "A", - parentVersionId: 1, - remoteHash: "hash-a" - }); - - queue.enqueue({ type: SyncEventType.Delete, path: "a.md" }); - - const event = await queue.next(); - assert.strictEqual(event?.type, SyncEventType.Delete); - if (event?.type === SyncEventType.Delete) { - assert.strictEqual(event.documentId, "A"); - } - }); - - it("delete for unknown path is silently ignored", () => { - const queue = createQueue(); - queue.enqueue({ type: SyncEventType.Delete, path: "unknown.md" }); - assert.strictEqual(queue.size, 0); - }); - - it("document store CRUD operations work correctly", () => { - const queue = createQueue(); - - assert.strictEqual(queue.getSettledDocumentByPath("a.md"), undefined); - assert.strictEqual(queue.documentCount, 0); - - queue.setDocument("a.md", { - documentId: "A", - parentVersionId: 1, - remoteHash: "hash-a" - }); - assert.strictEqual(queue.documentCount, 1); - assert.deepStrictEqual(queue.getSettledDocumentByPath("a.md"), { - documentId: "A", - parentVersionId: 1, - remoteHash: "hash-a" - }); - - const found = queue.getDocumentByDocumentId("A"); - assert.strictEqual(found?.path, "a.md"); - assert.strictEqual(found?.record.documentId, "A"); - - queue.removeDocument("a.md"); - assert.strictEqual(queue.documentCount, 0); - assert.strictEqual(queue.getSettledDocumentByPath("a.md"), undefined); - }); - - it("SyncLocal with oldPath moves the document in the store", () => { - const queue = createQueue(); - queue.setDocument("a.md", { - documentId: "A", - parentVersionId: 1, - remoteHash: "hash-a" - }); - - queue.enqueue({ type: SyncEventType.SyncLocal, path: "b.md", oldPath: "a.md" }); - assert.strictEqual(queue.getSettledDocumentByPath("a.md"), undefined); - assert.strictEqual(queue.getSettledDocumentByPath("b.md")?.documentId, "A"); - }); - - it("interleaved events for different documents are not confused", async () => { - const queue = createQueue(); - queue.setDocument("a.md", { - documentId: "A", - parentVersionId: 1, - remoteHash: "hash-a" - }); - queue.setDocument("b.md", { - documentId: "B", - parentVersionId: 2, - remoteHash: "hash-b" - }); - - queue.enqueue({ type: SyncEventType.SyncLocal, path: "a.md" }); - queue.enqueue({ type: SyncEventType.SyncLocal, path: "b.md" }); - queue.enqueue({ type: SyncEventType.Delete, path: "a.md" }); - queue.enqueue({ type: SyncEventType.SyncLocal, path: "b.md" }); - - // First next() should see the delete for A (coalescing sync-local + delete) - const first = await queue.next(); - assert.strictEqual(first?.type, SyncEventType.Delete); - if (first?.type === SyncEventType.Delete) { - assert.strictEqual(first.documentId, "A"); - } - - // Remaining should be the coalesced sync-local for B - const second = await queue.next(); - assert.strictEqual(second?.type, SyncEventType.SyncLocal); - if (second?.type === SyncEventType.SyncLocal) { - assert.strictEqual(second.documentId, "B"); - } - - assert.strictEqual(await queue.next(), undefined); - }); - - it("delete discards subsequent sync-remote events for the same document", async () => { - const queue = createQueue(); - queue.setDocument("a.md", { - documentId: "A", - parentVersionId: 1, - remoteHash: "hash-a" - }); - - queue.enqueue({ type: SyncEventType.Delete, path: "a.md" }); - queue.enqueue({ - type: SyncEventType.SyncRemote, - remoteVersion: fakeRemoteVersion("A", { vaultUpdateId: 5 }) - }); - - const event = await queue.next(); - assert.strictEqual(event?.type, SyncEventType.Delete); - assert.strictEqual(await queue.next(), undefined); - }); - - it("delete discards subsequent sync-local and sync-remote for the same document", async () => { - const queue = createQueue(); - queue.setDocument("a.md", { - documentId: "A", - parentVersionId: 1, - remoteHash: "hash-a" - }); - - queue.enqueue({ type: SyncEventType.Delete, path: "a.md" }); - queue.enqueue({ type: SyncEventType.SyncLocal, path: "a.md" }); - queue.enqueue({ type: SyncEventType.Create, path: "b.md" }); - queue.enqueue({ - type: SyncEventType.SyncRemote, - remoteVersion: fakeRemoteVersion("A", { vaultUpdateId: 5 }) - }); - - const first = await queue.next(); - assert.strictEqual(first?.type, SyncEventType.Delete); - - // Only the unrelated create should remain - const second = await queue.next(); - assert.strictEqual(second?.type, SyncEventType.Create); - assert.strictEqual(await queue.next(), undefined); - }); - - it("delete with promise documentId does not discard other events", async () => { - const queue = createQueue(); - queue.setDocument("a.md", { - documentId: "A", - parentVersionId: 1, - remoteHash: "hash-a" - }); - - // Create is pending — Delete for same path gets a promise documentId - queue.enqueue({ type: SyncEventType.Create, path: "unknown.md" }); - queue.enqueue({ type: SyncEventType.Delete, path: "unknown.md" }); - queue.enqueue({ type: SyncEventType.SyncLocal, path: "a.md" }); - - // Dequeue and resolve the Create - const event = await queue.next(); - assert.ok(event?.type === SyncEventType.Create); - event.resolvers!.resolve("NEW"); - - await queue.next(); // delete - const second = await queue.next(); - assert.strictEqual(second?.type, SyncEventType.SyncLocal); - }); - - it("getCreatePromise returns a promise resolved by the event's resolvers", async () => { - const queue = createQueue(); - queue.enqueue({ type: SyncEventType.Create, path: "a.md" }); - - const promise = queue.getCreatePromise("a.md"); - assert.ok(promise !== undefined); - - // The syncer resolves via event.resolvers after dequeuing - const event = await queue.next(); - assert.ok(event?.type === SyncEventType.Create); - assert.ok(event.resolvers !== undefined); - event.resolvers.resolve("resolved-id"); - - assert.strictEqual(await promise, "resolved-id"); - }); - - it("rejecting the event's resolvers rejects the create promise", async () => { - const queue = createQueue(); - queue.enqueue({ type: SyncEventType.Create, path: "a.md" }); - - const promise = queue.getCreatePromise("a.md"); - assert.ok(promise !== undefined); - - const event = await queue.next(); - assert.ok(event?.type === SyncEventType.Create); - assert.ok(event.resolvers !== undefined); - event.resolvers.promise.catch(() => { }); - event.resolvers.reject(new Error("cancelled")); - - await assert.rejects(promise); - }); - - it("clear rejects all pending create promises", async () => { - const queue = createQueue(); - queue.enqueue({ type: SyncEventType.Create, path: "a.md" }); - queue.enqueue({ type: SyncEventType.Create, path: "b.md" }); - - const promiseA = queue.getCreatePromise("a.md"); - const promiseB = queue.getCreatePromise("b.md"); - assert.ok(promiseA !== undefined); - assert.ok(promiseB !== undefined); - - queue.clear(); - - await assert.rejects(promiseA); - await assert.rejects(promiseB); - }); - - it("create can be re-enqueued after being dequeued", async () => { - const queue = createQueue(); - queue.enqueue({ type: SyncEventType.Create, path: "a.md" }); - await queue.next(); - - queue.enqueue({ type: SyncEventType.Create, path: "a.md" }); - assert.strictEqual(queue.size, 1); - }); - - it("silently ignores create events matching ignore patterns", () => { - const queue = createQueue(["*.tmp", ".hidden/**"]); - - queue.enqueue({ type: SyncEventType.Create, path: "scratch.tmp" }); - queue.enqueue({ type: SyncEventType.Create, path: ".hidden/secret.md" }); - assert.strictEqual(queue.size, 0); - - queue.enqueue({ type: SyncEventType.Create, path: "notes-new.md" }); - assert.strictEqual(queue.size, 1); - - queue.enqueue({ - type: SyncEventType.SyncRemote, - remoteVersion: fakeRemoteVersion("N") - }); - assert.strictEqual(queue.size, 2); - }); - - it("clear removes events but keeps documents", () => { - const queue = createQueue(); - queue.setDocument("a.md", { - documentId: "A", - parentVersionId: 1, - remoteHash: "hash-a" - }); - queue.enqueue({ type: SyncEventType.Create, path: "b.md" }); - queue.enqueue({ type: SyncEventType.SyncLocal, path: "a.md" }); - - assert.strictEqual(queue.size, 2); - - queue.clear(); - - assert.strictEqual(queue.size, 0); - assert.strictEqual(queue.documentCount, 1); - assert.strictEqual(queue.getSettledDocumentByPath("a.md")?.documentId, "A"); - }); - - it("allDocuments returns all tracked documents", () => { - const queue = createQueue(); - queue.setDocument("a.md", { - documentId: "A", - parentVersionId: 1, - remoteHash: "hash-a" - }); - queue.setDocument("b.md", { - documentId: "B", - parentVersionId: 2, - remoteHash: "hash-b" - }); - - const docs = queue.allSettledDocuments(); - assert.strictEqual(docs.length, 2); - const paths = docs.map(([p]) => p).sort(); - assert.deepStrictEqual(paths, ["a.md", "b.md"]); - }); - - it("loads initial state from persistence", () => { - const logger = new Logger(); - const settings = new Settings(logger, {}, async () => { }); - const queue = new SyncEventQueue(settings, logger, { - documents: [ - { - relativePath: "a.md", - documentId: "A", - parentVersionId: 5, - remoteHash: "hash-a" - }, - { - relativePath: "b.md", - documentId: "B", - parentVersionId: 3, - remoteHash: "hash-b" - } - ], - lastSeenUpdateId: 4 - }, async () => { }); - - assert.strictEqual(queue.documentCount, 2); - assert.strictEqual(queue.getSettledDocumentByPath("a.md")?.documentId, "A"); - assert.strictEqual(queue.getSettledDocumentByPath("b.md")?.documentId, "B"); - assert.strictEqual(queue.lastSeenUpdateId, 5); - }); - - it("trackedPaths combines documents and pending events", () => { - const queue = createQueue(); - queue.setDocument("a.md", { - documentId: "A", - parentVersionId: 1, - remoteHash: "hash-a" - }); - queue.setDocument("b.md", { - documentId: "B", - parentVersionId: 2, - remoteHash: "hash-b" - }); - - // Pending create adds a path - queue.enqueue({ type: SyncEventType.Create, path: "c.md" }); - // Pending delete removes a path - queue.enqueue({ type: SyncEventType.Delete, path: "a.md" }); - - const paths = queue.trackedPaths(); - assert.deepStrictEqual( - [...paths].sort(), - ["b.md", "c.md"] - ); - }); - - it("trackedPaths handles create-delete-create for the same path", () => { - const queue = createQueue(); - - queue.enqueue({ type: SyncEventType.Create, path: "a.md" }); - // Delete gets promise documentId from pending Create - queue.enqueue({ type: SyncEventType.Delete, path: "a.md" }); - queue.enqueue({ type: SyncEventType.Create, path: "a.md" }); - - const paths = queue.trackedPaths(); - assert.ok(paths.has("a.md")); - }); - - it("trackedPaths applies moves for pending SyncLocal events", () => { - const queue = createQueue(); - - queue.enqueue({ type: SyncEventType.Create, path: "a.md" }); - - // File was renamed from a.md to b.md - queue.enqueue({ type: SyncEventType.SyncLocal, path: "b.md", oldPath: "a.md" }); - - const paths = queue.trackedPaths(); - assert.ok(!paths.has("a.md")); - assert.ok(paths.has("b.md")); - }); - - it("trackedPaths tracks multiple moves for the same pending create", () => { - const queue = createQueue(); - - queue.enqueue({ type: SyncEventType.Create, path: "a.md" }); - - queue.enqueue({ type: SyncEventType.SyncLocal, path: "b.md", oldPath: "a.md" }); - queue.enqueue({ type: SyncEventType.SyncLocal, path: "c.md", oldPath: "b.md" }); - - const paths = queue.trackedPaths(); - assert.ok(!paths.has("a.md")); - assert.ok(!paths.has("b.md")); - assert.ok(paths.has("c.md")); - }); - - it("resolveCreate settles the document and replaces promise documentIds in the queue", async () => { - const queue = createQueue(); - - queue.enqueue({ type: SyncEventType.Create, path: "a.md" }); - const createPromise = queue.getCreatePromise("a.md")!; - - // Dependent events enqueued while create is still pending - queue.enqueue({ type: SyncEventType.SyncLocal, path: "a.md" }); - queue.enqueue({ type: SyncEventType.Delete, path: "a.md" }); - - const event = await queue.next(); // dequeue the create - assert.ok(event?.type === SyncEventType.Create); - - queue.resolveCreate(event, { - documentId: "DOC-1", - parentVersionId: 5, - remoteHash: "hash-1", - }); - - // Document is now settled - assert.strictEqual(queue.getSettledDocumentByPath("a.md")?.documentId, "DOC-1"); - - // Promise was resolved - assert.strictEqual(await createPromise, "DOC-1"); - - // Remaining events have string documentIds instead of promises. - // The SyncLocal + Delete for "DOC-1" coalesce: sync-local is - // discarded and the delete is returned (standard coalescing). - const deleteEvt = await queue.next(); - assert.ok(deleteEvt?.type === SyncEventType.Delete); - assert.strictEqual(deleteEvt.documentId, "DOC-1"); - - assert.strictEqual(await queue.next(), undefined); - }); -}); 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 b2cebb1f..00000000 --- a/frontend/sync-client/src/sync-operations/sync-event-queue.ts +++ /dev/null @@ -1,423 +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 { - SyncEventType, - type DocumentId, - type DocumentRecord, - type FileSyncEvent, - type RelativePath, - type StoredSyncState, - type SyncEvent, - type VaultUpdateId, -} from "./types"; - -export class SyncEventQueue { - // Latest state of the filesystem as we know it, excluding - // unconfirmed creates but including pending deletes. - // - // It's always indexed by the latest path on disk. - // - // It maps a subset of the remote state onto the local filesystem. - private readonly documents = 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 for 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 will be ignored - private ignorePatterns: RegExp[]; - - public constructor( - private readonly settings: Settings, - private readonly logger: Logger, - initialState: Partial | undefined, - private readonly saveData: (data: StoredSyncState) => Promise - ) { - this.ignorePatterns = globsToRegexes( - this.settings.getSettings().ignorePatterns, - this.logger - ); - - this.settings.onSettingsChanged.add((newSettings) => { - this.ignorePatterns = globsToRegexes( - newSettings.ignorePatterns, - this.logger - ); - }); - - initialState ??= {}; - - if (initialState.documents !== undefined) { - for (const { relativePath, ...record } of initialState.documents) { - this.documents.set(relativePath, record); - } - } - - this.logger.debug(`Loaded ${this.documents.size} documents`); - } - - public get size(): number { - return this.events.length; - } - - public get documentCount(): number { - return this.documents.size; - } - - public get lastSeenUpdateId(): VaultUpdateId { - let max = 0; - for (const record of this.documents.values()) { - if (record.parentVersionId > max) { - max = record.parentVersionId; - } - } - return max; - } - - - // todo: let's remove - public getSettledDocumentByPath(path: RelativePath): DocumentRecord | undefined { - return this.documents.get(path); - } - - public getDocumentByDocumentId( - target: DocumentId - ): { path: RelativePath; record: DocumentRecord } | undefined { - for (const [path, record] of this.documents) { - if (record.documentId === target) { - return { path, record }; - } - } - return undefined; - } - - public setDocument(path: RelativePath, record: DocumentRecord): void { - this.documents.set(path, record); - this.saveInTheBackground(); - } - - public removeDocument(path: RelativePath): void { - this.documents.delete(path); - this.saveInTheBackground(); - } - - /** - * Call once a create has been acknowledged by the server. - */ - public resolveCreate( - event: Extract, - record: DocumentRecord - ): void { - const promise = event.resolvers?.promise; - - this.documents.set(event.path, record); - event.resolvers?.resolve(record.documentId); - - if (promise !== undefined) { - for (const e of this.events) { - if ( - (e.type === SyncEventType.SyncLocal || e.type === SyncEventType.Delete) && - e.documentId === promise - ) { - (e as { documentId: DocumentId | Promise }).documentId = record.documentId; - } - } - } - - this.saveInTheBackground(); - } - - public getCreatePromise(path: RelativePath): Promise | undefined { - const event = this.findLastCreate(path); - if (event === undefined) return undefined; - event.resolvers ??= Promise.withResolvers(); - return event.resolvers.promise; - } - - public allSettledDocuments(): [RelativePath, DocumentRecord][] { - return Array.from(this.documents.entries()); - } - - /** - * Returns the set of paths we expect to exist on disk by replaying - * the event queue on top of the settled documents map. - */ - public trackedPaths(): Set { - const paths = new Set(this.documents.keys()); - // Track current path for each pending create so moves can be applied - const pendingPaths = new Map, RelativePath>(); - - for (const event of this.events) { - if (event.type === SyncEventType.Create) { - paths.add(event.path); - if (event.resolvers !== undefined) { - pendingPaths.set(event.resolvers.promise, event.path); - } - } else if (event.type === SyncEventType.Delete) { - if (typeof event.documentId === "string") { - const path = this.getDocumentByDocumentId(event.documentId)?.path; - if (path) { - paths.delete(path); - } else { - throw new Error(`Delete event for unknown documentId ${event.documentId}`); - } - } else { - const path = pendingPaths.get(event.documentId); - if (!path) { - throw new Error(`Delete event with unresolved documentId promise`); - } - paths.delete(path); - } - } // no need to handle SyncLocal as path updates are applied to this.documents immediately when the event is enqueued - } - return paths; - } - - public hasPendingEventsForPath(path: RelativePath): boolean { - const record = this.documents.get(path); - if (!record) { - 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.Create && e.path === path) || - (e.type === SyncEventType.SyncLocal && - e.documentId === docId) || - (e.type === SyncEventType.Delete && - e.documentId === docId) || - (e.type === SyncEventType.SyncRemote && - // we care about the local path not the remote - this.getDocumentByDocumentId(e.remoteVersion.documentId as DocumentId)?.path === path) - ); - } - - public async save(): Promise { - return this.saveData({ - documents: Array.from(this.documents.entries()).map( - ([relativePath, record]) => ({ - relativePath, - ...record - }) - ), - lastSeenUpdateId: this.lastSeenUpdateId - }); - } - - public resetState(): void { - this.rejectAllPendingCreates(); - this.documents.clear(); - this.saveInTheBackground(); - } - - public clear(): void { - this.rejectAllPendingCreates(); - this.events.length = 0; - } - - public enqueue(input: FileSyncEvent): void { - if (input.type === SyncEventType.SyncRemote) { - this.events.push(input); - return; - } - - const { path } = input; - - if (input.type === SyncEventType.Create) { - if (this.isIgnored(path)) { - this.logger.info(`Ignoring create for ${path} as it matches ignore patterns`); - return; - } - this.events.push({ type: SyncEventType.Create, path, originalPath: path }); - return; - } - - const lookupPath = (input.type === SyncEventType.SyncLocal && input.oldPath) ? input.oldPath : path; - const record = this.documents.get(lookupPath); - const documentId: DocumentId | Promise | undefined = - record?.documentId ?? this.getCreatePromise(lookupPath); - if (documentId === undefined) return; - - if (input.type === SyncEventType.Delete) { - this.events.push({ type: SyncEventType.Delete, documentId }); - return; - } - - if (input.oldPath !== undefined) { - if (typeof documentId === "string") { - this.documents.delete(input.oldPath); - this.documents.set(path, record!); - for (const e of this.events) { - if (e.type === SyncEventType.SyncLocal && e.documentId === documentId) { - e.path = path; - } - } - this.saveInTheBackground(); - } else { - this.updatePendingCreatePath(input.oldPath, path); - } - } - this.events.push({ type: SyncEventType.SyncLocal, documentId, path, originalPath: path }); - } - - - - public async next(): Promise { - if (this.events.length === 0) return undefined; - - const [first] = this.events; - - // Creates are always returned immediately (FIFO) - if (first.type === SyncEventType.Create) { - this.events.shift(); - return first; - } - - // Deletes are returned immediately; also discard any subsequent - // events for the same documentId so stale broadcasts don't - // resurrect the document - if (first.type === SyncEventType.Delete) { - this.events.shift(); - const { documentId } = first; - this.removeAllEventsForDocumentId(await documentId); - return first; - } - - if (first.type === SyncEventType.SyncLocal) { - const { documentId } = first; - - // If there's a later delete for the same documentId, discard - // all sync-locals for that document and return the delete - const deleteEvent = this.events.find( - (e) => - e.type === SyncEventType.Delete && - e.documentId === documentId - ); - if (deleteEvent !== undefined) { - this.removeAllEventsForDocumentId(await documentId); - return deleteEvent; - } - - // Coalesce multiple sync-locals for the same documentId and - // original path to the last one - const matching = this.events.filter( - (e) => - e.type === SyncEventType.SyncLocal && - e.documentId === documentId && - e.originalPath === first.originalPath // can't coalesce moves as they can depend on each other so we have to sync them in the same order, could do topological sort but let's keep it simple for now - ); - const result = matching[matching.length - 1]; - for (const item of matching) { - removeFromArray(this.events, item); - } - return result; - } - - // SyncRemote: coalesce multiple events for the same documentId to the last one - const { documentId } = first.remoteVersion; - const matching = this.events.filter( - (e) => - e.type === SyncEventType.SyncRemote && - e.remoteVersion.documentId === documentId - ); - const result = matching[matching.length - 1]; - for (const item of matching) { - removeFromArray(this.events, item); - } - return result; - } - - private isIgnored(path: RelativePath): boolean { - return this.ignorePatterns.some((pattern) => pattern.test(path)); - } - - public removeAllEventsForDocumentId(documentId: DocumentId): void { - for (let i = this.events.length - 1; i >= 0; i--) { - const e = this.events[i]; - if ( - (e.type === SyncEventType.SyncLocal && - e.documentId === documentId) || - (e.type === SyncEventType.SyncRemote && - e.remoteVersion.documentId === documentId) || - (e.type === SyncEventType.Delete && - e.documentId === documentId) - ) { - // eslint-disable-next-line no-restricted-syntax -- Bulk removal by predicate, not single-item removal - this.events.splice(i, 1); - } - } - } - - public updatePendingCreatePath( - oldPath: RelativePath, - newPath: RelativePath - ): void { - const createEvent = this.findLastCreate(oldPath); - if (createEvent === undefined) return; - - const promise = createEvent.resolvers?.promise; - createEvent.path = newPath; - - if (promise !== undefined) { - for (const e of this.events) { - if ( - e.type === SyncEventType.SyncLocal && - e.documentId === promise - ) { - e.path = newPath; - } - } - } - } - - private findCreatePathByPromise( - promise: Promise - ): RelativePath | undefined { - for (let i = this.events.length - 1; i >= 0; i--) { - const e = this.events[i]; - if ( - e.type === SyncEventType.Create && - e.resolvers?.promise === promise - ) { - return e.path; - } - } - return undefined; - } - - private findLastCreate( - path: RelativePath - ): Extract | undefined { - for (let i = this.events.length - 1; i >= 0; i--) { - const e = this.events[i]; - if (e.type === SyncEventType.Create && e.path === path) { - return e; - } - } - return undefined; - } - - private rejectAllPendingCreates(): void { - for (const event of this.events) { - if (event.type === SyncEventType.Create && event.resolvers !== undefined) { - event.resolvers.promise.catch(() => { /* suppressed — consumer may not be listening */ }); - event.resolvers.reject(new Error("Create was cancelled")); - } - } - } - - private saveInTheBackground(): void { - void this.save().catch((error: unknown) => { - this.logger.error(`Error saving sync state: ${error}`); - }); - } -} diff --git a/frontend/sync-client/src/sync-operations/syncer.ts b/frontend/sync-client/src/sync-operations/syncer.ts index 7a461b9c..71dedd85 100644 --- a/frontend/sync-client/src/sync-operations/syncer.ts +++ b/frontend/sync-client/src/sync-operations/syncer.ts @@ -1,73 +1,77 @@ -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 { 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 CommonHistoryEntry -} 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"; export class Syncer { public readonly onRemainingOperationsCountChanged = new EventListeners< (remainingOperations: number) => unknown >(); - private readonly queue: SyncEventQueue; + 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 draining: Promise | undefined; 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 + }); + + this.remoteDocumentsLock = new Locks(this.logger); + + settings.onSettingsChanged.add((newSettings, oldSettings) => { + if (newSettings.syncConcurrency !== oldSettings.syncConcurrency) { + this.syncQueue.concurrency = newSettings.syncConcurrency; + } + }); + + 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(); - } else { - this.runningScheduleSyncForOfflineChanges = undefined; } }); this.webSocketManager.onRemoteVaultUpdateReceived.add( @@ -79,32 +83,156 @@ export class Syncer { return this._isFirstSyncComplete; } - public hasPendingOperationsForDocument(relativePath: string): boolean { - return this.queue.hasPendingEventsForPath(relativePath); + 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 syncLocallyCreatedFile(relativePath: RelativePath): void { - this.queue.enqueue({ type: SyncEventType.Create, path: relativePath }); - this.ensureDraining(); + 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 syncLocallyDeletedFile(relativePath: RelativePath): void { - this.queue.enqueue({ - type: SyncEventType.Delete, - path: relativePath, - }); - this.ensureDraining(); - } - - public syncLocallyUpdatedFile({ + public async syncLocallyUpdatedFile({ oldPath, relativePath }: { oldPath?: RelativePath; relativePath: RelativePath; - }): void { - this.queue.enqueue({ type: SyncEventType.SyncLocal, 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}` + ); + } + + this.database.move(oldPath, relativePath); + } + } + + let document = + this.database.getLatestDocumentByRelativePath(relativePath); + + 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; + } + + 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 { @@ -129,781 +257,266 @@ 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; - // Loop until the draining promise stabilises — new drains can be - // chained by events enqueued during processing - let current = this.draining; - while (current !== undefined) { - await current; - if (this.draining === current) break; - current = this.draining; - } + await this.syncQueue.onIdle(); // Wait for queue to be empty and running tasks to finish } public async syncRemotelyUpdatedFile( message: WebSocketVaultUpdate ): Promise { try { - await this.scheduleSyncForOfflineChanges(); + const handlerPromise = awaitAll( + message.documents.map(async (document) => + this.internalSyncRemotelyUpdatedFile(document) + ) + ); - for (const remoteVersion of message.documents) { - this.queue.enqueue({ - type: SyncEventType.SyncRemote, - remoteVersion - }); - } + await handlerPromise; - if (message.isInitialSync) { - this._isFirstSyncComplete = true; - } - - await this.scheduleDrain(); - } catch (e) { - if (e instanceof SyncResetError) { - this.logger.info( - "Failed to sync remotely updated file due to a reset" + if (message.isInitialSync && message.documents.length > 0) { + this.database.setLastSeenUpdateId( + message.documents + .map((document) => document.vaultUpdateId) + .reduce((a, b) => Math.max(a, b)) ); - return; } + + this._isFirstSyncComplete = true; + } catch (e) { this.logger.error(`Failed to sync remotely updated file: ${e}`); } } public reset(): void { this._isFirstSyncComplete = false; - this.queue.clear(); + this.syncQueue.clear(); + this.remoteDocumentsLock.reset(); this.runningScheduleSyncForOfflineChanges = undefined; - // Do not set this.draining = undefined — the in-flight drain will - // exit naturally (SyncResetError or empty queue) and the promise - // chain stays intact, preventing concurrent drain invocations } - - private sendHandshakeMessage(): void { const message: WebSocketClientMessage = { 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 { - await scheduleOfflineChanges( - { logger: this.logger, operations: this.operations, queue: this.queue }, - (path) => this.syncLocallyCreatedFile(path), - (args) => this.syncLocallyUpdatedFile(args), - (path) => this.syncLocallyDeletedFile(path), + private async internalSyncRemotelyUpdatedFile( + remoteVersion: DocumentVersionWithoutContent + ): Promise { + let document = this.database.getDocumentByDocumentId( + remoteVersion.documentId ); - await this.scheduleDrain(); - } + 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 + ); - private ensureDraining(): void { - this.draining = (this.draining ?? Promise.resolve()).then( - async () => this.drain() - ); - } + try { + await this.syncQueue.add(async () => + this.internalSyncer.unrestrictedSyncRemotelyUpdatedFile( + remoteVersion, + document + ) + ); - private async scheduleDrain(): Promise { - this.ensureDraining(); - await this.draining; - } + resolve(); + } catch (e) { + reject(e); + } finally { + this.database.removeDocumentPromise(promise); + } + } - private async drain(): Promise { - let event = await this.queue.next(); - while (event !== undefined) { - try { - await this.processEvent(event); - } catch (e) { - if (e instanceof SyncResetError) { - this.logger.info("Drain interrupted by sync reset"); - return; + this.database.addSeenUpdateId(remoteVersion.vaultUpdateId); } - this.logger.error( - `Failed to process sync event ${event.type}: ${e}` - ); - } - this.notifyRemainingOperationsChanged(); - event = await this.queue.next(); - } - } - - private async processEvent(event: SyncEvent): Promise { - if (!this.settings.getSettings().isSyncEnabled) { - this.logger.info( - `Skipping sync operation because sync is disabled` ); - return; } + // 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 { - switch (event.type) { - case SyncEventType.Create: - await this.processCreate(event); - break; - case SyncEventType.Delete: - await this.processDelete(event); - break; - case SyncEventType.SyncLocal: - await this.processSyncLocal(event); - break; - case SyncEventType.SyncRemote: - await this.processSyncRemote(event); - break; - } - } catch (e) { - if (e instanceof FileNotFoundError) { - this.logger.info( - `Skipping sync event '${event.type}' because the file no longer exists` - ); - if (event.type === SyncEventType.Create) { - event.resolvers?.promise.catch(() => { }); - event.resolvers?.reject(new Error("Create was cancelled")); - } - return; - } - if (e instanceof HttpClientError) { - this.logger.error( - `Server rejected ${event.type} request: ${e.message}` - ); - return; - } - throw e; - } - } - - - - private async processCreate( - event: Extract - ): Promise { - const effectivePath = event.path; - const contentBytes = await this.operations.read(effectivePath); - const contentHash = await hash(contentBytes); - - const oversizedEntry = this.getHistoryEntryForSkippedOversizedFile( - contentBytes.byteLength, - effectivePath - ); - if (oversizedEntry !== undefined) { - this.history.addHistoryEntry(oversizedEntry); - event.resolvers?.promise.catch(() => { }); - event.resolvers?.reject(new Error("Create was cancelled")); - return; - } - - const response = await this.syncService.create({ - relativePath: event.originalPath, - contentBytes - }); - - - // Handle concurrent move & creation: the server merged our create - // with an existing document that we also have locally at a different path - const existingDoc = this.queue.getDocumentByDocumentId( - response.documentId - ); - - // need to merge in db - if (existingDoc !== undefined && existingDoc.path !== effectivePath) { - // this.logger.info( - // `Merging existing document ${existingDoc.path} into ${effectivePath} after concurrent move & creation` - // ); - // await this.operations.delete(existingDoc.path); - // this.queue.removeDocument(existingDoc.path); - // if (!this.queue.getDocumentByDocumentId(existingDoc.record.documentId)) { - // this.queue.removeAllEventsForDocumentId(existingDoc.record.documentId); - // } - // } - } - - - await this.handleMaybeMergingResponse({ - path: effectivePath, - response, - contentHash, - originalContentBytes: contentBytes - }); - - this.history.addHistoryEntry({ - status: SyncStatus.SUCCESS, - details: { type: SyncType.CREATE, relativePath: effectivePath }, - 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 { - let documentId: DocumentId; - if (typeof event.documentId === "string") { - documentId = event.documentId; - } else { - try { - documentId = await event.documentId; - } catch { - this.logger.debug( - "Skipping delete for a document whose create was cancelled" - ); - return; - } - } - - const doc = this.queue.getDocumentByDocumentId(documentId); - if (doc === undefined) { - this.logger.debug( - `Skipping delete for unknown documentId ${documentId}` - ); - return; - } - const relativePath = doc.path; - - const response = await this.syncService.delete({ - documentId, - relativePath - }); - - this.queue.removeDocument(doc.path); - - this.history.addHistoryEntry({ - status: SyncStatus.SUCCESS, - details: { - type: SyncType.DELETE, - relativePath - }, - message: "Successfully deleted file on the server", - author: response.userId - }); - } - - private async processSyncLocal( - event: Extract - ): Promise { - let documentId: DocumentId; - if (typeof event.documentId === "string") { - documentId = event.documentId; - } else { - try { - documentId = await event.documentId; - } catch { - this.logger.debug( - "Skipping sync-local for a document whose create was cancelled" - ); - return; - } - } - - const doc = this.queue.getDocumentByDocumentId(documentId); - - if (doc === undefined) { - this.logger.debug( - `Skipping sync-local for unknown document ${documentId}` - ); - return; - } - - const { path: diskPath, record } = doc; - - // Read file from the current disk path - const contentBytes = await this.operations.read(diskPath); - const contentHash = await hash(contentBytes); - - // Upload using the original path - const uploadPath = event.originalPath; - - const pathChanged = - record.remoteRelativePath !== undefined && - record.remoteRelativePath !== uploadPath; - - if (contentHash === record.remoteHash && !pathChanged) { - this.logger.debug( - `File hash of ${diskPath} matches last synced version; no need to sync` - ); - return; - } - - const response = await this.sendUpdate( - record, - uploadPath, - contentBytes - ); - - await this.handleMaybeMergingResponse({ - path: diskPath, - response, - contentHash, - originalContentBytes: contentBytes - }); - - - const isMerge = - "type" in response && response.type === "MergingUpdate"; - this.history.addHistoryEntry({ - status: SyncStatus.SUCCESS, - details: { - type: SyncType.UPDATE, - relativePath: diskPath - }, - message: isMerge - ? "Updated file and merged with remote changes" - : "Successfully updated file on the server", - author: response.userId, - timestamp: new Date(response.updatedDate) - }); - } - - private async processSyncRemote( - event: Extract - ): Promise { - const { remoteVersion } = event; - const existingDoc = this.queue.getDocumentByDocumentId( - remoteVersion.documentId - ); - - if (existingDoc !== undefined) { - if ( - existingDoc.record.parentVersionId >= - remoteVersion.vaultUpdateId - ) { - this.logger.debug( - `Document ${existingDoc.path} is already up-to-date` - ); - return; - } - - await this.processRemoteUpdateForExistingDocument( - existingDoc.path, - existingDoc.record, - remoteVersion - ); - return; - } - - if (remoteVersion.isDeleted) { - this.logger.debug( - `Document ${remoteVersion.relativePath} has been deleted remotely, no need to sync` - ); - return; - } - - await this.processRemoteUpdateForNewDocument(remoteVersion); - } - - private async processRemoteUpdateForExistingDocument( - currentPath: RelativePath, - record: DocumentRecord, - remoteVersion: DocumentVersionWithoutContent - ): Promise { - if (remoteVersion.isDeleted) { - // Check for local changes before deleting - let hasLocalChanges = false; - try { - const contentBytes = await this.operations.read(currentPath); - const contentHash = await hash(contentBytes); - hasLocalChanges = record.remoteHash !== contentHash; - } catch (e) { - if (!(e instanceof FileNotFoundError)) throw e; - } - - if (hasLocalChanges) { - // Local changes survive; re-upload as a new document - this.queue.removeDocument(currentPath); - this.syncLocallyCreatedFile(currentPath); - return; - } - - await this.operations.delete(currentPath); - this.queue.removeDocument(currentPath); - - this.history.addHistoryEntry({ - status: SyncStatus.SUCCESS, - details: { - type: SyncType.DELETE, - relativePath: currentPath - }, - message: - "Successfully deleted file which had been deleted remotely", - author: remoteVersion.userId, - timestamp: new Date(remoteVersion.updatedDate) - }); - return; - } - - // Fetch the latest full version from the server - const fullVersion = await this.syncService.get({ - documentId: remoteVersion.documentId - }); - - // The document may have been deleted between the broadcast - // and the fetch — handle it the same as a remote delete - if (fullVersion.isDeleted) { - const contentBytes = await this.operations.read(currentPath); - const localHash = await hash(contentBytes); - if (localHash !== record.remoteHash) { - this.queue.removeDocument(currentPath); - this.syncLocallyCreatedFile(currentPath); - } else { - await this.operations.delete(currentPath); - this.queue.removeDocument(currentPath); - } - return; - } - - const contentBytes = await this.operations.read(currentPath); - const contentHash = await hash(contentBytes); - - const hasLocalChanges = record.remoteHash !== contentHash; - - if (hasLocalChanges) { - const response = await this.sendUpdate( - record, - currentPath, - contentBytes - ); - - await this.handleMaybeMergingResponse({ - path: currentPath, - response, - contentHash, - originalContentBytes: contentBytes - }); - - - this.history.addHistoryEntry({ - status: SyncStatus.SUCCESS, - details: { - type: SyncType.UPDATE, - relativePath: currentPath - }, - message: "Merged local changes with remote update", - author: response.userId, - timestamp: new Date(response.updatedDate) - }); - } else { - const responseBytes = base64ToBytes(fullVersion.contentBase64); - - // Handle remote path change - let actualPath = currentPath; - if ( - fullVersion.relativePath !== currentPath && - record.remoteRelativePath === currentPath - ) { - actualPath = fullVersion.relativePath; - await this.operations.delete(fullVersion.relativePath); - await this.operations.move( - currentPath, - fullVersion.relativePath - ); - } - - await this.operations.write( - actualPath, - contentBytes, - responseBytes - ); - - // Re-read and re-hash after write (the 3-way merge may produce different content) - const afterWriteBytes = await this.operations.read(actualPath); - const afterWriteHash = await hash(afterWriteBytes); - - this.queue.setDocument(actualPath, { - documentId: fullVersion.documentId, - parentVersionId: fullVersion.vaultUpdateId, - remoteHash: afterWriteHash, - remoteRelativePath: fullVersion.relativePath - }); - - // If the path changed, remove the old entry - if (actualPath !== currentPath) { - this.queue.removeDocument(currentPath); - } - - await this.updateCache( - fullVersion.vaultUpdateId, - responseBytes, - actualPath - ); - - this.history.addHistoryEntry({ - status: SyncStatus.SUCCESS, - details: - actualPath !== currentPath - ? { - type: SyncType.MOVE, - relativePath: actualPath, - movedFrom: currentPath - } - : { - type: SyncType.UPDATE, - relativePath: actualPath - }, - message: - "Successfully downloaded remotely updated file from the server", - author: fullVersion.userId, - timestamp: new Date(fullVersion.updatedDate) - }); - } - } - - private async processRemoteUpdateForNewDocument( - remoteVersion: DocumentVersionWithoutContent - ): Promise { - const oversizedEntry = this.getHistoryEntryForSkippedOversizedFile( - remoteVersion.contentSize, - remoteVersion.relativePath - ); - if (oversizedEntry !== undefined) { - this.history.addHistoryEntry(oversizedEntry); - return; - } - - const contentBytes = - await this.syncService.getDocumentVersionContent({ - documentId: remoteVersion.documentId, - vaultUpdateId: remoteVersion.vaultUpdateId - }); - - // A concurrent operation may have created the document already - const existingDoc = this.queue.getDocumentByDocumentId( - remoteVersion.documentId - ); - if (existingDoc !== undefined) { - this.logger.debug( - `Document ${remoteVersion.relativePath} has already been created locally` - ); - return; - } - - const deconflictedPath = await this.operations.ensureClearPath( - remoteVersion.relativePath - ); - if (deconflictedPath !== undefined) { - // The displaced file was moved to a deconflicted path. - // Remove its document record so the offline scan treats - // it as a new file rather than an existing document that - // needs its path synced (which would create duplicates) - this.queue.removeDocument(deconflictedPath); - } - - const contentHash = await hash(contentBytes); - this.queue.setDocument(remoteVersion.relativePath, { - documentId: remoteVersion.documentId, - parentVersionId: remoteVersion.vaultUpdateId, - remoteHash: contentHash, - remoteRelativePath: remoteVersion.relativePath - }); - - await this.operations.create( - remoteVersion.relativePath, - contentBytes - ); - - await this.updateCache( - remoteVersion.vaultUpdateId, - contentBytes, - remoteVersion.relativePath - ); - - - this.history.addHistoryEntry({ - status: SyncStatus.SUCCESS, - details: { - type: SyncType.CREATE, - relativePath: remoteVersion.relativePath - }, - message: - "Successfully downloaded remote file which hadn't existed locally", - author: remoteVersion.userId, - timestamp: new Date(remoteVersion.updatedDate) - }); - } - - - - private async sendUpdate( - record: DocumentRecord, - relativePath: RelativePath, - contentBytes: Uint8Array - ): Promise { - const isText = - !isBinary(contentBytes) && - isFileTypeMergable( - relativePath, - (await this.serverConfig.getConfig()).mergeableFileExtensions - ); - - const cachedVersion = this.contentCache.get(record.parentVersionId); - - 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) + await this.syncQueue.add(async () => + this.internalSyncer.unrestrictedSyncRemotelyUpdatedFile( + remoteVersion, + document ) - }); + ); + + resolve(); + } catch (e) { + reject(e); + } finally { + this.database.removeDocumentPromise(promise); } - return this.syncService.putBinary({ - documentId: record.documentId, - parentVersionId: record.parentVersionId, - relativePath, - contentBytes - }); + this.database.addSeenUpdateId(remoteVersion.vaultUpdateId); } - private async handleMaybeMergingResponse({ - path, - response, - contentHash, - originalContentBytes - }: { - path: RelativePath; - response: DocumentUpdateResponse; - contentHash: string; - originalContentBytes: Uint8Array; - }): Promise { - if (response.isDeleted) { - // If the local file has been edited, re-create it as a new - // document so local edits survive the remote delete - if (await this.operations.exists(path)) { - const localBytes = await this.operations.read(path); - const localHash = await hash(localBytes); - const record = this.queue.getSettledDocumentByPath(path); - if (record !== undefined && localHash !== record.remoteHash) { - this.queue.removeDocument(path); - this.syncLocallyCreatedFile(path); + 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( + `Document ${relativePath} might have been updated locally, scheduling sync to validate and update it` + ); + + return this.syncLocallyUpdatedFile({ + relativePath + }); + } + + // 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); + }); + + if (contentHash == undefined) { + // The file was deleted before we had a chance to read it, no need to sync it here return; } - } - await this.operations.delete(path); - this.queue.removeDocument(path); + + 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 */ + + 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); + }) + ); + } + + /** + * 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; } - let actualPath = path; + const [allLocalFiles, remote] = await awaitAll([ + this.operations.listFilesRecursively(), + this.syncQueue.add(async () => this.syncService.getAll()) + ]); - // Server may have changed the path (e.g. first-rename-wins conflict) - if (response.relativePath !== path) { - actualPath = response.relativePath; - const displacedPath = await this.operations.move( - path, - response.relativePath - ); - if (displacedPath !== undefined) { - const displacedRecord = - this.queue.getSettledDocumentByPath(displacedPath); - if (displacedRecord !== undefined) { - const displacedBytes = - await this.operations.read(displacedPath); - const displacedHash = await hash(displacedBytes); - if (displacedHash !== displacedRecord.remoteHash) { - this.queue.enqueue({ type: SyncEventType.SyncLocal, path: displacedPath }); - } - } - } - // Remove old path entry; the new path will be set below - this.queue.removeDocument(path); + 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 + ); + }); } - if ("type" in response && response.type === "MergingUpdate") { - const responseBytes = base64ToBytes(response.contentBase64); - await this.operations.write( - actualPath, - originalContentBytes, - responseBytes - ); - - // Re-read and re-hash after write (invariant #3) - const afterWriteBytes = await this.operations.read(actualPath); - const afterWriteHash = await hash(afterWriteBytes); - - this.queue.setDocument(actualPath, { - documentId: response.documentId, - parentVersionId: response.vaultUpdateId, - remoteHash: afterWriteHash, - remoteRelativePath: response.relativePath - }); - - // Cache the SERVER's content, not local (invariant #2) - await this.updateCache( - response.vaultUpdateId, - responseBytes, - actualPath - ); - } else { - // Fast-forward update: no merge needed - this.queue.setDocument(actualPath, { - documentId: response.documentId, - parentVersionId: response.vaultUpdateId, - remoteHash: contentHash, - remoteRelativePath: response.relativePath - }); - - await this.updateCache( - response.vaultUpdateId, - originalContentBytes, - actualPath - ); - } - } - - 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 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 as const, - relativePath - }, - message: `File size of ${sizeInMB} MB exceeds the maximum file size limit of ${maxFileSizeMB} MB` - }; - } - } - - private notifyRemainingOperationsChanged(): void { - const currentCount = this.queue.size; - 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 4db503c4..00000000 --- a/frontend/sync-client/src/sync-operations/types.ts +++ /dev/null @@ -1,56 +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; - remoteHash: string; - remoteRelativePath?: RelativePath; -} - -export interface StoredDocument extends DocumentRecord { - relativePath: RelativePath; -} - -export interface StoredSyncState { - documents: StoredDocument[]; - lastSeenUpdateId: VaultUpdateId | undefined; -} - -export enum SyncEventType { - Create = "create", - SyncLocal = "sync-local", - Delete = "delete", - SyncRemote = "sync-remote", -} - -export type FileSyncEvent = - | { type: SyncEventType.Create; path: RelativePath } - | { type: SyncEventType.SyncLocal; path: RelativePath; oldPath?: RelativePath } - | { type: SyncEventType.Delete; path: RelativePath } - | { type: SyncEventType.SyncRemote; remoteVersion: DocumentVersionWithoutContent }; - -export type SyncEvent = - | { - type: SyncEventType.Create; - path: RelativePath; // current path on disk - originalPath: RelativePath; // original path on disk when the event was created - resolvers?: PromiseWithResolvers - } - | { - type: SyncEventType.SyncLocal; - 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 created - } - | { - type: SyncEventType.Delete; - documentId: DocumentId | Promise; // if it's a promise, the promise is fulfilled once the document's create event is processed - } - | { - type: SyncEventType.SyncRemote; - 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 88b699fe..a0e0b348 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"; 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/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 fd8894b8..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 () => { @@ -265,7 +252,7 @@ describe("reset", () => { await sleep(1); const secondPromise = locks.withLock(testPath, async () => "second"); - void secondPromise.catch(() => { }); // eslint-disable-line @typescript-eslint/no-empty-function + void secondPromise.catch(() => {}); // eslint-disable-line @typescript-eslint/no-empty-function locks.reset(); @@ -286,7 +273,7 @@ describe("reset", () => { await sleep(1); const secondPromise = locks.withLock(testPath, async () => "second"); - void secondPromise.catch(() => { }); // eslint-disable-line @typescript-eslint/no-empty-function + void secondPromise.catch(() => {}); // eslint-disable-line @typescript-eslint/no-empty-function locks.reset(); @@ -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 4e512869..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,50 +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 @@ -61,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); }); } @@ -81,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()); } } @@ -89,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; @@ -111,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 @@ -132,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); } @@ -171,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 new file mode 100644 index 00000000..7b7271d7 --- /dev/null +++ b/frontend/sync-client/src/utils/data-structures/min-covered.test.ts @@ -0,0 +1,76 @@ +import { describe, it } from "node:test"; +import assert from "node:assert"; +import { CoveredValues } from "./min-covered"; + +describe("CoveredValues", () => { + it("should initialize with the given min value", () => { + const covered = new CoveredValues(5); + assert.strictEqual(covered.min, 5); + }); + + it("should add values greater than min", () => { + const covered = new CoveredValues(0); + covered.add(3); + assert.strictEqual(covered.min, 0); + covered.add(1); + assert.strictEqual(covered.min, 1); + covered.add(4); + assert.strictEqual(covered.min, 1); + covered.add(2); + assert.strictEqual(covered.min, 4); + }); + + it("should ignore duplicate values", () => { + const covered = new CoveredValues(0); + covered.add(3); + covered.add(3); + covered.add(3); + assert.strictEqual(covered.min, 0); + covered.add(1); + covered.add(2); + assert.strictEqual(covered.min, 3); + }); + + it("should handle multiple consecutive values", () => { + const covered = new CoveredValues(132); + for (let i = 250; i > 132; i--) { + assert.strictEqual(covered.min, 132); + covered.add(i); + } + assert.strictEqual(covered.min, 250); + }); + + it("should handle adding values lower than current min", () => { + 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 setting min value", () => { + const covered = new CoveredValues(5); + covered.add(7); + covered.add(8); + covered.add(9); + assert.strictEqual(covered.min, 5); + // 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 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 new file mode 100644 index 00000000..8b38822f --- /dev/null +++ b/frontend/sync-client/src/utils/data-structures/min-covered.ts @@ -0,0 +1,61 @@ +/** + * A class that tracks the minimum covered value in a sequence of numbers. + * It keeps track of a minimum value based on the seen values. + * + * It expects integers slightly out of order and makes sure that the value of `min` is + * always the minimum of the seen values. This is done with bounded memory usage. + * + * @example + * ```typescript + * const covered = new CoveredValues(0); + * covered.add(2); // seenValues = [2], min = 0 + * covered.add(1); // seenValues = [], min = 2 + * covered.min; // returns 2 + * ``` + */ +export class CoveredValues { + private seenValues: number[] = []; + + public constructor(private minValue: number) {} + + public get min(): number { + 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; + } + + let i = 0; + while (i < this.seenValues.length && this.seenValues[i] < value) { + i++; + } + + if (i === this.seenValues.length) { + this.seenValues.push(value); + } else if (this.seenValues[i] === value) { + return; + } else { + this.seenValues.splice(i, 0, value); + } + + this.advanceMinWhilePossible(); + } + + private advanceMinWhilePossible(): void { + while ( + this.seenValues.length > 0 && + this.seenValues[0] === this.minValue + 1 + ) { + this.seenValues.shift(); + this.minValue++; + } + } +} 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 f38335fe..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.reset; - 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 4c748925..c3d323d3 100644 --- a/frontend/sync-client/src/utils/find-matching-file.ts +++ b/frontend/sync-client/src/utils/find-matching-file.ts @@ -1,14 +1,14 @@ -import type { DocumentRecord, RelativePath } 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: { path: RelativePath; record: DocumentRecord }[] -): Promise<{ path: RelativePath; record: DocumentRecord } | undefined> { - if (contentHash === await EMPTY_HASH) { + candidates: DocumentRecord[] +): DocumentRecord | undefined { + if (contentHash === EMPTY_HASH) { return undefined; } - return candidates.find(({ record }) => 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 933929c5..906b6fad 100644 --- a/frontend/sync-client/src/utils/hash.ts +++ b/frontend/sync-client/src/utils/hash.ts @@ -1,11 +1,12 @@ -export async function hash(content: Uint8Array): Promise { - const digest = await crypto.subtle.digest( - "SHA-256", - content as Uint8Array - ); - 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 54373f50..52cbbce7 100644 --- a/frontend/sync-client/src/utils/rate-limit.ts +++ b/frontend/sync-client/src/utils/rate-limit.ts @@ -1,3 +1,4 @@ +import { createPromise } from "./create-promise"; import { sleep } from "./sleep"; /** @@ -44,7 +45,7 @@ export function rateLimit< newArgs = undefined; } - const { promise, resolve } = Promise.withResolvers(); + const [promise, resolve] = createPromise(); running = promise; sleep( typeof minIntervalMs === "function" 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 1422ac23..1640c2ec 100644 --- a/frontend/test-client/src/agent/mock-agent.ts +++ b/frontend/test-client/src/agent/mock-agent.ts @@ -4,20 +4,18 @@ 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 lastSyncEnabledState = true; public constructor( initialSettings: Partial, @@ -25,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); } @@ -52,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 ); @@ -66,11 +63,10 @@ export class MockAgent extends MockClient { 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; @@ -89,35 +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)); @@ -125,18 +99,19 @@ export class MockAgent extends MockClient { options.push(this.enableSyncAction.bind(this)); } + const files = await this.listFilesRecursively(); - options.push( - this.renameFileAction.bind(this), - this.updateFileAction.bind(this), - this.updateBinaryFileAction.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)); + if (this.doDeletes) { + options.push(this.deleteFileAction.bind(this, files)); + } } - if (Math.random() < 0.015 && this.doResets) { // we can't just queue this up as once it's destroyed, no more method calls can go to SyncClient await this.resetClient(); @@ -146,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}` ); @@ -178,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; } @@ -211,145 +161,102 @@ 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. - if (!this.useSlowFileEvents) { - 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. - if (!this.useSlowFileEvents) { - 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) { - 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; } } - 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); }); - if (!this.useSlowFileEvents) { + if (this.doDeletes) { assert( found.length <= 1, - `[${this.name}] Content ${content} found in multiple files: ${found.join(", ")}` + `[${this.name}] Content ${content} found in ${found.join(", ")}` ); - } - - if (!this.useSlowFileEvents && !this.doDeletes) { + } 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) { - if (this.useSlowFileEvents) { - this.client.logger.warn( - `Content ${content} (of ${this.name}) found more than once in '${file}'. File content:\n${fileContent}` - ); - } else { - assert( - false, - `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(", ")}` ); - } - if (!this.useSlowFileEvents && !this.doDeletes) { + const [file] = found; + const fileContent = new TextDecoder().decode( + this.localFiles.get(file) + ); assert( - found.length >= 1, - `[${this.name}] Binary content ${content} not found in any files` + fileContent.split(content).length == 2, + `Content ${content} (of ${this.name}) found more than once in '${file}'. File content:\n${fileContent}` ); } } } - private async resetClient(): Promise { this.client.logger.info(`Resetting client ${this.name}`); await this.client.destroy(); @@ -360,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)) ) { @@ -372,54 +279,26 @@ export class MockAgent extends MockClient { `Decided to create file ${file} with content ${content}` ); - - 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}` - ); - - 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.client.getSettings().isSyncEnabled && this.doNotTouchWhileOffline.includes(file) ) { this.client.logger.info( @@ -428,17 +307,10 @@ export class MockAgent extends MockClient { 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)) ) { @@ -448,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( @@ -479,59 +343,16 @@ export class MockAgent extends MockClient { `Decided to update file ${file} with ${content}` ); this.doNotTouchWhileOffline.push(file); - await this.atomicUpdateText( - file, - (old) => ({ - text: old.text + ` ${content} `, - cursors: [] - }) - ); - + await this.atomicUpdateText(file, (old) => ({ + text: old.text + ` ${content} `, + cursors: [] + })); } - 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, 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); - + return this.delete(file); } private getContent(): string { @@ -540,29 +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); - const idx = this.writtenBinaryContents.indexOf(uuid); - if (idx !== -1) this.writtenBinaryContents.splice(idx, 1); - } - - 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 5d816aa4..c814879a 100644 --- a/frontend/test-client/src/agent/mock-client.ts +++ b/frontend/test-client/src/agent/mock-client.ts @@ -2,24 +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; - }> = {}; + }> = { + 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; } @@ -40,78 +46,147 @@ 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 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)}` + ); - public override async delete(path: RelativePath): Promise { - this.files.delete(path); - this.executeFileOperation( - async () => { this.client.syncLocallyDeletedFile(path); }, + this.executeFileOperation(async () => { + if (hasExisted) { + return this.client.syncLocallyUpdatedFile({ + relativePath: path + }); + } else { + return this.client.syncLocallyCreatedFile(path); + } + }); + } + + 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 override async rename( + 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.syncLocallyUpdatedFile({ + + 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 setTimeout(callback, Math.random() * 100); diff --git a/frontend/test-client/src/cli.ts b/frontend/test-client/src/cli.ts index 0fcd975b..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,110 +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; } } @@ -173,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, @@ -181,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) => { @@ -246,19 +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; } @@ -281,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); }); @@ -292,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 cf40a76c..00000000 --- a/frontend/test-client/src/utils/test-error-tracker.ts +++ /dev/null @@ -1,25 +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/package-lock.json b/package-lock.json deleted file mode 100644 index 9e0474fd..00000000 --- a/package-lock.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "vault-link", - "lockfileVersion": 3, - "requires": true, - "packages": {} -} diff --git a/scripts/clean-up.sh b/scripts/clean-up.sh index dcf400bb..4dfbf4a0 100755 --- a/scripts/clean-up.sh +++ b/scripts/clean-up.sh @@ -1,4 +1,4 @@ #!/bin/bash -rm -rf /host/tmp/vaultlink-e2e-databases +rm -rf sync-server/databases rm -rf logs diff --git a/scripts/e2e.sh b/scripts/e2e.sh index d3ebefb2..6c66e835 100755 --- a/scripts/e2e.sh +++ b/scripts/e2e.sh @@ -19,48 +19,35 @@ process_count=$1 mkdir -p logs -# Build and restart the server -echo "Building server..." -cd sync-server -cargo build --release - -# Kill any existing server process -echo "Stopping existing server..." -pkill -f "sync_server" 2>/dev/null || true -sleep 1 - -# Clean databases (uses tmpfs via /dev/shm for zero disk I/O) -echo "Cleaning databases..." -rm -rf /host/tmp/vaultlink-e2e-databases - -# Start the server in the background -echo "Starting server..." -./target/release/sync_server config-e2e.yml & -server_pid=$! -echo "Server started with PID: $server_pid" - -# Ensure server is killed on script exit -cleanup_server() { - echo "Stopping server (PID: $server_pid)..." - kill $server_pid 2>/dev/null || true - wait $server_pid 2>/dev/null || true -} -trap cleanup_server EXIT - -cd .. - cd frontend npm ci npm run build ../scripts/utils/wait-for-server.sh +cd .. +scripts/update-api-types.sh +if [[ $(git status --porcelain) ]]; then + git status --porcelain + echo "Failing CI because the working directory is not clean after generating api types" + exit 1 +fi +cd frontend + pids=() for i in $(seq 1 $process_count); do - node test-client/dist/cli.js > "../logs/log_${i}.log" 2>&1 & + # Create a named pipe for this process + pipe="/tmp/vaultlink_pipe_$$_$i" + mkfifo "$pipe" + + # Start the node process writing to the pipe + node test-client/dist/cli.js > "$pipe" 2>&1 & pid=$! pids+=($pid) - echo "Started process $i with PID: $pid (log: logs/log_${i}.log)" + echo "Started process $i with PID: $pid" + + # Read from pipe, prefix with PID + (sed "s/^/[PID $pid] /" < "$pipe" > "../logs/log_${i}.log"; rm "$pipe") & done cd .. @@ -88,25 +75,10 @@ print_failed_log() { return 1 } -E2E_TIMEOUT=${2:-3600} -start_time=$(date +%s) -echo "Monitoring $process_count processes (timeout: ${E2E_TIMEOUT}s)" +echo "Monitoring $process_count processes" # Monitor processes while true; do - # Script-level timeout to prevent indefinite hangs - current_time=$(date +%s) - elapsed=$((current_time - start_time)) - if [ $elapsed -ge $E2E_TIMEOUT ]; then - echo "E2E timeout reached (${E2E_TIMEOUT}s). Killing remaining processes." - for pid in "${pids[@]}"; do - if [ -n "$pid" ]; then - kill $pid 2>/dev/null || true - fi - done - exit 1 - fi - if print_failed_log; then # Kill remaining processes for pid in "${pids[@]}"; do diff --git a/scripts/update-api-types.sh b/scripts/update-api-types.sh index 1f10944c..36ca100d 100755 --- a/scripts/update-api-types.sh +++ b/scripts/update-api-types.sh @@ -9,7 +9,6 @@ cargo test export_bindings cd - cp -r sync-server/bindings/* frontend/sync-client/src/services/types/ -cp -r sync-server/bindings/* frontend/history-ui/src/lib/types/ cd frontend npm run lint 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 d0f76446..96b3c199 100644 --- a/sync-server/config-e2e.yml +++ b/sync-server/config-e2e.yml @@ -1,5 +1,5 @@ database: - databases_directory_path: /host/tmp/vaultlink-e2e-databases + databases_directory_path: databases max_connections_per_vault: 8 cursor_timeout: 1m server: diff --git a/sync-server/src/app_state/database.rs b/sync-server/src/app_state/database.rs index c91bc28a..b0ef0ee7 100644 --- a/sync-server/src/app_state/database.rs +++ b/sync-server/src/app_state/database.rs @@ -1,12 +1,12 @@ 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; @@ -42,8 +42,7 @@ struct VaultPools { #[derive(Debug)] struct VaultPool { cell: Arc>, - /// Monotonic timestamp in milliseconds (from `Instant::now()` at server start) - last_accessed_ms: AtomicU64, + last_accessed: Mutex, } #[derive(Clone, Debug)] @@ -58,8 +57,6 @@ pub struct Database { /// 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, } /// A write transaction backed by a raw `BEGIN IMMEDIATE` instead of sqlx's @@ -174,49 +171,6 @@ fn rollback_before_acquire( } 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, @@ -255,7 +209,7 @@ impl Database { vault.clone(), Arc::new(VaultPool { cell, - last_accessed_ms: AtomicU64::new(0), + last_accessed: Mutex::new(Instant::now()), }), ); } @@ -266,7 +220,6 @@ impl Database { 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); @@ -393,7 +346,7 @@ impl Database { .or_insert_with(|| { Arc::new(VaultPool { cell: Arc::new(OnceCell::new()), - last_accessed_ms: AtomicU64::new(self.now_ms()), + last_accessed: Mutex::new(Instant::now()), }) }) .clone() @@ -411,7 +364,7 @@ impl Database { }) .await?; - vault_pool.last_accessed_ms.store(self.now_ms(), Ordering::Relaxed); + *vault_pool.last_accessed.lock().await = Instant::now(); Ok(pools.clone()) } @@ -663,12 +616,12 @@ 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!( @@ -697,20 +650,22 @@ impl Database { version.has_been_merged ); - // 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")?; + transaction + .commit() + .await + .context("Failed to commit transaction")?; + } else { + query + .execute(&self.get_connection_pool(vault_id).await?) + .await + .context("Cannot insert document version")?; + } self.broadcasts .send_document_update( @@ -728,140 +683,6 @@ impl Database { 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, - 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), - }) - .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), - }; - - if let Some(before) = before_update_id { - let query = sqlx::query_as!( - models::VaultHistoryRow, - r#" - select - 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, - 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 @@ -869,14 +690,16 @@ impl Database { // 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 now = Instant::now(); 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 + // If the lock is contested, the pool is actively used — not idle. + let Ok(last) = vp.last_accessed.try_lock() else { + return false; + }; + now.duration_since(*last) > IDLE_POOL_TIMEOUT }) .map(|(vault_id, _)| vault_id.clone()) .collect(); @@ -887,48 +710,21 @@ impl Database { .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(); - - 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; + for (vault_id, vault_pool) in idle_pools { + if let Some(pools) = vault_pool.cell.get() { + // Checkpoint the WAL before closing to reclaim disk space + // and ensure the next open doesn't need a large WAL replay. + // TRUNCATE mode resets the WAL file to zero bytes. + if let Err(e) = sqlx::query("PRAGMA wal_checkpoint(TRUNCATE)") + .execute(&pools.writer) + .await + { + log::warn!("WAL checkpoint failed for vault `{vault_id}`: {e}"); + } + info!("Closing idle database connection pools for vault `{vault_id}`"); + pools.reader.close().await; + pools.writer.close().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/models.rs b/sync-server/src/app_state/database/models.rs index 7aea3358..59d08c82 100644 --- a/sync-server/src/app_state/database/models.rs +++ b/sync-server/src/app_state/database/models.rs @@ -77,24 +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 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 91183970..cf359497 100644 --- a/sync-server/src/app_state/websocket/broadcasts.rs +++ b/sync-server/src/app_state/websocket/broadcasts.rs @@ -10,7 +10,6 @@ use crate::{app_state::database::models::VaultId, config::server_config::ServerC pub struct Broadcasts { broadcast_channel_capacity: usize, tx: Arc>>>, - send_locks: Arc>>>>, } type TxMap = HashMap>; @@ -20,23 +19,9 @@ impl Broadcasts { Self { broadcast_channel_capacity: server_config.broadcast_channel_capacity, tx: Arc::new(Mutex::new(HashMap::new())), - send_locks: 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) { tx_map.retain(|_, sender| sender.receiver_count() > 0); diff --git a/sync-server/src/app_state/websocket/utils.rs b/sync-server/src/app_state/websocket/utils.rs index 24bc287a..ce8205fa 100644 --- a/sync-server/src/app_state/websocket/utils.rs +++ b/sync-server/src/app_state/websocket/utils.rs @@ -33,7 +33,7 @@ pub fn get_authenticated_handshake( let user = auth(state, handshake.token.trim(), vault_id)?; Ok(AuthenticatedWebSocketHandshake { handshake, user }) } - WebSocketClientMessage::CursorPositions(_) => Err( + WebSocketClientMessage::CursorPositions(_) | WebSocketClientMessage::Ping {} => Err( unauthenticated_error(anyhow::anyhow!("Expected a handshake message")), ), } diff --git a/sync-server/src/server.rs b/sync-server/src/server.rs index 0835e9b6..95b0038b 100644 --- a/sync-server/src/server.rs +++ b/sync-server/src/server.rs @@ -4,27 +4,24 @@ 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 restore_document_version; 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; @@ -55,11 +52,9 @@ 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); @@ -111,7 +106,7 @@ 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"); + info!("CORS: allowing all origins (wildcard)"); let header: HeaderValue = "*" .parse() .context("Failed to parse wildcard CORS origin")?; @@ -158,10 +153,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), @@ -174,14 +165,6 @@ fn get_authed_routes(app_state: AppState) -> Router { "/vaults/:vault_id/documents/:document_id", delete(delete_document::delete_document), ) - .route( - "/vaults/:vault_id/documents/:document_id/restore", - post(restore_document_version::restore_document_version), - ) - .route( - "/vaults/:vault_id/history", - get(fetch_vault_history::fetch_vault_history), - ) .layer(middleware::from_fn_with_state(app_state, auth_middleware)) } diff --git a/sync-server/src/server/auth.rs b/sync-server/src/server/auth.rs index 7fa45abd..3b5474d4 100644 --- a/sync-server/src/server/auth.rs +++ b/sync-server/src/server/auth.rs @@ -41,17 +41,13 @@ 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, diff --git a/sync-server/src/server/create_document.rs b/sync-server/src/server/create_document.rs index 89941b9a..39560ef8 100644 --- a/sync-server/src/server/create_document.rs +++ b/sync-server/src/server/create_document.rs @@ -1,3 +1,4 @@ +use anyhow::Context as _; use axum::{ Extension, Json, extract::{Path, State}, @@ -5,6 +6,7 @@ use axum::{ use axum_extra::TypedHeader; use axum_typed_multipart::TypedMultipart; use log::{debug, info}; +use reconcile_text::{BuiltinTokenizer, reconcile}; use serde::Deserialize; use super::{device_id_header::DeviceIdHeader, requests::CreateDocumentVersion}; @@ -130,7 +132,7 @@ 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)?; diff --git a/sync-server/src/server/delete_document.rs b/sync-server/src/server/delete_document.rs index 3e6398b8..0083505e 100644 --- a/sync-server/src/server/delete_document.rs +++ b/sync-server/src/server/delete_document.rs @@ -1,4 +1,4 @@ -use anyhow::Context; +use anyhow::{Context, anyhow}; use axum::{ Extension, Json, extract::{Path, State}, @@ -16,8 +16,8 @@ use crate::{ }, }, config::user_config::User, - errors::{SyncServerError, server_error, write_transaction_error}, - utils::normalize::normalize, + errors::{SyncServerError, client_error, not_found_error, server_error, write_transaction_error}, + utils::{normalize::normalize, sanitize_path::sanitize_path}, }; #[derive(Deserialize)] @@ -72,15 +72,12 @@ pub async fn delete_document( return Ok(Json(latest_version.clone().into())); } - let (latest_relative_path, latest_content) = latest_version.map_or_else( - || (String::new(), Vec::new()), - |version| (version.relative_path, version.content), - ); + 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: last_update_id + 1, document_id, - relative_path: latest_relative_path, + relative_path: sanitize_path(&request.relative_path).map_err(client_error)?, content: latest_content, // copy the content from the latest version updated_date: chrono::Utc::now(), is_deleted: true, @@ -91,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/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_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/responses.rs b/sync-server/src/server/responses.rs index f393747d..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,35 +36,6 @@ 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 an update document request. #[derive(TS, Debug, Clone, Serialize)] #[serde(tag = "type")] diff --git a/sync-server/src/server/restore_document_version.rs b/sync-server/src/server/restore_document_version.rs deleted file mode 100644 index 36c0344e..00000000 --- a/sync-server/src/server/restore_document_version.rs +++ /dev/null @@ -1,147 +0,0 @@ -use anyhow::anyhow; -use axum::{ - Extension, Json, - extract::{Path, State}, -}; -use axum_extra::TypedHeader; -use log::{debug, info}; -use serde::Deserialize; - -use super::device_id_header::DeviceIdHeader; -use crate::{ - app_state::{ - AppState, - database::models::{ - DocumentId, DocumentVersionWithoutContent, StoredDocumentVersion, VaultId, - VaultUpdateId, - }, - }, - config::user_config::User, - errors::{SyncServerError, client_error, not_found_error, server_error, write_transaction_error}, - utils::{find_first_available_path::find_first_available_path, normalize::normalize}, -}; - -#[derive(Deserialize)] -pub struct RestorePathParams { - #[serde(deserialize_with = "normalize")] - vault_id: VaultId, - - document_id: DocumentId, -} - -#[derive(Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct RestoreDocumentVersionRequest { - pub vault_update_id: VaultUpdateId, -} - -#[axum::debug_handler] -pub async fn restore_document_version( - Path(RestorePathParams { - vault_id, - document_id, - }): Path, - Extension(user): Extension, - TypedHeader(device_id): TypedHeader, - State(state): State, - Json(request): Json, -) -> Result, SyncServerError> { - debug!( - "Restoring document `{document_id}` in vault `{vault_id}` to version `{}`", - request.vault_update_id - ); - - if request.vault_update_id <= 0 { - return Err(client_error(anyhow!( - "Invalid vault_update_id: `{}`", - request.vault_update_id - ))); - } - - let mut transaction = state - .database - .create_write_transaction(&vault_id) - .await - .map_err(write_transaction_error)?; - - let target_version = state - .database - .get_document_version(&vault_id, request.vault_update_id, Some(&mut *transaction)) - .await - .map_err(server_error)? - .ok_or_else(|| { - not_found_error(anyhow!("Version `{}` not found", request.vault_update_id)) - })?; - - if target_version.document_id != document_id { - transaction.rollback().await.map_err(server_error)?; - return Err(not_found_error(anyhow!( - "Version `{}` does not belong to document `{document_id}`", - request.vault_update_id, - ))); - } - - if target_version.is_deleted { - transaction.rollback().await.map_err(server_error)?; - return Err(client_error(anyhow!( - "Cannot restore to a deleted version `{}`", - request.vault_update_id, - ))); - } - - let existing = state - .database - .get_latest_non_deleted_document_by_path( - &vault_id, - &target_version.relative_path, - Some(&mut *transaction), - ) - .await - .map_err(server_error)?; - - let restore_path = if let Some(existing_doc) = &existing - && existing_doc.document_id != document_id - { - find_first_available_path( - &vault_id, - &target_version.relative_path, - &state.database, - &mut transaction, - ) - .await - .map_err(server_error)? - } else { - target_version.relative_path.clone() - }; - - let last_update_id = state - .database - .get_max_update_id_in_vault(&vault_id, Some(&mut *transaction)) - .await - .map_err(server_error)?; - - let new_version = StoredDocumentVersion { - vault_update_id: last_update_id + 1, - document_id, - relative_path: restore_path, - content: target_version.content, - updated_date: chrono::Utc::now(), - is_deleted: false, - user_id: user.name.clone(), - device_id: device_id.0.clone(), - has_been_merged: false, - }; - - state - .database - .insert_document_version(&vault_id, &new_version, transaction) - .await - .map_err(server_error)?; - - info!( - "Restored document `{document_id}` to version `{}` as new version `{}`", - request.vault_update_id, new_version.vault_update_id - ); - - Ok(Json(new_version.into())) -} diff --git a/sync-server/src/server/update_document.rs b/sync-server/src/server/update_document.rs index b6227de5..bd6c6586 100644 --- a/sync-server/src/server/update_document.rs +++ b/sync-server/src/server/update_document.rs @@ -237,27 +237,16 @@ pub async fn update_document( 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}")))?; - + let merged = reconcile( + parent_text, + &latest_text.into(), + &new_text.into(), + &*BuiltinTokenizer::Word, + ) + .apply() + .text() + .into_bytes(); + let is_different = merged != content; (merged, is_different) } 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 @@ -306,7 +295,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)?;