diff --git a/.gitignore b/.gitignore index 138531a..0e25f47 100644 --- a/.gitignore +++ b/.gitignore @@ -86,3 +86,9 @@ pids/ # Optional stylelint cache .stylelintcache .claude + +# Nix +result + +# pnpm store +.pnpm-store/ diff --git a/CLAUDE.md b/CLAUDE.md index 4d72f84..99315a5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -136,3 +136,46 @@ Used throughout sync-engine: if heads are available, calls `handle.changeAt(head - **Server load.** `enableRemoteHeadsGossiping` is disabled — pushwork syncs directly with the server so the gossip protocol is unnecessary overhead. `waitForSync` processes documents in batches of 10 (`SYNC_BATCH_SIZE`) to avoid flooding the server with concurrent sync messages. Without batching, syncing 100+ documents simultaneously can overwhelm the sync server (which is single-threaded with no backpressure). - **`waitForBidirectionalSync` on large trees.** Full tree traversal (`getAllDocumentHeads`) is expensive because it `repo.find()`s every document. For post-push stabilization, pass the `handles` option to only check documents that actually changed. The initial pre-pull call still needs the full scan to discover remote changes. The dynamic timeout adds the first scan's duration on top of the base timeout, since the first scan is just establishing baseline — its duration shouldn't count against stability-wait time. - **Versioned URLs and `repo.find()`.** `repo.find(versionedUrl)` returns a view handle whose `.heads()` returns the VERSION heads, not the current document heads. Always use `getPlainUrl()` when you need the current/mutable state. The snapshot head update loop at the end of `sync()` must use `getPlainUrl(snapshotEntry.url)` — without this, artifact directories (which store versioned URLs) get stale heads written to the snapshot, causing `changeAt()` to fork from the wrong point on the next sync. This was the root cause of the artifact deletion resurrection bug: `batchUpdateDirectory` would `changeAt` from an empty directory state where the file entry didn't exist yet, so the splice found nothing to delete. + +## Subduction sync backend (`--sub`) + +The `--sub` flag switches from the default WebSocket sync adapter to the Subduction backend built into `automerge-repo@2.6.0-subduction.9`. The Repo manages a `SubductionSource` internally — pushwork just passes `subductionWebsocketEndpoints` and the Repo handles connection management, sync, and retries. + +### How it works + +- `repo-factory.ts`: Initializes Subduction Wasm via ESM dynamic import, then creates Repo. When `sub: true`, passes `subductionWebsocketEndpoints: [syncServer]` and `periodicSyncInterval: 2000` (CLI needs fast sync, not the default 10s). When `sub: false`, uses the traditional WebSocket network adapter instead. +- Default server: `wss://subduction.sync.inkandswitch.com` (vs `wss://sync3.automerge.org` for WebSocket) +- `network-sync.ts`: When no `StorageId` is provided (Subduction mode), `waitForSync` falls back to head-stability polling (3 consecutive stable checks at 100ms intervals) instead of `getSyncInfo`-based verification +- `sync-engine.ts`: In sub mode, skips `recreateFailedDocuments` — SubductionSource has its own heal-sync retry logic +- Everything else (push/pull phases, artifact handling, `nukeAndRebuildDocs`, change detection) is identical + +### Wasm initialization + +As of `automerge-repo@2.6.0-subduction.9`, the Repo constructor _always_ creates a `SubductionSource` internally (even without Subduction endpoints), which imports `MemorySigner` and `set_subduction_logger` from `@automerge/automerge-subduction/slim`. The `/slim` entry does NOT auto-init the Wasm — so Wasm must be initialized before _any_ `new Repo()` call, including the default WebSocket path. + +`automerge-repo` exports `initSubduction()` which dynamically imports `@automerge/automerge-subduction` (the non-`/slim` entry that auto-inits Wasm). Pushwork calls this via `repoMod.initSubduction()` after loading the Repo module — no direct dependency on `@automerge/automerge-subduction` is needed. + +`repo-factory.ts` uses a `new Function("specifier", "return import(specifier)")` wrapper to perform _real_ ESM `import()` calls that Node.js evaluates as ESM. This is necessary because TypeScript with `"module": "commonjs"` compiles `await import("x")` to `require("x")`, which resolves CJS entries. The CJS and ESM module graphs have separate Wasm instances, so initializing via CJS `require()` doesn't help the ESM `/slim` imports inside `automerge-repo`. The `new Function` trick bypasses tsc's transformation and shares the same ESM module graph as the Repo's internal imports. + +The Repo class itself is also loaded via this ESM dynamic import (cached after first call) so that `new Repo()` sees the initialized Wasm module. + +### Packaging notes + +- `automerge-repo@2.6.0-subduction.9` correctly pins `@automerge/automerge-subduction@0.7.0` — no pnpm override needed (unlike subduction.7 which required an override to fix a version mismatch). +- `RepoConfig` now properly types all Subduction options (`subductionWebsocketEndpoints`, `periodicSyncInterval`, `batchSyncInterval`, `signer`, `subductionPolicy`, `subductionAdapters`) — no `as any` cast needed. +- The `automerge-repo-network-websocket` adapter's `NetworkAdapter` types are slightly behind the repo's `NetworkAdapterInterface` (missing `state()` method in declarations). The adapter works at runtime; the type mismatch is worked around with `as unknown as NetworkAdapterInterface`. +- New `"heal-exhausted"` event on Repo fires when self-healing sync gives up after all retry attempts for a document. Not currently used by pushwork but available for better error reporting. + +### Subduction mode persistence + +`--sub` is only accepted on `init` and `clone`. It persists `subduction: true` in `.pushwork/config.json`. All subsequent commands (`sync`, `watch`, etc.) read it from config via `config.subduction ?? false`. The force-defaults path in `setupCommandContext` preserves `subduction` alongside `root_directory_url`. + +When Subduction mode is active, commands print a banner: "Using Subduction sync backend (from config)". + +Every `sync` run prints the root Automerge URL at the end. + +### Corrupt storage recovery + +`repo-factory.ts` scans `.pushwork/automerge/` for 0-byte files before creating the Repo. These indicate incomplete writes from a previous run (process exited before storage flushed). If any are found, the entire automerge cache is wiped and recreated — data will re-download from the sync server. The snapshot (`.pushwork/snapshot.json`) is preserved so all document URLs are retained. + +This is a safety net for the Subduction `HydrationError: LooseCommit too short` crash. The upstream fix (`Repo.shutdown()` now calls `flush()` and `SubductionSource.shutdown()` awaits pending writes) prevents the corruption from happening in the first place, but edge cases (SIGKILL, OOM, power loss) can still produce 0-byte files. diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..47bc443 --- /dev/null +++ b/flake.lock @@ -0,0 +1,128 @@ +{ + "nodes": { + "command-utils": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + }, + "locked": { + "lastModified": 1769220798, + "narHash": "sha256-ulD8bbh5eV4rUH61JC4gS8Ik0R2hBEEyCom3f8w2vXE=", + "ref": "refs/heads/main", + "rev": "6c72a70e0241a5af26ba664ab63f3e2d89c45cd0", + "revCount": 5, + "type": "git", + "url": "https://codeberg.org/expede/nix-command-utils" + }, + "original": { + "type": "git", + "url": "https://codeberg.org/expede/nix-command-utils" + } + }, + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1709126324, + "narHash": "sha256-q6EQdSeUZOG26WelxqkmR7kArjgWCdw5sfJVHPH/7j8=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "d465f4819400de7c8d874d50b982301f28a84605", + "type": "github" + }, + "original": { + "id": "flake-utils", + "type": "indirect" + } + }, + "flake-utils_2": { + "inputs": { + "systems": "systems_2" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1769089682, + "narHash": "sha256-9yA/LIuAVQq0lXelrZPjLuLVuZdm03p8tfmHhnDIkms=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "078d69f03934859a181e81ba987c2bb033eebfc5", + "type": "github" + }, + "original": { + "id": "nixpkgs", + "ref": "nixos-25.11", + "type": "indirect" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1775002709, + "narHash": "sha256-d3Yx83vSrN+2z/loBh4mJpyRqr9aAJqlke4TkpFmRJA=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "bcd464ccd2a1a7cd09aa2f8d4ffba83b761b1d0e", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-25.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "command-utils": "command-utils", + "flake-utils": "flake-utils_2", + "nixpkgs": "nixpkgs_2" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "systems_2": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..d34bb9d --- /dev/null +++ b/flake.nix @@ -0,0 +1,66 @@ +{ + description = "Pushwork: Bidirectional directory synchronization using Automerge CRDTs"; + + inputs = { + command-utils.url = "git+https://codeberg.org/expede/nix-command-utils"; + flake-utils.url = "github:numtide/flake-utils"; + nixpkgs.url = "github:nixos/nixpkgs/nixos-25.11"; + }; + + outputs = { + self, + command-utils, + flake-utils, + nixpkgs, + }: + flake-utils.lib.eachDefaultSystem (system: let + pkgs = import nixpkgs {inherit system;}; + + nodejs = pkgs.nodejs_24; + pnpm-pkg = pkgs.pnpm; + pnpm' = "${pnpm-pkg}/bin/pnpm"; + + asModule = command-utils.asModule.${system}; + cmd = command-utils.cmd.${system}; + pnpm = command-utils.pnpm.${system}; + + pnpm-cfg = {pnpm = pnpm';}; + + menu = + command-utils.commands.${system} + [ + (pnpm.build pnpm-cfg) + (pnpm.dev pnpm-cfg) + (pnpm.install pnpm-cfg) + (pnpm.lint pnpm-cfg) + (pnpm.test pnpm-cfg) + (pnpm.typecheck pnpm-cfg) + (asModule { + "clean" = cmd "Remove dist and node_modules" "rm -rf dist node_modules"; + "start" = cmd "Run pushwork CLI" "node dist/cli.js \"$@\""; + "sync" = cmd "Build and run sync" "${pnpm'} build && node dist/cli.js sync \"$@\""; + "watch" = cmd "Watch, build, and sync loop" "node dist/cli.js watch \"$@\""; + }) + ]; + in { + devShells.default = pkgs.mkShell { + name = "Pushwork Dev Shell"; + + nativeBuildInputs = + [ + nodejs + pkgs.nodePackages.vscode-langservers-extracted + pkgs.typescript + pkgs.typescript-language-server + pnpm-pkg + ] + ++ menu; + + shellHook = '' + menu + ''; + }; + + formatter = pkgs.alejandra; + }); +} diff --git a/package.json b/package.json index 81605ec..1d200ad 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pushwork", - "version": "1.1.8", + "version": "1.1.8-subduction", "description": "Bidirectional directory synchronization using Automerge CRDTs", "main": "dist/index.js", "exports": { @@ -38,11 +38,11 @@ "author": "Peter van Hardenberg", "license": "MIT", "dependencies": { - "@automerge/automerge": "^3.2.4", - "@automerge/automerge-repo": "^2.5.3", - "@automerge/automerge-repo-network-websocket": "^2.5.3", - "@automerge/automerge-repo-storage-indexeddb": "^2.5.3", - "@automerge/automerge-repo-storage-nodefs": "^2.5.3", + "@automerge/automerge": "^3.2.5", + "@automerge/automerge-repo": "2.6.0-subduction.12", + "@automerge/automerge-repo-network-websocket": "2.6.0-subduction.12", + "@automerge/automerge-repo-storage-nodefs": "2.6.0-subduction.12", + "@automerge/automerge-subduction": "0.7.0", "@commander-js/extra-typings": "^14.0.0", "chalk": "^5.3.0", "commander": "^14.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9c53aca..cce8fcd 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,20 +9,20 @@ importers: .: dependencies: '@automerge/automerge': - specifier: ^3.2.4 - version: 3.2.4 + specifier: ^3.2.5 + version: 3.2.5 '@automerge/automerge-repo': - specifier: ^2.5.3 - version: 2.5.4 + specifier: 2.6.0-subduction.12 + version: 2.6.0-subduction.12 '@automerge/automerge-repo-network-websocket': - specifier: ^2.5.3 - version: 2.5.4 - '@automerge/automerge-repo-storage-indexeddb': - specifier: ^2.5.3 - version: 2.5.4 + specifier: 2.6.0-subduction.12 + version: 2.6.0-subduction.12 '@automerge/automerge-repo-storage-nodefs': - specifier: ^2.5.3 - version: 2.5.4 + specifier: 2.6.0-subduction.12 + version: 2.6.0-subduction.12 + '@automerge/automerge-subduction': + specifier: 0.7.0 + version: 0.7.0 '@commander-js/extra-typings': specifier: ^14.0.0 version: 14.0.0(commander@14.0.3) @@ -34,7 +34,7 @@ importers: version: 14.0.3 diff: specifier: ^8.0.2 - version: 8.0.3 + version: 8.0.4 glob: specifier: ^10.3.0 version: 10.5.0 @@ -65,7 +65,7 @@ importers: version: 2.1.4 '@types/node': specifier: ^20.0.0 - version: 20.19.37 + version: 20.19.39 '@types/tmp': specifier: ^0.2.4 version: 0.2.6 @@ -77,13 +77,13 @@ importers: version: 4.6.0 jest: specifier: ^29.7.0 - version: 29.7.0(@types/node@20.19.37) + version: 29.7.0(@types/node@20.19.39) tmp: specifier: ^0.2.1 version: 0.2.5 ts-jest: specifier: ^29.1.0 - version: 29.4.6(@babel/core@7.29.0)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@20.19.37))(typescript@5.9.3) + version: 29.4.9(@babel/core@7.29.0)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@20.19.39))(typescript@5.9.3) tsx: specifier: ^4.19.2 version: 4.21.0 @@ -93,20 +93,20 @@ importers: packages: - '@automerge/automerge-repo-network-websocket@2.5.4': - resolution: {integrity: sha512-3XZmwZCSD2M2iDNsmVn04F2MZ2RMXDxfngw7vYxXvBx5mNvN39Fjsg/RDr356Zvrz3Nq+ckXbZiCT5MfGgwxdQ==} + '@automerge/automerge-repo-network-websocket@2.6.0-subduction.12': + resolution: {integrity: sha512-h50j3C/cTjPp1+rSZT03tvQlOhroSaCFyhZ7Qibb1Xa3STxVp1vMVKW975SqTiR9aw/Ijn2n18cSsDqCaAFJTg==} - '@automerge/automerge-repo-storage-indexeddb@2.5.4': - resolution: {integrity: sha512-si/DF/cl39DgJX7eq8/gAZiilc8ldhWc8OST76UgWD/edAkftIPQCOm9VDBm5t5rVO3defeUWIy1uNqnFFQHuQ==} + '@automerge/automerge-repo-storage-nodefs@2.6.0-subduction.12': + resolution: {integrity: sha512-u4+QplWz/dAH0jBEA273aNR6alF/gvSLc3NpELFYI6BYkKhuGmbBJOr8c9qTUMQ8SF4UkPU4vSgo97O3nQWRhw==} - '@automerge/automerge-repo-storage-nodefs@2.5.4': - resolution: {integrity: sha512-VzYba3yB97rZLwom0az5EOW2k+UN1XnMrgtXAIH47G5BhIt/3E8UMdlJrSHzNB8J4p4cOiTb/RGb2xRz66yB6g==} + '@automerge/automerge-repo@2.6.0-subduction.12': + resolution: {integrity: sha512-yyvifpGsmiEs6fWSkNShEPwwMRn/Tp9ciwPwvYk3+0HQPUXMZ7SFpto1pzAHj1AYxqIOt+J5d28Nt7fIo6z4hQ==} - '@automerge/automerge-repo@2.5.4': - resolution: {integrity: sha512-+QDFxcyQN1UimePMlcHs1RI/bIwl0VmngHFWz1poVF6MbJLqNvi5T+fHTU1zkf9zcmb15/I76s8WEj0/8EE+/g==} + '@automerge/automerge-subduction@0.7.0': + resolution: {integrity: sha512-I838jrX+j6zeLEIyszuA246OY1HnTyS+bXODJAVlBmXGM0QzasRePulNrzJtclvowzhY56SybUj4+j/PNCdBhA==} - '@automerge/automerge@3.2.4': - resolution: {integrity: sha512-/IAShHSxme5d4ZK0Vs4A0P+tGaR/bSz6KtIJSCIPfwilCxsIqfRHAoNjmsXip6TGouadmbuw3WfLop6cal8pPQ==} + '@automerge/automerge@3.2.5': + resolution: {integrity: sha512-tCdaGDTLaRzBJV+Q9zMyyEH/vUFkBNh21jpwlpcEYV7gs5r63xh9UFa1N3yNJ5ZzmiLBD42TIBeqK+g54sLp0w==} '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} @@ -722,158 +722,158 @@ packages: peerDependencies: commander: ~14.0.0 - '@esbuild/aix-ppc64@0.27.4': - resolution: {integrity: sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==} + '@esbuild/aix-ppc64@0.27.7': + resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.27.4': - resolution: {integrity: sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==} + '@esbuild/android-arm64@0.27.7': + resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.27.4': - resolution: {integrity: sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==} + '@esbuild/android-arm@0.27.7': + resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.27.4': - resolution: {integrity: sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==} + '@esbuild/android-x64@0.27.7': + resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.27.4': - resolution: {integrity: sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==} + '@esbuild/darwin-arm64@0.27.7': + resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.27.4': - resolution: {integrity: sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==} + '@esbuild/darwin-x64@0.27.7': + resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.27.4': - resolution: {integrity: sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==} + '@esbuild/freebsd-arm64@0.27.7': + resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.27.4': - resolution: {integrity: sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==} + '@esbuild/freebsd-x64@0.27.7': + resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.27.4': - resolution: {integrity: sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==} + '@esbuild/linux-arm64@0.27.7': + resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.27.4': - resolution: {integrity: sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==} + '@esbuild/linux-arm@0.27.7': + resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.27.4': - resolution: {integrity: sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==} + '@esbuild/linux-ia32@0.27.7': + resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.27.4': - resolution: {integrity: sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==} + '@esbuild/linux-loong64@0.27.7': + resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.27.4': - resolution: {integrity: sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==} + '@esbuild/linux-mips64el@0.27.7': + resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.27.4': - resolution: {integrity: sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==} + '@esbuild/linux-ppc64@0.27.7': + resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.27.4': - resolution: {integrity: sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==} + '@esbuild/linux-riscv64@0.27.7': + resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.27.4': - resolution: {integrity: sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==} + '@esbuild/linux-s390x@0.27.7': + resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.27.4': - resolution: {integrity: sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==} + '@esbuild/linux-x64@0.27.7': + resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.27.4': - resolution: {integrity: sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==} + '@esbuild/netbsd-arm64@0.27.7': + resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.27.4': - resolution: {integrity: sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==} + '@esbuild/netbsd-x64@0.27.7': + resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.27.4': - resolution: {integrity: sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==} + '@esbuild/openbsd-arm64@0.27.7': + resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.27.4': - resolution: {integrity: sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==} + '@esbuild/openbsd-x64@0.27.7': + resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.27.4': - resolution: {integrity: sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==} + '@esbuild/openharmony-arm64@0.27.7': + resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.27.4': - resolution: {integrity: sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==} + '@esbuild/sunos-x64@0.27.7': + resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.27.4': - resolution: {integrity: sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==} + '@esbuild/win32-arm64@0.27.7': + resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.27.4': - resolution: {integrity: sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==} + '@esbuild/win32-ia32@0.27.7': + resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.27.4': - resolution: {integrity: sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==} + '@esbuild/win32-x64@0.27.7': + resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -999,8 +999,8 @@ packages: '@sinclair/typebox@0.27.10': resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==} - '@sinclair/typebox@0.34.48': - resolution: {integrity: sha512-kKJTNuK3AQOrgjjotVxMrCn1sUJwM76wMszfq1kdU4uYVJjvEWuFQ6HgvLt4Xz3fSmZlTOxJ/Ie13KnIcWQXFA==} + '@sinclair/typebox@0.34.49': + resolution: {integrity: sha512-brySQQs7Jtn0joV8Xh9ZV/hZb9Ozb0pmazDIASBkYKCjXrXU3mpcFahmK/z4YDhGkQvP9mWJbVyahdtU5wQA+A==} '@sinonjs/commons@3.0.1': resolution: {integrity: sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==} @@ -1041,8 +1041,8 @@ packages: '@types/mime-types@2.1.4': resolution: {integrity: sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w==} - '@types/node@20.19.37': - resolution: {integrity: sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==} + '@types/node@20.19.39': + resolution: {integrity: sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==} '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} @@ -1159,26 +1159,26 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - baseline-browser-mapping@2.10.8: - resolution: {integrity: sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ==} + baseline-browser-mapping@2.10.16: + resolution: {integrity: sha512-Lyf3aK28zpsD1yQMiiHD4RvVb6UdMoo8xzG2XzFIfR9luPzOpcBlAsT/qfB1XWS1bxWT+UtE4WmQgsp297FYOA==} engines: {node: '>=6.0.0'} hasBin: true bl@5.1.0: resolution: {integrity: sha512-tv1ZJHLfTDnXE6tMHv73YgSJaWR2AFuPwMntBe7XL/GBFHnT0CLnsHMogfk5+GzCDC5ZWarSCYaIGATZt9dNsQ==} - brace-expansion@1.1.12: - resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} + brace-expansion@1.1.13: + resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==} - brace-expansion@2.0.2: - resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==} + brace-expansion@2.0.3: + resolution: {integrity: sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==} braces@3.0.3: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - browserslist@4.28.1: - resolution: {integrity: sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==} + browserslist@4.28.2: + resolution: {integrity: sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true @@ -1213,8 +1213,8 @@ packages: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} - caniuse-lite@1.0.30001780: - resolution: {integrity: sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==} + caniuse-lite@1.0.30001787: + resolution: {integrity: sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg==} cbor-extract@2.2.2: resolution: {integrity: sha512-hlSxxI9XO2yQfe9g6msd3g4xCfDqK5T5P0fRMLuaLHhxn4ViPrm+a+MUfhrvH2W962RGxcBwEGzLQyjbDG1gng==} @@ -1327,15 +1327,15 @@ packages: resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - diff@8.0.3: - resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} + diff@8.0.4: + resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} - electron-to-chromium@1.5.313: - resolution: {integrity: sha512-QBMrTWEf00GXZmJyx2lbYD45jpI3TUFnNIzJ5BBc8piGUDwMPa1GV6HJWTZVvY/eiN3fSopl7NRbgGp9sZ9LTA==} + electron-to-chromium@1.5.334: + resolution: {integrity: sha512-mgjZAz7Jyx1SRCwEpy9wefDS7GvNPazLthHg8eQMJ76wBdGQQDW33TCrUTvQ4wzpmOrv2zrFoD3oNufMdyMpog==} emittery@0.13.1: resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} @@ -1353,8 +1353,8 @@ packages: error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} - esbuild@0.27.4: - resolution: {integrity: sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==} + esbuild@0.27.7: + resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==} engines: {node: '>=18'} hasBin: true @@ -1442,8 +1442,8 @@ packages: resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==} engines: {node: '>=10'} - get-tsconfig@4.13.6: - resolution: {integrity: sha512-shZT/QMiSHc/YBLxxOkMtgSid5HFoauqCE3/exfsEcwg1WkeqjG+V40yBbBrsD+jW2HDXcs28xOfcbm2jI8Ddw==} + get-tsconfig@4.13.7: + resolution: {integrity: sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==} glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} @@ -1457,8 +1457,8 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - handlebars@4.7.8: - resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==} + handlebars@4.7.9: + resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} engines: {node: '>=0.4.7'} hasBin: true @@ -1821,8 +1821,8 @@ packages: node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} - node-releases@2.0.36: - resolution: {integrity: sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==} + node-releases@2.0.37: + resolution: {integrity: sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==} normalize-path@3.0.0: resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==} @@ -1888,12 +1888,12 @@ packages: picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} - picomatch@2.3.1: - resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + picomatch@2.3.2: + resolution: {integrity: sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==} engines: {node: '>=8.6'} - picomatch@4.0.3: - resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + picomatch@4.0.4: + resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} pirates@4.0.7: @@ -1915,8 +1915,8 @@ packages: pure-rand@6.1.0: resolution: {integrity: sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==} - pure-rand@8.2.0: - resolution: {integrity: sha512-KHnUjm68KSO/hqpWlVwagMDPrIjnDNY9r0DbKN79xEa5RU2MLUe0lICBGpWDF8cwmhUiN8r9A8DLGPVcFB62/A==} + pure-rand@8.4.0: + resolution: {integrity: sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==} react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} @@ -1939,8 +1939,8 @@ packages: regjsgen@0.8.0: resolution: {integrity: sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==} - regjsparser@0.13.0: - resolution: {integrity: sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==} + regjsparser@0.13.1: + resolution: {integrity: sha512-dLsljMd9sqwRkby8zhO1gSg3PnJIBFid8f4CQj/sXx+7cKx+E7u0PKhZ+U4wmhx7EfmtvnA318oVaIkAB1lRJw==} hasBin: true require-directory@2.1.1: @@ -2093,8 +2093,8 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} - ts-jest@29.4.6: - resolution: {integrity: sha512-fSpWtOO/1AjSNQguk43hb/JCo16oJDnMJf3CdEGNkqsEX3t0KX96xvyX1D7PfLCpVoKu4MfVrqUkFyblYoY4lA==} + ts-jest@29.4.9: + resolution: {integrity: sha512-LTb9496gYPMCqjeDLdPrKuXtncudeV1yRZnF4Wo5l3SFi0RYEnYRNgMrFIdg+FHvfzjCyQk1cLncWVqiSX+EvQ==} engines: {node: ^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0} hasBin: true peerDependencies: @@ -2105,7 +2105,7 @@ packages: esbuild: '*' jest: ^29.0.0 || ^30.0.0 jest-util: ^29.0.0 || ^30.0.0 - typescript: '>=4.3 <6' + typescript: '>=4.3 <7' peerDependenciesMeta: '@babel/core': optional: true @@ -2213,8 +2213,8 @@ packages: resolution: {integrity: sha512-+QU2zd6OTD8XWIJCbffaiQeH9U73qIqafo1x6V1snCWYGJf6cVE0cDR4D8xRzcEnfI21IFrUPzPGtcPf8AC+Rw==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} - ws@8.19.0: - resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} + ws@8.20.0: + resolution: {integrity: sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==} engines: {node: '>=10.0.0'} peerDependencies: bufferutil: ^4.0.1 @@ -2225,8 +2225,8 @@ packages: utf-8-validate: optional: true - xstate@5.28.0: - resolution: {integrity: sha512-Iaqq6ZrUzqeUtA3hC5LQKZfR8ZLzEFTImMHJM3jWEdVvXWdKvvVLXZEiNQWm3SCA9ZbEou/n5rcsna1wb9t28A==} + xstate@5.30.0: + resolution: {integrity: sha512-mIzIuMjtYVkqXq9dUzYQoag7b/dF1CBS/yhliuPLfR0FwKPC18HiUivb/crcqY2gknhR8gJEhnppLg6ubQ0gGw==} y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} @@ -2249,46 +2249,49 @@ packages: snapshots: - '@automerge/automerge-repo-network-websocket@2.5.4': + '@automerge/automerge-repo-network-websocket@2.6.0-subduction.12': dependencies: - '@automerge/automerge-repo': 2.5.4 + '@automerge/automerge-repo': 2.6.0-subduction.12 cbor-x: 1.6.4 debug: 4.4.3 eventemitter3: 5.0.4 - isomorphic-ws: 5.0.0(ws@8.19.0) - ws: 8.19.0 + isomorphic-ws: 5.0.0(ws@8.20.0) + ws: 8.20.0 transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate - '@automerge/automerge-repo-storage-indexeddb@2.5.4': + '@automerge/automerge-repo-storage-nodefs@2.6.0-subduction.12': dependencies: - '@automerge/automerge-repo': 2.5.4 - transitivePeerDependencies: - - supports-color - - '@automerge/automerge-repo-storage-nodefs@2.5.4': - dependencies: - '@automerge/automerge-repo': 2.5.4 + '@automerge/automerge-repo': 2.6.0-subduction.12 rimraf: 5.0.10 transitivePeerDependencies: + - bufferutil - supports-color + - utf-8-validate - '@automerge/automerge-repo@2.5.4': + '@automerge/automerge-repo@2.6.0-subduction.12': dependencies: - '@automerge/automerge': 3.2.4 + '@automerge/automerge': 3.2.5 + '@automerge/automerge-subduction': 0.7.0 bs58check: 3.0.1 cbor-x: 1.6.4 debug: 4.4.3 eventemitter3: 5.0.4 fast-sha256: 1.3.0 + isomorphic-ws: 5.0.0(ws@8.20.0) uuid: 9.0.1 - xstate: 5.28.0 + ws: 8.20.0 + xstate: 5.30.0 transitivePeerDependencies: + - bufferutil - supports-color + - utf-8-validate + + '@automerge/automerge-subduction@0.7.0': {} - '@automerge/automerge@3.2.4': {} + '@automerge/automerge@3.2.5': {} '@babel/code-frame@7.29.0': dependencies: @@ -2334,7 +2337,7 @@ snapshots: dependencies: '@babel/compat-data': 7.29.0 '@babel/helper-validator-option': 7.27.1 - browserslist: 4.28.1 + browserslist: 4.28.2 lru-cache: 5.1.1 semver: 6.3.1 @@ -3046,82 +3049,82 @@ snapshots: dependencies: commander: 14.0.3 - '@esbuild/aix-ppc64@0.27.4': + '@esbuild/aix-ppc64@0.27.7': optional: true - '@esbuild/android-arm64@0.27.4': + '@esbuild/android-arm64@0.27.7': optional: true - '@esbuild/android-arm@0.27.4': + '@esbuild/android-arm@0.27.7': optional: true - '@esbuild/android-x64@0.27.4': + '@esbuild/android-x64@0.27.7': optional: true - '@esbuild/darwin-arm64@0.27.4': + '@esbuild/darwin-arm64@0.27.7': optional: true - '@esbuild/darwin-x64@0.27.4': + '@esbuild/darwin-x64@0.27.7': optional: true - '@esbuild/freebsd-arm64@0.27.4': + '@esbuild/freebsd-arm64@0.27.7': optional: true - '@esbuild/freebsd-x64@0.27.4': + '@esbuild/freebsd-x64@0.27.7': optional: true - '@esbuild/linux-arm64@0.27.4': + '@esbuild/linux-arm64@0.27.7': optional: true - '@esbuild/linux-arm@0.27.4': + '@esbuild/linux-arm@0.27.7': optional: true - '@esbuild/linux-ia32@0.27.4': + '@esbuild/linux-ia32@0.27.7': optional: true - '@esbuild/linux-loong64@0.27.4': + '@esbuild/linux-loong64@0.27.7': optional: true - '@esbuild/linux-mips64el@0.27.4': + '@esbuild/linux-mips64el@0.27.7': optional: true - '@esbuild/linux-ppc64@0.27.4': + '@esbuild/linux-ppc64@0.27.7': optional: true - '@esbuild/linux-riscv64@0.27.4': + '@esbuild/linux-riscv64@0.27.7': optional: true - '@esbuild/linux-s390x@0.27.4': + '@esbuild/linux-s390x@0.27.7': optional: true - '@esbuild/linux-x64@0.27.4': + '@esbuild/linux-x64@0.27.7': optional: true - '@esbuild/netbsd-arm64@0.27.4': + '@esbuild/netbsd-arm64@0.27.7': optional: true - '@esbuild/netbsd-x64@0.27.4': + '@esbuild/netbsd-x64@0.27.7': optional: true - '@esbuild/openbsd-arm64@0.27.4': + '@esbuild/openbsd-arm64@0.27.7': optional: true - '@esbuild/openbsd-x64@0.27.4': + '@esbuild/openbsd-x64@0.27.7': optional: true - '@esbuild/openharmony-arm64@0.27.4': + '@esbuild/openharmony-arm64@0.27.7': optional: true - '@esbuild/sunos-x64@0.27.4': + '@esbuild/sunos-x64@0.27.7': optional: true - '@esbuild/win32-arm64@0.27.4': + '@esbuild/win32-arm64@0.27.7': optional: true - '@esbuild/win32-ia32@0.27.4': + '@esbuild/win32-ia32@0.27.7': optional: true - '@esbuild/win32-x64@0.27.4': + '@esbuild/win32-x64@0.27.7': optional: true '@isaacs/cliui@8.0.2': @@ -3146,7 +3149,7 @@ snapshots: '@jest/console@29.7.0': dependencies: '@jest/types': 29.6.3 - '@types/node': 20.19.37 + '@types/node': 20.19.39 chalk: 4.1.2 jest-message-util: 29.7.0 jest-util: 29.7.0 @@ -3159,14 +3162,14 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.19.37 + '@types/node': 20.19.39 ansi-escapes: 4.3.2 chalk: 4.1.2 ci-info: 3.9.0 exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@20.19.37) + jest-config: 29.7.0(@types/node@20.19.39) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -3191,7 +3194,7 @@ snapshots: dependencies: '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.19.37 + '@types/node': 20.19.39 jest-mock: 29.7.0 '@jest/expect-utils@29.7.0': @@ -3209,7 +3212,7 @@ snapshots: dependencies: '@jest/types': 29.6.3 '@sinonjs/fake-timers': 10.3.0 - '@types/node': 20.19.37 + '@types/node': 20.19.39 jest-message-util: 29.7.0 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -3225,7 +3228,7 @@ snapshots: '@jest/pattern@30.0.1': dependencies: - '@types/node': 20.19.37 + '@types/node': 20.19.39 jest-regex-util: 30.0.1 '@jest/reporters@29.7.0': @@ -3236,7 +3239,7 @@ snapshots: '@jest/transform': 29.7.0 '@jest/types': 29.6.3 '@jridgewell/trace-mapping': 0.3.31 - '@types/node': 20.19.37 + '@types/node': 20.19.39 chalk: 4.1.2 collect-v8-coverage: 1.0.3 exit: 0.1.2 @@ -3263,7 +3266,7 @@ snapshots: '@jest/schemas@30.0.5': dependencies: - '@sinclair/typebox': 0.34.48 + '@sinclair/typebox': 0.34.49 '@jest/source-map@29.6.3': dependencies: @@ -3329,7 +3332,7 @@ snapshots: '@jest/schemas': 29.6.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 20.19.37 + '@types/node': 20.19.39 '@types/yargs': 17.0.35 chalk: 4.1.2 @@ -3339,7 +3342,7 @@ snapshots: '@jest/schemas': 30.0.5 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 20.19.37 + '@types/node': 20.19.39 '@types/yargs': 17.0.35 chalk: 4.1.2 @@ -3369,7 +3372,7 @@ snapshots: '@sinclair/typebox@0.27.10': {} - '@sinclair/typebox@0.34.48': {} + '@sinclair/typebox@0.34.49': {} '@sinonjs/commons@3.0.1': dependencies: @@ -3404,7 +3407,7 @@ snapshots: '@types/graceful-fs@4.1.9': dependencies: - '@types/node': 20.19.37 + '@types/node': 20.19.39 '@types/istanbul-lib-coverage@2.0.6': {} @@ -3423,7 +3426,7 @@ snapshots: '@types/mime-types@2.1.4': {} - '@types/node@20.19.37': + '@types/node@20.19.39': dependencies: undici-types: 6.21.0 @@ -3458,7 +3461,7 @@ snapshots: anymatch@3.1.3: dependencies: normalize-path: 3.0.0 - picomatch: 2.3.1 + picomatch: 2.3.2 argparse@1.0.10: dependencies: @@ -3582,7 +3585,7 @@ snapshots: base64-js@1.5.1: {} - baseline-browser-mapping@2.10.8: {} + baseline-browser-mapping@2.10.16: {} bl@5.1.0: dependencies: @@ -3590,12 +3593,12 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 - brace-expansion@1.1.12: + brace-expansion@1.1.13: dependencies: balanced-match: 1.0.2 concat-map: 0.0.1 - brace-expansion@2.0.2: + brace-expansion@2.0.3: dependencies: balanced-match: 1.0.2 @@ -3603,13 +3606,13 @@ snapshots: dependencies: fill-range: 7.1.1 - browserslist@4.28.1: + browserslist@4.28.2: dependencies: - baseline-browser-mapping: 2.10.8 - caniuse-lite: 1.0.30001780 - electron-to-chromium: 1.5.313 - node-releases: 2.0.36 - update-browserslist-db: 1.2.3(browserslist@4.28.1) + baseline-browser-mapping: 2.10.16 + caniuse-lite: 1.0.30001787 + electron-to-chromium: 1.5.334 + node-releases: 2.0.37 + update-browserslist-db: 1.2.3(browserslist@4.28.2) bs-logger@0.2.6: dependencies: @@ -3641,7 +3644,7 @@ snapshots: camelcase@6.3.0: {} - caniuse-lite@1.0.30001780: {} + caniuse-lite@1.0.30001787: {} cbor-extract@2.2.2: dependencies: @@ -3704,15 +3707,15 @@ snapshots: core-js-compat@3.49.0: dependencies: - browserslist: 4.28.1 + browserslist: 4.28.2 - create-jest@29.7.0(@types/node@20.19.37): + create-jest@29.7.0(@types/node@20.19.39): dependencies: '@jest/types': 29.6.3 chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@20.19.37) + jest-config: 29.7.0(@types/node@20.19.39) jest-util: 29.7.0 prompts: 2.4.2 transitivePeerDependencies: @@ -3742,11 +3745,11 @@ snapshots: diff-sequences@29.6.3: {} - diff@8.0.3: {} + diff@8.0.4: {} eastasianwidth@0.2.0: {} - electron-to-chromium@1.5.313: {} + electron-to-chromium@1.5.334: {} emittery@0.13.1: {} @@ -3760,34 +3763,34 @@ snapshots: dependencies: is-arrayish: 0.2.1 - esbuild@0.27.4: + esbuild@0.27.7: optionalDependencies: - '@esbuild/aix-ppc64': 0.27.4 - '@esbuild/android-arm': 0.27.4 - '@esbuild/android-arm64': 0.27.4 - '@esbuild/android-x64': 0.27.4 - '@esbuild/darwin-arm64': 0.27.4 - '@esbuild/darwin-x64': 0.27.4 - '@esbuild/freebsd-arm64': 0.27.4 - '@esbuild/freebsd-x64': 0.27.4 - '@esbuild/linux-arm': 0.27.4 - '@esbuild/linux-arm64': 0.27.4 - '@esbuild/linux-ia32': 0.27.4 - '@esbuild/linux-loong64': 0.27.4 - '@esbuild/linux-mips64el': 0.27.4 - '@esbuild/linux-ppc64': 0.27.4 - '@esbuild/linux-riscv64': 0.27.4 - '@esbuild/linux-s390x': 0.27.4 - '@esbuild/linux-x64': 0.27.4 - '@esbuild/netbsd-arm64': 0.27.4 - '@esbuild/netbsd-x64': 0.27.4 - '@esbuild/openbsd-arm64': 0.27.4 - '@esbuild/openbsd-x64': 0.27.4 - '@esbuild/openharmony-arm64': 0.27.4 - '@esbuild/sunos-x64': 0.27.4 - '@esbuild/win32-arm64': 0.27.4 - '@esbuild/win32-ia32': 0.27.4 - '@esbuild/win32-x64': 0.27.4 + '@esbuild/aix-ppc64': 0.27.7 + '@esbuild/android-arm': 0.27.7 + '@esbuild/android-arm64': 0.27.7 + '@esbuild/android-x64': 0.27.7 + '@esbuild/darwin-arm64': 0.27.7 + '@esbuild/darwin-x64': 0.27.7 + '@esbuild/freebsd-arm64': 0.27.7 + '@esbuild/freebsd-x64': 0.27.7 + '@esbuild/linux-arm': 0.27.7 + '@esbuild/linux-arm64': 0.27.7 + '@esbuild/linux-ia32': 0.27.7 + '@esbuild/linux-loong64': 0.27.7 + '@esbuild/linux-mips64el': 0.27.7 + '@esbuild/linux-ppc64': 0.27.7 + '@esbuild/linux-riscv64': 0.27.7 + '@esbuild/linux-s390x': 0.27.7 + '@esbuild/linux-x64': 0.27.7 + '@esbuild/netbsd-arm64': 0.27.7 + '@esbuild/netbsd-x64': 0.27.7 + '@esbuild/openbsd-arm64': 0.27.7 + '@esbuild/openbsd-x64': 0.27.7 + '@esbuild/openharmony-arm64': 0.27.7 + '@esbuild/sunos-x64': 0.27.7 + '@esbuild/win32-arm64': 0.27.7 + '@esbuild/win32-ia32': 0.27.7 + '@esbuild/win32-x64': 0.27.7 escalade@3.2.0: {} @@ -3823,7 +3826,7 @@ snapshots: fast-check@4.6.0: dependencies: - pure-rand: 8.2.0 + pure-rand: 8.4.0 fast-json-stable-stringify@2.1.0: {} @@ -3862,7 +3865,7 @@ snapshots: get-stream@6.0.1: {} - get-tsconfig@4.13.6: + get-tsconfig@4.13.7: dependencies: resolve-pkg-maps: 1.0.0 @@ -3886,7 +3889,7 @@ snapshots: graceful-fs@4.2.11: {} - handlebars@4.7.8: + handlebars@4.7.9: dependencies: minimist: 1.2.8 neo-async: 2.6.2 @@ -3943,9 +3946,9 @@ snapshots: isexe@2.0.0: {} - isomorphic-ws@5.0.0(ws@8.19.0): + isomorphic-ws@5.0.0(ws@8.20.0): dependencies: - ws: 8.19.0 + ws: 8.20.0 istanbul-lib-coverage@3.2.2: {} @@ -4006,7 +4009,7 @@ snapshots: '@jest/expect': 29.7.0 '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.19.37 + '@types/node': 20.19.39 chalk: 4.1.2 co: 4.6.0 dedent: 1.7.2 @@ -4026,16 +4029,16 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@29.7.0(@types/node@20.19.37): + jest-cli@29.7.0(@types/node@20.19.39): dependencies: '@jest/core': 29.7.0 '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 - create-jest: 29.7.0(@types/node@20.19.37) + create-jest: 29.7.0(@types/node@20.19.39) exit: 0.1.2 import-local: 3.2.0 - jest-config: 29.7.0(@types/node@20.19.37) + jest-config: 29.7.0(@types/node@20.19.39) jest-util: 29.7.0 jest-validate: 29.7.0 yargs: 17.7.2 @@ -4045,7 +4048,7 @@ snapshots: - supports-color - ts-node - jest-config@29.7.0(@types/node@20.19.37): + jest-config@29.7.0(@types/node@20.19.39): dependencies: '@babel/core': 7.29.0 '@jest/test-sequencer': 29.7.0 @@ -4070,7 +4073,7 @@ snapshots: slash: 3.0.0 strip-json-comments: 3.1.1 optionalDependencies: - '@types/node': 20.19.37 + '@types/node': 20.19.39 transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -4099,7 +4102,7 @@ snapshots: '@jest/environment': 29.7.0 '@jest/fake-timers': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.19.37 + '@types/node': 20.19.39 jest-mock: 29.7.0 jest-util: 29.7.0 @@ -4109,7 +4112,7 @@ snapshots: dependencies: '@jest/types': 29.6.3 '@types/graceful-fs': 4.1.9 - '@types/node': 20.19.37 + '@types/node': 20.19.39 anymatch: 3.1.3 fb-watchman: 2.0.2 graceful-fs: 4.2.11 @@ -4124,14 +4127,14 @@ snapshots: jest-haste-map@30.3.0: dependencies: '@jest/types': 30.3.0 - '@types/node': 20.19.37 + '@types/node': 20.19.39 anymatch: 3.1.3 fb-watchman: 2.0.2 graceful-fs: 4.2.11 jest-regex-util: 30.0.1 jest-util: 30.3.0 jest-worker: 30.3.0 - picomatch: 4.0.3 + picomatch: 4.0.4 walker: 1.0.8 optionalDependencies: fsevents: 2.3.3 @@ -4163,7 +4166,7 @@ snapshots: jest-mock@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 20.19.37 + '@types/node': 20.19.39 jest-util: 29.7.0 jest-pnp-resolver@1.2.3(jest-resolve@29.7.0): @@ -4200,7 +4203,7 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.19.37 + '@types/node': 20.19.39 chalk: 4.1.2 emittery: 0.13.1 graceful-fs: 4.2.11 @@ -4228,7 +4231,7 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.19.37 + '@types/node': 20.19.39 chalk: 4.1.2 cjs-module-lexer: 1.4.3 collect-v8-coverage: 1.0.3 @@ -4274,20 +4277,20 @@ snapshots: jest-util@29.7.0: dependencies: '@jest/types': 29.6.3 - '@types/node': 20.19.37 + '@types/node': 20.19.39 chalk: 4.1.2 ci-info: 3.9.0 graceful-fs: 4.2.11 - picomatch: 2.3.1 + picomatch: 2.3.2 jest-util@30.3.0: dependencies: '@jest/types': 30.3.0 - '@types/node': 20.19.37 + '@types/node': 20.19.39 chalk: 4.1.2 ci-info: 4.4.0 graceful-fs: 4.2.11 - picomatch: 4.0.3 + picomatch: 4.0.4 jest-validate@29.7.0: dependencies: @@ -4302,7 +4305,7 @@ snapshots: dependencies: '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 20.19.37 + '@types/node': 20.19.39 ansi-escapes: 4.3.2 chalk: 4.1.2 emittery: 0.13.1 @@ -4311,25 +4314,25 @@ snapshots: jest-worker@29.7.0: dependencies: - '@types/node': 20.19.37 + '@types/node': 20.19.39 jest-util: 29.7.0 merge-stream: 2.0.0 supports-color: 8.1.1 jest-worker@30.3.0: dependencies: - '@types/node': 20.19.37 + '@types/node': 20.19.39 '@ungap/structured-clone': 1.3.0 jest-util: 30.3.0 merge-stream: 2.0.0 supports-color: 8.1.1 - jest@29.7.0(@types/node@20.19.37): + jest@29.7.0(@types/node@20.19.39): dependencies: '@jest/core': 29.7.0 '@jest/types': 29.6.3 import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@20.19.37) + jest-cli: 29.7.0(@types/node@20.19.39) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -4389,7 +4392,7 @@ snapshots: micromatch@4.0.8: dependencies: braces: 3.0.3 - picomatch: 2.3.1 + picomatch: 2.3.2 mime-db@1.52.0: {} @@ -4401,11 +4404,11 @@ snapshots: minimatch@3.1.5: dependencies: - brace-expansion: 1.1.12 + brace-expansion: 1.1.13 minimatch@9.0.9: dependencies: - brace-expansion: 2.0.2 + brace-expansion: 2.0.3 minimist@1.2.8: {} @@ -4424,7 +4427,7 @@ snapshots: node-int64@0.4.0: {} - node-releases@2.0.36: {} + node-releases@2.0.37: {} normalize-path@3.0.0: {} @@ -4490,9 +4493,9 @@ snapshots: picocolors@1.1.1: {} - picomatch@2.3.1: {} + picomatch@2.3.2: {} - picomatch@4.0.3: {} + picomatch@4.0.4: {} pirates@4.0.7: {} @@ -4513,7 +4516,7 @@ snapshots: pure-rand@6.1.0: {} - pure-rand@8.2.0: {} + pure-rand@8.4.0: {} react-is@18.3.1: {} @@ -4534,13 +4537,13 @@ snapshots: regenerate: 1.4.2 regenerate-unicode-properties: 10.2.2 regjsgen: 0.8.0 - regjsparser: 0.13.0 + regjsparser: 0.13.1 unicode-match-property-ecmascript: 2.0.0 unicode-match-property-value-ecmascript: 2.2.1 regjsgen@0.8.0: {} - regjsparser@0.13.0: + regjsparser@0.13.1: dependencies: jsesc: 3.1.0 @@ -4673,12 +4676,12 @@ snapshots: dependencies: is-number: 7.0.0 - ts-jest@29.4.6(@babel/core@7.29.0)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@20.19.37))(typescript@5.9.3): + ts-jest@29.4.9(@babel/core@7.29.0)(@jest/transform@30.3.0)(@jest/types@30.3.0)(babel-jest@30.3.0(@babel/core@7.29.0))(jest-util@30.3.0)(jest@29.7.0(@types/node@20.19.39))(typescript@5.9.3): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 - handlebars: 4.7.8 - jest: 29.7.0(@types/node@20.19.37) + handlebars: 4.7.9 + jest: 29.7.0(@types/node@20.19.39) json5: 2.2.3 lodash.memoize: 4.1.2 make-error: 1.3.6 @@ -4695,8 +4698,8 @@ snapshots: tsx@4.21.0: dependencies: - esbuild: 0.27.4 - get-tsconfig: 4.13.6 + esbuild: 0.27.7 + get-tsconfig: 4.13.7 optionalDependencies: fsevents: 2.3.3 @@ -4724,9 +4727,9 @@ snapshots: unicode-property-aliases-ecmascript@2.2.0: {} - update-browserslist-db@1.2.3(browserslist@4.28.1): + update-browserslist-db@1.2.3(browserslist@4.28.2): dependencies: - browserslist: 4.28.1 + browserslist: 4.28.2 escalade: 3.2.0 picocolors: 1.1.1 @@ -4774,9 +4777,9 @@ snapshots: imurmurhash: 0.1.4 signal-exit: 4.1.0 - ws@8.19.0: {} + ws@8.20.0: {} - xstate@5.28.0: {} + xstate@5.30.0: {} y18n@5.0.8: {} diff --git a/src/cli.ts b/src/cli.ts index 67d4f08..40ed606 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -39,11 +39,12 @@ program "--sync-server ", "Custom sync server URL and storage ID" ) + .option("--sub", "Use Subduction sync backend", false) .action(async (path, opts) => { const [syncServer, syncServerStorageId] = validateSyncServer( opts.syncServer ); - await init(path, { syncServer, syncServerStorageId }); + await init(path, { syncServer, syncServerStorageId, sub: opts.sub }); }); // Track command (set root directory URL without full initialization) @@ -92,6 +93,7 @@ program "--sync-server ", "Custom sync server URL and storage ID" ) + .option("--sub", "Use Subduction sync backend", false) .option("-v, --verbose", "Verbose output", false) .action(async (url, path, opts) => { const [syncServer, syncServerStorageId] = validateSyncServer( @@ -102,6 +104,7 @@ program verbose: opts.verbose, syncServer, syncServerStorageId, + sub: opts.sub, }); }); diff --git a/src/commands.ts b/src/commands.ts index 0ef1b5d..9b949af 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -18,6 +18,7 @@ import { DirectoryDocument, CommandOptions, } from "./types"; +import { DEFAULT_SUBDUCTION_SERVER } from "./types/config"; import { SyncEngine } from "./core"; import { pathExists, ensureDirectoryExists, formatRelativePath } from "./utils"; import { ConfigManager } from "./core/config"; @@ -42,19 +43,30 @@ interface CommandContext { */ async function initializeRepository( resolvedPath: string, - overrides: Partial + overrides: Partial, + sub: boolean = false ): Promise<{ config: DirectoryConfig; repo: Repo; syncEngine: SyncEngine }> { // Create .pushwork directory structure const syncToolDir = path.join(resolvedPath, ConfigManager.CONFIG_DIR); await ensureDirectoryExists(syncToolDir); await ensureDirectoryExists(path.join(syncToolDir, "automerge")); + // Persist Subduction mode in config so subsequent commands pick it up + if (sub) { + overrides = { ...overrides, subduction: true }; + } + // Create configuration with overrides const configManager = new ConfigManager(resolvedPath); const config = await configManager.initializeWithOverrides(overrides); + // Override sync server for Subduction mode + if (sub && !overrides.sync_server) { + config.sync_server = DEFAULT_SUBDUCTION_SERVER; + } + // Create repository and sync engine - const repo = await createRepo(resolvedPath, config); + const repo = await createRepo(resolvedPath, config, sub); const syncEngine = new SyncEngine(repo, resolvedPath, config); return { config, repo, syncEngine }; @@ -83,12 +95,15 @@ async function setupCommandContext( let config: DirectoryConfig; if (options?.forceDefaults) { - // Force mode: use defaults, only preserving root_directory_url from local config + // Force mode: use defaults, only preserving root_directory_url and subduction from local config const localConfig = await configManager.load(); config = configManager.getDefaultDirectoryConfig(); if (localConfig?.root_directory_url) { config.root_directory_url = localConfig.root_directory_url; } + if (localConfig?.subduction) { + config.subduction = localConfig.subduction; + } } else { config = await configManager.getMerged(); } @@ -98,8 +113,16 @@ async function setupCommandContext( config = { ...config, sync_enabled: options.syncEnabled }; } + // Read Subduction mode from persisted config + const sub = config.subduction ?? false; + + // Override sync server for Subduction mode + if (sub) { + config.sync_server = DEFAULT_SUBDUCTION_SERVER; + } + // Create repo with config - const repo = await createRepo(resolvedPath, config); + const repo = await createRepo(resolvedPath, config, sub); // Create sync engine const syncEngine = new SyncEngine(repo, resolvedPath, config); @@ -157,7 +180,12 @@ export async function init( ): Promise { const resolvedPath = path.resolve(targetPath); + const sub = options.sub ?? false; + out.task(`Initializing`); + if (sub) { + out.taskLine("Using Subduction sync backend", true); + } await ensureDirectoryExists(resolvedPath); @@ -173,7 +201,7 @@ export async function init( const { repo, syncEngine, config } = await initializeRepository(resolvedPath, { sync_server: options.syncServer, sync_server_storage_id: options.syncServerStorageId, - }); + }, sub); // Create new root directory document out.update("Creating root directory"); @@ -190,9 +218,9 @@ export async function init( await syncEngine.setRootDirectoryUrl(rootHandle.url); // Wait for root document to sync to server if sync is enabled - // This ensures the document is uploaded before we exit - // waitForSync() verifies the server has the document by comparing local and remote heads - if (config.sync_enabled && config.sync_server_storage_id) { + // With Subduction, we skip StorageId-based sync verification — + // the SubductionSource handles sync internally. + if (config.sync_enabled && !sub && config.sync_server_storage_id) { out.update("Syncing to server"); const { failed } = await waitForSync([rootHandle], config.sync_server_storage_id); if (failed.length > 0) { @@ -203,7 +231,7 @@ export async function init( // Run initial sync to capture existing files out.update("Running initial sync"); - const result = await syncEngine.sync(); + const result = await syncEngine.sync({ sub }); out.update("Writing to disk"); await safeRepoShutdown(repo); @@ -232,10 +260,15 @@ export async function sync( : "Syncing" ); - const { repo, syncEngine } = await setupCommandContext(targetPath, { + const { repo, syncEngine, config } = await setupCommandContext(targetPath, { forceDefaults: !options.gentle, }); + const sub = config.subduction ?? false; + if (sub) { + out.taskLine("Using Subduction sync backend (from config)", true); + } + if (options.nuclear) { await syncEngine.nuclearReset(); } @@ -286,7 +319,7 @@ export async function sync( out.log(""); out.log("Run without --dry-run to apply these changes"); } else { - const result = await syncEngine.sync(); + const result = await syncEngine.sync({ sub }); out.taskLine("Writing to disk"); await safeRepoShutdown(repo); @@ -329,6 +362,12 @@ export async function sync( out.warn(`... and ${result.errors.length - 5} more errors`); } } + + // Always print the root URL + const rootUrl = await syncEngine.getRootDirectoryUrl(); + if (rootUrl) { + out.info(`Root: ${rootUrl}`); + } } process.exit(); @@ -587,7 +626,12 @@ export async function clone( const resolvedPath = path.resolve(targetPath); + const sub = options.sub ?? false; + out.task(`Cloning ${rootUrl}`); + if (sub) { + out.taskLine("Using Subduction sync backend", true); + } // Check if directory exists and handle --force if (await pathExists(resolvedPath)) { @@ -617,13 +661,14 @@ export async function clone( { sync_server: options.syncServer, sync_server_storage_id: options.syncServerStorageId, - } + }, + sub ); // Connect to existing root directory and download files out.update("Downloading files"); await syncEngine.setRootDirectoryUrl(rootUrl as AutomergeUrl); - const result = await syncEngine.sync(); + const result = await syncEngine.sync({ sub }); out.update("Writing to disk"); await safeRepoShutdown(repo); @@ -838,10 +883,12 @@ export async function watch( const script = options.script || "pnpm build"; const watchDir = options.watchDir || "src"; // Default to watching 'src' directory const verbose = options.verbose || false; - const { repo, syncEngine, workingDir } = await setupCommandContext( - targetPath + const { repo, syncEngine, config, workingDir } = await setupCommandContext( + targetPath, ); + const sub = config.subduction ?? false; + const absoluteWatchDir = path.resolve(workingDir, watchDir); // Check if watch directory exists @@ -856,6 +903,9 @@ export async function watch( "WATCHING", `${chalk.underline(formatRelativePath(watchDir))} for changes...` ); + if (sub) { + out.info("Using Subduction sync backend (from config)"); + } out.info(`Build script: ${script}`); out.info(`Working directory: ${workingDir}`); @@ -894,7 +944,7 @@ export async function watch( // Run sync out.task("Syncing"); - const result = await syncEngine.sync(); + const result = await syncEngine.sync({ sub }); if (result.success) { if (result.filesChanged === 0 && result.directoriesChanged === 0) { diff --git a/src/core/config.ts b/src/core/config.ts index 41db973..c0a6311 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -212,6 +212,10 @@ export class ConfigManager { merged.sync_server_storage_id = override.sync_server_storage_id; } + if ("subduction" in override && override.subduction !== undefined) { + merged.subduction = override.subduction; + } + if ("sync_enabled" in override && override.sync_enabled !== undefined) { merged.sync_enabled = override.sync_enabled; } diff --git a/src/core/sync-engine.ts b/src/core/sync-engine.ts index 73d383a..e0dc773 100644 --- a/src/core/sync-engine.ts +++ b/src/core/sync-engine.ts @@ -207,6 +207,11 @@ export class SyncEngine { /** * Set the root directory URL in the snapshot */ + async getRootDirectoryUrl(): Promise { + const snapshot = await this.snapshotManager.load() + return snapshot?.rootDirectoryUrl + } + async setRootDirectoryUrl(url: AutomergeUrl): Promise { let snapshot = await this.snapshotManager.load() if (!snapshot) { @@ -423,7 +428,7 @@ export class SyncEngine { /** * Run full bidirectional sync */ - async sync(): Promise { + async sync(options?: {sub?: boolean}): Promise { const result: SyncResult = { success: false, filesChanged: 0, @@ -482,7 +487,6 @@ export class SyncEngine { await waitForBidirectionalSync( this.repo, snapshot.rootDirectoryUrl, - this.config.sync_server_storage_id, { timeoutMs: 5000, // Increased timeout for initial sync pollIntervalMs: 100, @@ -526,6 +530,12 @@ export class SyncEngine { // Wait for network sync (important for clone scenarios) if (this.config.sync_enabled) { + const sub = options?.sub ?? false + // In Subduction mode, pass no StorageId so waitForSync + // falls back to head-stability polling. In WebSocket mode, + // pass the StorageId for precise getSyncInfo-based verification. + const storageId = sub ? undefined : this.config.sync_server_storage_id + try { // Ensure root directory handle is tracked for sync if (snapshot.rootDirectoryUrl) { @@ -546,11 +556,13 @@ export class SyncEngine { out.update(`Uploading ${allHandles.length} documents to sync server`) const {failed} = await waitForSync( allHandles, - this.config.sync_server_storage_id + storageId ) - // Recreate failed documents and retry once - if (failed.length > 0) { + // Recreate failed documents and retry once. + // Skip in Subduction mode — SubductionSource has its + // own heal-sync retry logic. + if (failed.length > 0 && !sub) { debug(`sync: ${failed.length} documents failed, recreating`) out.update(`Recreating ${failed.length} failed documents`) const retryHandles = await this.recreateFailedDocuments(failed, snapshot) @@ -559,7 +571,7 @@ export class SyncEngine { out.update(`Retrying ${retryHandles.length} recreated documents`) const retry = await waitForSync( retryHandles, - this.config.sync_server_storage_id + storageId ) if (retry.failed.length > 0) { const msg = `${retry.failed.length} documents failed to sync to server after recreation` @@ -572,6 +584,9 @@ export class SyncEngine { }) } } + } else if (failed.length > 0 && sub) { + debug(`sync: ${failed.length} documents timed out in sub mode (SubductionSource will retry)`) + out.taskLine(`${failed.length} documents still syncing (Subduction will retry)`, true) } debug("sync: all handles synced to server") @@ -585,7 +600,6 @@ export class SyncEngine { await waitForBidirectionalSync( this.repo, snapshot.rootDirectoryUrl, - this.config.sync_server_storage_id, { timeoutMs: BIDIRECTIONAL_SYNC_TIMEOUT_MS, pollIntervalMs: 100, @@ -610,7 +624,7 @@ export class SyncEngine { out.update("Syncing root directory update") await waitForSync( [rootHandle], - this.config.sync_server_storage_id + storageId ) } } catch (error) { diff --git a/src/types/config.ts b/src/types/config.ts index 97c135c..5ac1e32 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -6,6 +6,7 @@ import { StorageId } from "@automerge/automerge-repo"; export const DEFAULT_SYNC_SERVER = "wss://sync3.automerge.org"; export const DEFAULT_SYNC_SERVER_STORAGE_ID = "3760df37-a4c6-4f66-9ecd-732039a9385d" as StorageId; +export const DEFAULT_SUBDUCTION_SERVER = "wss://subduction.sync.inkandswitch.com"; /** * Global configuration options @@ -25,6 +26,7 @@ export interface GlobalConfig { */ export interface DirectoryConfig extends GlobalConfig { root_directory_url?: string; + subduction?: boolean; sync_enabled: boolean; } @@ -42,6 +44,7 @@ export interface CloneOptions extends CommandOptions { force?: boolean; // Overwrite existing directory syncServer?: string; // Custom sync server URL syncServerStorageId?: StorageId; // Custom sync server storage ID + sub?: boolean; } /** @@ -83,6 +86,7 @@ export interface CheckoutOptions extends CommandOptions { export interface InitOptions extends CommandOptions { syncServer?: string; syncServerStorageId?: StorageId; + sub?: boolean; } /** diff --git a/src/utils/network-sync.ts b/src/utils/network-sync.ts index 3a5d3a6..51f1cfd 100644 --- a/src/utils/network-sync.ts +++ b/src/utils/network-sync.ts @@ -21,13 +21,11 @@ function debug(...args: any[]) { * * @param repo - The Automerge repository * @param rootDirectoryUrl - The root directory URL to start traversal from - * @param syncServerStorageId - The sync server storage ID * @param options - Configuration options */ export async function waitForBidirectionalSync( repo: Repo, rootDirectoryUrl: AutomergeUrl | undefined, - syncServerStorageId: StorageId | undefined, options: { timeoutMs?: number; pollIntervalMs?: number; @@ -42,7 +40,7 @@ export async function waitForBidirectionalSync( handles, } = options; - if (!syncServerStorageId || !rootDirectoryUrl) { + if (!rootDirectoryUrl) { return; } @@ -295,16 +293,20 @@ export async function waitForSync( ): Promise { const startTime = Date.now(); - if (!syncServerStorageId) { - debug("waitForSync: no sync server storage ID, skipping"); - return { failed: [] }; - } - if (handlesToWaitOn.length === 0) { debug("waitForSync: no documents to sync"); return { failed: [] }; } + // When no StorageId is available (Subduction mode), use head-stability + // polling. The SubductionSource handles sync internally — we just wait + // for each handle's heads to stop changing. + if (!syncServerStorageId) { + debug(`waitForSync: no storage ID, using head-stability polling for ${handlesToWaitOn.length} documents`); + out.taskLine(`Waiting for ${handlesToWaitOn.length} documents to sync`); + return waitForSyncViaHeadStability(handlesToWaitOn, timeoutMs, startTime); + } + debug(`waitForSync: waiting for ${handlesToWaitOn.length} documents (timeout=${timeoutMs}ms, batchSize=${SYNC_BATCH_SIZE})`); // Separate already-synced from needs-sync @@ -370,3 +372,85 @@ export async function waitForSync( return { failed }; } + +/** + * Wait for sync by polling head stability (Subduction mode). + * Each handle's heads are polled until they remain unchanged for + * several consecutive checks, indicating the SubductionSource has + * finished syncing. + */ +async function waitForSyncViaHeadStability( + handles: DocHandle[], + timeoutMs: number, + startTime: number, +): Promise { + const failed: DocHandle[] = []; + let synced = 0; + + // Process in batches + for (let i = 0; i < handles.length; i += SYNC_BATCH_SIZE) { + const batch = handles.slice(i, i + SYNC_BATCH_SIZE); + + const results = await Promise.allSettled( + batch.map(handle => waitForHandleHeadStability(handle, timeoutMs, startTime)) + ); + + for (const result of results) { + if (result.status === "rejected") { + failed.push(result.reason as DocHandle); + } else { + synced++; + } + } + } + + const elapsed = Date.now() - startTime; + if (failed.length > 0) { + debug(`waitForSync(heads): ${failed.length} documents failed after ${elapsed}ms`); + out.taskLine(`Sync: ${synced} synced, ${failed.length} timed out after ${(elapsed / 1000).toFixed(1)}s`, true); + } else { + debug(`waitForSync(heads): all ${handles.length} documents synced in ${elapsed}ms`); + out.taskLine(`All ${handles.length} documents synced (${(elapsed / 1000).toFixed(1)}s)`); + } + + return { failed }; +} + +/** + * Wait for a single handle's heads to stabilize. + * Polls heads at 100ms intervals; resolves after 3 consecutive stable + * checks, rejects on timeout. + */ +function waitForHandleHeadStability( + handle: DocHandle, + timeoutMs: number, + startTime: number, +): Promise> { + return new Promise>((resolve, reject) => { + let lastHeads = JSON.stringify(handle.heads()); + let stableCount = 0; + const stableRequired = 3; + + const pollInterval = setInterval(() => { + const currentHeads = JSON.stringify(handle.heads()); + if (currentHeads === lastHeads) { + stableCount++; + if (stableCount >= stableRequired) { + clearInterval(pollInterval); + clearTimeout(timeout); + debug(`waitForSync(heads): ${handle.url}... converged in ${Date.now() - startTime}ms`); + resolve(handle); + } + } else { + stableCount = 0; + lastHeads = currentHeads; + } + }, 100); + + const timeout = setTimeout(() => { + clearInterval(pollInterval); + debug(`waitForSync(heads): ${handle.url}... timed out after ${timeoutMs}ms`); + reject(handle); + }, timeoutMs); + }); +} diff --git a/src/utils/repo-factory.ts b/src/utils/repo-factory.ts index 36f23c1..c26db99 100644 --- a/src/utils/repo-factory.ts +++ b/src/utils/repo-factory.ts @@ -1,28 +1,150 @@ -import { Repo } from "@automerge/automerge-repo"; +import { type Repo, type RepoConfig, type NetworkAdapterInterface } from "@automerge/automerge-repo"; import { NodeFSStorageAdapter } from "@automerge/automerge-repo-storage-nodefs"; -import { BrowserWebSocketClientAdapter } from "@automerge/automerge-repo-network-websocket"; +import * as fs from "fs/promises"; import * as path from "path"; import { DirectoryConfig } from "../types"; /** - * Create an Automerge repository with configuration-based setup + * Perform a real ESM dynamic import that tsc won't rewrite to require(). + * + * TypeScript with `"module": "commonjs"` compiles `await import("x")` to + * `require("x")`, which resolves CJS entries instead of ESM entries. The + * Wasm module instance is different between the CJS and ESM module graphs, + * so initializing via CJS require() doesn't help the ESM /slim imports + * inside automerge-repo. + * + * This helper uses `new Function` to create a real `import()` expression + * that Node.js evaluates as ESM, sharing the same module graph as the + * Repo's internal imports. + */ +const dynamicImport = new Function("specifier", "return import(specifier)") as ( + specifier: string, +) => Promise; + +/** + * Initialize the Subduction Wasm module and return the Repo constructor. + * + * The Repo constructor calls set_subduction_logger() and new MemorySigner() + * from @automerge/automerge-subduction/slim, which require the Wasm module + * to be initialized first. automerge-repo exports initSubduction() to + * handle this — it dynamically imports the non-/slim entry (which + * auto-initializes the Wasm as a side effect). + * + * Both the Repo and initSubduction must be loaded via ESM dynamic import() + * so they share the same module graph as the Repo's internal /slim imports. + */ +let cachedRepoClass: typeof Repo | undefined; + +async function getRepoClass(): Promise { + if (cachedRepoClass) return cachedRepoClass; + + // Import Repo and initialize Subduction Wasm via automerge-repo's + // initSubduction() helper. This must happen before new Repo() because + // the constructor calls set_subduction_logger() and new MemorySigner() + // which require the Wasm module to be ready. + // + // Both imports use the ESM dynamic import wrapper so they share the + // same module graph as the Repo's internal /slim imports. + const repoMod = await dynamicImport("@automerge/automerge-repo"); + await repoMod.initSubduction(); + cachedRepoClass = repoMod.Repo as typeof Repo; + return cachedRepoClass; +} + +/** + * Scan a directory tree for 0-byte files, which indicate incomplete writes + * from a previous run (process exited before storage flushed). Returns true + * if any are found. + */ +async function hasCorruptStorage(dir: string): Promise { + try { + await fs.access(dir); + } catch { + return false; + } + + const entries = await fs.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (await hasCorruptStorage(fullPath)) return true; + } else if (entry.isFile()) { + const stat = await fs.stat(fullPath); + if (stat.size === 0) return true; + } + } + return false; +} + +/** + * Create an Automerge repository with configuration-based setup. + * + * When `sub` is true, uses the Subduction sync backend built into + * automerge-repo. The Repo manages its own SubductionSource internally — + * we just pass `subductionWebsocketEndpoints` and the Repo handles + * connection management, sync, and retries. + * + * When `sub` is false (default), uses the traditional WebSocket network + * adapter for sync via the automerge sync server. */ export async function createRepo( workingDir: string, - config: DirectoryConfig + config: DirectoryConfig, + sub: boolean = false ): Promise { + const RepoClass = await getRepoClass(); + const syncToolDir = path.join(workingDir, ".pushwork"); - const storage = new NodeFSStorageAdapter(path.join(syncToolDir, "automerge")); + const automergeDir = path.join(syncToolDir, "automerge"); + + // Detect and recover from corrupt local storage (0-byte files left by + // incomplete writes from a previous run). Wipe the cache so the Repo + // hydrates cleanly from the sync server. + if (await hasCorruptStorage(automergeDir)) { + console.warn("[pushwork] Corrupt local storage detected, clearing cache..."); + await fs.rm(automergeDir, { recursive: true, force: true }); + await fs.mkdir(automergeDir, { recursive: true }); + } + + const storage = new NodeFSStorageAdapter(automergeDir); + + if (sub) { + const endpoints: string[] = []; + if (config.sync_enabled && config.sync_server) { + endpoints.push(config.sync_server); + } + + return new RepoClass({ + storage, + subductionWebsocketEndpoints: endpoints, + // Disable periodic sync — reactive sync (handle.change → #save → + // #recompute → #doSync) handles pushes, and pushwork's polling + // handles pulls. The periodic timer would fire 929+ concurrent + // requests every N seconds on large trees, overwhelming the server. + // Heal-sync retries still work independently via scheduleHealSync. + periodicSyncInterval: 0, + // Disable the 5-minute batch sync timer — we control the lifecycle. + batchSyncInterval: 0, + }); + } - const repoConfig: any = { storage }; + // Default: WebSocket sync adapter + const repoConfig: RepoConfig = { storage }; - // Add network adapter only if sync is enabled and server is configured if (config.sync_enabled && config.sync_server) { - const networkAdapter = new BrowserWebSocketClientAdapter( + // Load the WebSocket adapter via ESM dynamic import to stay in the + // same module graph as the Repo. + const wsMod = await dynamicImport("@automerge/automerge-repo-network-websocket"); + // The websocket adapter package (subduction.8) hasn't updated its + // NetworkAdapter base-class types to match the repo's new + // NetworkAdapterInterface (which added state() and stricter + // EventEmitter generics). At runtime the adapter has all required + // methods; this is purely a declaration mismatch. + const networkAdapter = new wsMod.BrowserWebSocketClientAdapter( config.sync_server - ); + ) as unknown as NetworkAdapterInterface; repoConfig.network = [networkAdapter]; } - return new Repo(repoConfig); + return new RepoClass(repoConfig); } diff --git a/test/integration/sub-flag.test.ts b/test/integration/sub-flag.test.ts new file mode 100644 index 0000000..f36cccd --- /dev/null +++ b/test/integration/sub-flag.test.ts @@ -0,0 +1,187 @@ +import * as fs from "fs/promises"; +import * as path from "path"; +import * as tmp from "tmp"; +import { execSync, execFile as execFileCb } from "child_process"; +import { promisify } from "util"; +import { SnapshotManager } from "../../src/core"; + +const execFile = promisify(execFileCb); + +describe("--sub flag integration", () => { + let tmpDir: string; + let cleanup: () => void; + const cliPath = path.join(__dirname, "../../dist/cli.js"); + + beforeAll(() => { + execSync("pnpm build", { cwd: path.join(__dirname, "../.."), stdio: "pipe" }); + }); + + beforeEach(() => { + const tmpObj = tmp.dirSync({ unsafeCleanup: true }); + tmpDir = tmpObj.name; + cleanup = tmpObj.removeCallback; + }); + + afterEach(() => { + cleanup(); + }); + + /** + * Run pushwork CLI command and return stdout. + * Throws on non-zero exit code. + */ + async function pushwork(args: string[], timeoutMs = 30000): Promise { + const { stdout } = await execFile("node", [cliPath, ...args], { + timeout: timeoutMs, + env: { ...process.env, NO_COLOR: "1" }, + }); + return stdout; + } + + describe("init --sub", () => { + it("should initialize a directory with --sub flag", async () => { + await fs.writeFile(path.join(tmpDir, "hello.txt"), "Hello from sub!"); + + await pushwork(["init", "--sub", tmpDir]); + + // Verify .pushwork was created + const pushworkDir = path.join(tmpDir, ".pushwork"); + const stat = await fs.stat(pushworkDir); + expect(stat.isDirectory()).toBe(true); + + // Verify snapshot exists and tracks the file + const snapshotManager = new SnapshotManager(tmpDir); + const snapshot = await snapshotManager.load(); + expect(snapshot).not.toBeNull(); + expect(snapshot!.rootDirectoryUrl).toBeDefined(); + expect(snapshot!.rootDirectoryUrl).toMatch(/^automerge:/); + expect(snapshot!.files.has("hello.txt")).toBe(true); + }, 60000); + + it("should track files in subdirectories", async () => { + await fs.mkdir(path.join(tmpDir, "src"), { recursive: true }); + await fs.writeFile(path.join(tmpDir, "src", "index.ts"), "export default {}"); + await fs.writeFile(path.join(tmpDir, "package.json"), '{"name": "test"}'); + + await pushwork(["init", "--sub", tmpDir]); + + const snapshotManager = new SnapshotManager(tmpDir); + const snapshot = await snapshotManager.load(); + expect(snapshot).not.toBeNull(); + expect(snapshot!.files.has("src/index.ts")).toBe(true); + expect(snapshot!.files.has("package.json")).toBe(true); + }, 60000); + + it("should respect default exclude patterns with --sub", async () => { + await fs.writeFile(path.join(tmpDir, "included.txt"), "keep me"); + await fs.mkdir(path.join(tmpDir, "node_modules")); + await fs.writeFile(path.join(tmpDir, "node_modules", "dep.js"), "module"); + await fs.mkdir(path.join(tmpDir, ".git")); + await fs.writeFile(path.join(tmpDir, ".git", "HEAD"), "ref: refs/heads/main"); + + await pushwork(["init", "--sub", tmpDir]); + + const snapshotManager = new SnapshotManager(tmpDir); + const snapshot = await snapshotManager.load(); + expect(snapshot).not.toBeNull(); + expect(snapshot!.files.has("included.txt")).toBe(true); + expect(snapshot!.files.has("node_modules/dep.js")).toBe(false); + expect(snapshot!.files.has(".git/HEAD")).toBe(false); + }, 60000); + }); + + describe("sync --sub", () => { + it("should sync after init --sub", async () => { + await fs.writeFile(path.join(tmpDir, "file1.txt"), "initial content"); + + // Init with --sub + await pushwork(["init", "--sub", tmpDir]); + + // Add a new file + await fs.writeFile(path.join(tmpDir, "file2.txt"), "new file"); + + // Sync with --sub + await pushwork(["sync", "--sub", tmpDir]); + + // Verify the new file is now tracked + const snapshotManager = new SnapshotManager(tmpDir); + const snapshot = await snapshotManager.load(); + expect(snapshot).not.toBeNull(); + expect(snapshot!.files.has("file1.txt")).toBe(true); + expect(snapshot!.files.has("file2.txt")).toBe(true); + }, 60000); + + it("should detect file modifications on sync --sub", async () => { + await fs.writeFile(path.join(tmpDir, "mutable.txt"), "version 1"); + + await pushwork(["init", "--sub", tmpDir]); + + // Record initial heads + const snapshotManager = new SnapshotManager(tmpDir); + const snapshot1 = await snapshotManager.load(); + const initialHead = snapshot1!.files.get("mutable.txt")!.head; + + // Modify the file + await fs.writeFile(path.join(tmpDir, "mutable.txt"), "version 2"); + + // Sync + await pushwork(["sync", "--sub", tmpDir]); + + // Heads should have changed + const snapshot2 = await snapshotManager.load(); + const updatedHead = snapshot2!.files.get("mutable.txt")!.head; + expect(updatedHead).not.toEqual(initialHead); + }, 60000); + + it("should handle file deletions on sync --sub", async () => { + await fs.writeFile(path.join(tmpDir, "ephemeral.txt"), "delete me"); + await fs.writeFile(path.join(tmpDir, "keeper.txt"), "keep me"); + + await pushwork(["init", "--sub", tmpDir]); + + // Delete a file + await fs.unlink(path.join(tmpDir, "ephemeral.txt")); + + // Sync + await pushwork(["sync", "--sub", tmpDir]); + + // Deleted file should be gone from snapshot + const snapshotManager = new SnapshotManager(tmpDir); + const snapshot = await snapshotManager.load(); + expect(snapshot).not.toBeNull(); + expect(snapshot!.files.has("ephemeral.txt")).toBe(false); + expect(snapshot!.files.has("keeper.txt")).toBe(true); + }, 60000); + }); + + describe("url after init --sub", () => { + it("should print a valid automerge URL", async () => { + await pushwork(["init", "--sub", tmpDir]); + + const stdout = await pushwork(["url", tmpDir]); + expect(stdout.trim()).toMatch(/^automerge:/); + }, 60000); + }); + + describe("status after init --sub", () => { + it("should report status without errors", async () => { + await fs.writeFile(path.join(tmpDir, "test.txt"), "status check"); + await pushwork(["init", "--sub", tmpDir]); + + // status should not throw + const stdout = await pushwork(["status", tmpDir]); + expect(stdout).toBeDefined(); + }, 60000); + }); + + describe("diff after init --sub", () => { + it("should show no changes immediately after init", async () => { + await fs.writeFile(path.join(tmpDir, "stable.txt"), "no changes"); + await pushwork(["init", "--sub", tmpDir]); + + const stdout = await pushwork(["diff", tmpDir]); + // After a fresh init+sync, there should be no pending changes + expect(stdout).not.toContain("modified"); + }, 60000); + }); +}); diff --git a/test/unit/network-sync-sub.test.ts b/test/unit/network-sync-sub.test.ts new file mode 100644 index 0000000..61b641e --- /dev/null +++ b/test/unit/network-sync-sub.test.ts @@ -0,0 +1,144 @@ +import { waitForSync } from "../../src/utils/network-sync"; +import { DocHandle, StorageId } from "@automerge/automerge-repo"; + +/** + * Create a mock DocHandle with controllable heads. + * + * @param headSequence - An array of head values the handle returns on + * successive calls to heads(). Once exhausted, the last value repeats. + * This lets us simulate heads that change (sync in progress) and then + * stabilize (sync complete). + */ +function mockHandle(headSequence: string[][]): DocHandle { + let callCount = 0; + + return { + url: `automerge:mock-${Math.random().toString(36).slice(2)}`, + heads: () => { + const idx = Math.min(callCount++, headSequence.length - 1); + return headSequence[idx]; + }, + // getSyncInfo is only called in the StorageId path, not the head-stability path + getSyncInfo: jest.fn(), + on: jest.fn(), + off: jest.fn(), + } as unknown as DocHandle; +} + +describe("waitForSync (Subduction / head-stability mode)", () => { + // When syncServerStorageId is undefined, waitForSync should use the + // head-stability polling path instead of the getSyncInfo-based path. + + it("should return immediately for empty handle list", async () => { + const result = await waitForSync([], undefined); + expect(result.failed).toHaveLength(0); + }); + + it("should resolve when handle heads are already stable", async () => { + // Heads never change — stable from the start + const handle = mockHandle([["head-a", "head-b"]]); + const result = await waitForSync([handle], undefined, 5000); + + expect(result.failed).toHaveLength(0); + // getSyncInfo should never be called in head-stability mode + expect(handle.getSyncInfo).not.toHaveBeenCalled(); + }); + + it("should resolve after heads stabilize", async () => { + // Heads change for the first few polls, then stabilize + const handle = mockHandle([ + ["head-1"], // poll 1: initial + ["head-2"], // poll 2: changed (reset stable count) + ["head-3"], // poll 3: changed again + ["head-3"], // poll 4: stable check 1 + ["head-3"], // poll 5: stable check 2 + ["head-3"], // poll 6: stable check 3 → converged + ]); + + const result = await waitForSync([handle], undefined, 10000); + expect(result.failed).toHaveLength(0); + }); + + it("should report handle as failed on timeout", async () => { + // Heads keep changing — never stabilize + let counter = 0; + const neverStable = { + url: "automerge:never-stable", + heads: () => [`head-${counter++}`], + getSyncInfo: jest.fn(), + on: jest.fn(), + off: jest.fn(), + } as unknown as DocHandle; + + const result = await waitForSync([neverStable], undefined, 500); + expect(result.failed).toHaveLength(1); + expect(result.failed[0]).toBe(neverStable); + }); + + it("should handle a mix of stable and unstable handles", async () => { + const stable = mockHandle([["stable-head"]]); + + let counter = 0; + const unstable = { + url: "automerge:unstable", + heads: () => [`changing-${counter++}`], + getSyncInfo: jest.fn(), + on: jest.fn(), + off: jest.fn(), + } as unknown as DocHandle; + + const result = await waitForSync([stable, unstable], undefined, 500); + + // The stable handle should succeed, the unstable one should fail + expect(result.failed).toHaveLength(1); + expect(result.failed[0]).toBe(unstable); + }); + + it("should not use getSyncInfo when no StorageId is provided", async () => { + const handle = mockHandle([["head-a"]]); + await waitForSync([handle], undefined, 5000); + + // The head-stability path does not call getSyncInfo at all + expect(handle.getSyncInfo).not.toHaveBeenCalled(); + }); +}); + +describe("waitForSync (WebSocket / StorageId mode)", () => { + // When a StorageId IS provided, waitForSync should use getSyncInfo-based + // verification instead of head-stability polling. + + it("should use getSyncInfo when StorageId is provided", async () => { + const storageId = "test-storage-id" as StorageId; + const heads = ["head-a"]; + + const handle = { + url: "automerge:ws-handle", + heads: () => heads, + getSyncInfo: jest.fn().mockReturnValue({ lastHeads: heads }), + on: jest.fn(), + off: jest.fn(), + } as unknown as DocHandle; + + const result = await waitForSync([handle], storageId, 5000); + + expect(result.failed).toHaveLength(0); + expect(handle.getSyncInfo).toHaveBeenCalledWith(storageId); + }); + + it("should detect already-synced handles via getSyncInfo", async () => { + const storageId = "test-storage-id" as StorageId; + const heads = ["same-head"]; + + const handle = { + url: "automerge:already-synced", + heads: () => heads, + // getSyncInfo returns matching heads → already synced + getSyncInfo: jest.fn().mockReturnValue({ lastHeads: heads }), + on: jest.fn(), + off: jest.fn(), + } as unknown as DocHandle; + + const result = await waitForSync([handle], storageId, 5000); + expect(result.failed).toHaveLength(0); + }); +}); diff --git a/test/unit/repo-factory.test.ts b/test/unit/repo-factory.test.ts new file mode 100644 index 0000000..bfd1342 --- /dev/null +++ b/test/unit/repo-factory.test.ts @@ -0,0 +1,109 @@ +/** + * Tests for repo-factory.ts Subduction configuration. + * + * The actual Repo construction requires Wasm initialization via real ESM + * dynamic imports. We test by invoking the CLI as a subprocess (which runs + * in a real Node.js context) and inspecting the results. + * + * Non-sub (WebSocket) init is tested elsewhere (init-sync.test.ts). + * These tests focus on the --sub path. + */ + +import * as path from "path"; +import * as fs from "fs/promises"; +import * as tmp from "tmp"; +import { execSync } from "child_process"; + +describe("createRepo with --sub", () => { + let tmpDir: string; + let cleanup: () => void; + const cliPath = path.join(__dirname, "../../dist/cli.js"); + + beforeAll(() => { + execSync("pnpm build", { cwd: path.join(__dirname, "../.."), stdio: "pipe" }); + }); + + beforeEach(async () => { + const tmpObj = tmp.dirSync({ unsafeCleanup: true }); + tmpDir = tmpObj.name; + cleanup = tmpObj.removeCallback; + }); + + afterEach(() => { + cleanup(); + }); + + it("should create a working repo with --sub flag", async () => { + await fs.writeFile(path.join(tmpDir, "test.txt"), "hello"); + + execSync(`node "${cliPath}" init --sub "${tmpDir}"`, { + stdio: "pipe", + timeout: 30000, + }); + + const snapshotPath = path.join(tmpDir, ".pushwork", "snapshot.json"); + const stat = await fs.stat(snapshotPath); + expect(stat.isFile()).toBe(true); + }); + + it("should produce a valid automerge URL", async () => { + await fs.writeFile(path.join(tmpDir, "test.txt"), "hello"); + + execSync(`node "${cliPath}" init --sub "${tmpDir}"`, { + stdio: "pipe", + timeout: 30000, + }); + + const url = execSync(`node "${cliPath}" url "${tmpDir}"`, { + encoding: "utf8", + timeout: 10000, + }).trim(); + + expect(url).toMatch(/^automerge:/); + }); + + it("should track files in the snapshot", async () => { + await fs.writeFile(path.join(tmpDir, "a.txt"), "aaa"); + await fs.mkdir(path.join(tmpDir, "sub"), { recursive: true }); + await fs.writeFile(path.join(tmpDir, "sub", "b.txt"), "bbb"); + + execSync(`node "${cliPath}" init --sub "${tmpDir}"`, { + stdio: "pipe", + timeout: 30000, + }); + + const ls = execSync(`node "${cliPath}" ls "${tmpDir}"`, { + encoding: "utf8", + timeout: 10000, + }); + + expect(ls).toContain("a.txt"); + expect(ls).toContain("b.txt"); + }); + + it("should be able to sync after init", async () => { + await fs.writeFile(path.join(tmpDir, "initial.txt"), "first"); + + execSync(`node "${cliPath}" init --sub "${tmpDir}"`, { + stdio: "pipe", + timeout: 30000, + }); + + // Add a new file + await fs.writeFile(path.join(tmpDir, "added.txt"), "second"); + + // Sync should not throw + execSync(`node "${cliPath}" sync --sub "${tmpDir}"`, { + stdio: "pipe", + timeout: 30000, + }); + + const ls = execSync(`node "${cliPath}" ls "${tmpDir}"`, { + encoding: "utf8", + timeout: 10000, + }); + + expect(ls).toContain("initial.txt"); + expect(ls).toContain("added.txt"); + }); +}); diff --git a/test/unit/subduction-config.test.ts b/test/unit/subduction-config.test.ts new file mode 100644 index 0000000..a81e4bd --- /dev/null +++ b/test/unit/subduction-config.test.ts @@ -0,0 +1,76 @@ +import * as path from "path"; +import * as fs from "fs/promises"; +import * as tmp from "tmp"; +import { ConfigManager } from "../../src/core/config"; +import { DEFAULT_SUBDUCTION_SERVER, DEFAULT_SYNC_SERVER } from "../../src/types/config"; + +describe("Subduction configuration", () => { + let tmpDir: string; + let cleanup: () => void; + + beforeEach(async () => { + const tmpObj = tmp.dirSync({ unsafeCleanup: true }); + tmpDir = tmpObj.name; + cleanup = tmpObj.removeCallback; + + // Set up .pushwork directory structure + await fs.mkdir(path.join(tmpDir, ".pushwork", "automerge"), { recursive: true }); + }); + + afterEach(() => { + cleanup(); + }); + + describe("DEFAULT_SUBDUCTION_SERVER", () => { + it("should be the subduction sync endpoint", () => { + expect(DEFAULT_SUBDUCTION_SERVER).toBe("wss://subduction.sync.inkandswitch.com"); + }); + + it("should differ from the default WebSocket sync server", () => { + expect(DEFAULT_SUBDUCTION_SERVER).not.toBe(DEFAULT_SYNC_SERVER); + }); + }); + + describe("ConfigManager defaults", () => { + it("should use the WebSocket server as default sync_server", async () => { + const configManager = new ConfigManager(tmpDir); + const config = configManager.getDefaultDirectoryConfig(); + expect(config.sync_server).toBe(DEFAULT_SYNC_SERVER); + }); + + it("should not default to the subduction server", async () => { + const configManager = new ConfigManager(tmpDir); + const config = configManager.getDefaultDirectoryConfig(); + expect(config.sync_server).not.toBe(DEFAULT_SUBDUCTION_SERVER); + }); + }); + + describe("sub flag option types", () => { + // These tests verify that the option interfaces accept `sub`. + // If the type definitions are wrong, these will fail at compile time. + it("should accept sub on InitOptions", () => { + const opts: import("../../src/types/config").InitOptions = { sub: true }; + expect(opts.sub).toBe(true); + }); + + it("should accept sub on SyncOptions", () => { + const opts: import("../../src/types/config").SyncOptions = { sub: true }; + expect(opts.sub).toBe(true); + }); + + it("should accept sub on CloneOptions", () => { + const opts: import("../../src/types/config").CloneOptions = { sub: true }; + expect(opts.sub).toBe(true); + }); + + it("should accept sub on WatchOptions", () => { + const opts: import("../../src/types/config").WatchOptions = { sub: true }; + expect(opts.sub).toBe(true); + }); + + it("should default sub to undefined (not required)", () => { + const opts: import("../../src/types/config").SyncOptions = {}; + expect(opts.sub).toBeUndefined(); + }); + }); +});