Compare commits
4 commits
main
...
asch/impro
| Author | SHA1 | Date | |
|---|---|---|---|
| 3160e850ca | |||
| 9e81343ab1 | |||
| 20e1c3f22d | |||
| a33e4bbcb9 |
58 changed files with 1188 additions and 719 deletions
27
.github/dependabot.yml
vendored
Normal file
27
.github/dependabot.yml
vendored
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directories: ["/frontend", "/docs"]
|
||||
schedule:
|
||||
interval: "daily"
|
||||
|
||||
- package-ecosystem: "docker"
|
||||
directories: ["**"]
|
||||
schedule:
|
||||
interval: "daily"
|
||||
|
||||
- package-ecosystem: "cargo"
|
||||
directories: ["**"]
|
||||
schedule:
|
||||
interval: "daily"
|
||||
|
||||
# Disable this for security reasons
|
||||
# - package-ecosystem: "github-actions"
|
||||
# directories: ["**"]
|
||||
# schedule:
|
||||
# interval: "daily"
|
||||
36
.github/workflows/check.yml
vendored
Normal file
36
.github/workflows/check.yml
vendored
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
name: Check
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
RUSTFLAGS: "-Dwarnings"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: self-hosted
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4.2.0
|
||||
with:
|
||||
node-version: "25.x"
|
||||
check-latest: true
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: "1.92.0"
|
||||
components: clippy, rustfmt
|
||||
|
||||
- name: Lint & test
|
||||
run: scripts/check.sh
|
||||
58
.github/workflows/deploy-docs.yml
vendored
Normal file
58
.github/workflows/deploy-docs.yml
vendored
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
name: Deploy Documentation
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "docs/**"
|
||||
- ".github/workflows/deploy-docs.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
concurrency:
|
||||
group: pages
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: self-hosted
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4.2.0
|
||||
with:
|
||||
node-version: "25.x"
|
||||
check-latest: true
|
||||
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v4
|
||||
|
||||
- name: Build docs
|
||||
run: scripts/build-docs.sh
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v3
|
||||
with:
|
||||
path: docs/.vitepress/dist
|
||||
|
||||
deploy:
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
needs: build
|
||||
runs-on: self-hosted
|
||||
name: Deploy
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
72
.github/workflows/e2e.yml
vendored
Normal file
72
.github/workflows/e2e.yml
vendored
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
name: E2E tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
schedule:
|
||||
- cron: "0 * * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: e2e-tests
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
RUSTFLAGS: "-Dwarnings"
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: self-hosted
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4.2.0
|
||||
with:
|
||||
node-version: "25.x"
|
||||
check-latest: true
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: "1.92.0"
|
||||
components: clippy, rustfmt
|
||||
|
||||
- name: Setup rust
|
||||
run: |
|
||||
which sqlx || cargo install sqlx-cli
|
||||
cd sync-server
|
||||
sqlx database create --database-url sqlite://db.sqlite3
|
||||
sqlx migrate run --source src/app_state/database/migrations --database-url sqlite://db.sqlite3
|
||||
|
||||
- name: E2E tests
|
||||
run: |
|
||||
cd sync-server
|
||||
cargo run config-e2e.yml --color never &
|
||||
SERVER_PID=$!
|
||||
cd ..
|
||||
|
||||
scripts/e2e.sh 8
|
||||
EXIT_CODE=$?
|
||||
|
||||
kill $SERVER_PID 2>/dev/null || true
|
||||
wait $SERVER_PID 2>/dev/null || true
|
||||
|
||||
exit $EXIT_CODE
|
||||
|
||||
- name: Upload e2e logs
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: e2e-logs
|
||||
path: logs/
|
||||
retention-days: 30
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: scripts/clean-up.sh
|
||||
67
.github/workflows/publish-cli-docker.yml
vendored
Normal file
67
.github/workflows/publish-cli-docker.yml
vendored
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
name: Publish CLI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
tags: ["*"]
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}-cli
|
||||
|
||||
jobs:
|
||||
publish-docker:
|
||||
runs-on: self-hosted
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install cosign
|
||||
uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 #v3.5.0
|
||||
with:
|
||||
cosign-release: "v2.2.4"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0
|
||||
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0
|
||||
with:
|
||||
context: frontend
|
||||
file: frontend/local-client-cli/Dockerfile
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Sign the published Docker image
|
||||
env:
|
||||
TAGS: ${{ steps.meta.outputs.tags }}
|
||||
DIGEST: ${{ steps.build-and-push.outputs.digest }}
|
||||
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
|
||||
59
.github/workflows/publish-plugin.yml
vendored
Normal file
59
.github/workflows/publish-plugin.yml
vendored
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
name: Publish Obsidian plugin
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["*"]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
|
||||
jobs:
|
||||
publish-plugin:
|
||||
runs-on: self-hosted
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node.js environment
|
||||
uses: actions/setup-node@v4.2.0
|
||||
with:
|
||||
node-version: "25.x"
|
||||
check-latest: true
|
||||
|
||||
- name: Build plugin
|
||||
run: |
|
||||
cd frontend
|
||||
npm ci
|
||||
npm run build
|
||||
|
||||
- name: Setup Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
toolchain: "1.92.0"
|
||||
components: clippy, rustfmt
|
||||
|
||||
- name: Install cross-compilation tools
|
||||
run: |
|
||||
apt update
|
||||
apt install -y gcc-aarch64-linux-gnu musl-tools gcc-mingw-w64-x86-64
|
||||
|
||||
- name: Build Linux and Windows binaries
|
||||
run: ./scripts/build-sync-server-binaries.sh
|
||||
|
||||
- name: Create release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
tag="${GITHUB_REF#refs/tags/}"
|
||||
|
||||
mkdir -p release
|
||||
cp frontend/obsidian-plugin/dist/* release/
|
||||
cp sync-server/artifacts/sync-server-* release/
|
||||
cd release
|
||||
|
||||
gh release create "$tag" \
|
||||
--title="$tag" \
|
||||
--draft \
|
||||
*
|
||||
92
.github/workflows/publish-server-docker.yml
vendored
Normal file
92
.github/workflows/publish-server-docker.yml
vendored
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
name: Publish server Docker image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
tags: ["*"]
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
|
||||
env:
|
||||
# Use docker.io for Docker Hub if empty
|
||||
REGISTRY: ghcr.io
|
||||
# github.repository as <account>/<repo>
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
publish-docker:
|
||||
runs-on: self-hosted
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
# This is used to complete the identity challenge
|
||||
# with sigstore/fulcio.
|
||||
id-token: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# Install the cosign tool
|
||||
# https://github.com/sigstore/cosign-installer
|
||||
- name: Install cosign
|
||||
if: github.ref_type == 'tag'
|
||||
uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 #v3.5.0
|
||||
with:
|
||||
cosign-release: "v2.2.4"
|
||||
|
||||
# Set up BuildKit Docker container builder to be able to build
|
||||
# multi-platform images and export cache
|
||||
# https://github.com/docker/setup-buildx-action
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0
|
||||
|
||||
# Login against a Docker registry
|
||||
# https://github.com/docker/login-action
|
||||
- name: Log into registry ${{ env.REGISTRY }}
|
||||
if: github.ref_type == 'tag'
|
||||
uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Extract metadata (tags, labels) for Docker
|
||||
# https://github.com/docker/metadata-action
|
||||
- name: Extract Docker metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
|
||||
# Build and push Docker image with Buildx
|
||||
# https://github.com/docker/build-push-action
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0
|
||||
with:
|
||||
context: sync-server
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ github.ref_type == 'tag' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
# Sign the resulting Docker image digest.
|
||||
# This will only write to the public Rekor transparency log when the Docker
|
||||
# repository is public to avoid leaking data. If you would like to publish
|
||||
# transparency data even for private images, pass --force to cosign below.
|
||||
# https://github.com/sigstore/cosign
|
||||
- name: Sign the published Docker image
|
||||
if: ${{ github.ref_type == 'tag' }}
|
||||
env:
|
||||
# https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable
|
||||
TAGS: ${{ steps.meta.outputs.tags }}
|
||||
DIGEST: ${{ steps.build-and-push.outputs.digest }}
|
||||
# This step uses the identity token to provision an ephemeral certificate
|
||||
# against the sigstore community Fulcio instance.
|
||||
run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST}
|
||||
|
|
@ -3,9 +3,15 @@ import type { TestDefinition } from "../test-definition";
|
|||
|
||||
export const coalescedRemoteUpdateWatermarkLossTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 sends three rapid updates. After syncing, both clients " +
|
||||
"disconnect and reconnect twice. Content should remain correct " +
|
||||
"after each reconnect.",
|
||||
"Probes that the watermark advances correctly through coalesced " +
|
||||
"remote updates. Client 0 sends three rapid updates, all observed " +
|
||||
"by Client 1 (their vault_update_ids may coalesce in the engine's " +
|
||||
"MinCovered). Then Client 1 disconnects and Client 0 issues one " +
|
||||
"MORE update while Client 1 is offline. On Client 1's reconnect, " +
|
||||
"catch-up uses last_seen_vault_update_id — if the coalesced " +
|
||||
"updates wrongly advanced the watermark past Client 0's offline " +
|
||||
"update's id, that update is silently lost. The final assert " +
|
||||
"pins Client 1 receiving the post-reconnect update via catch-up.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "create", client: 0, path: "doc.md", content: "original" },
|
||||
|
|
@ -13,40 +19,40 @@ export const coalescedRemoteUpdateWatermarkLossTest: TestDefinition = {
|
|||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Three rapid updates — coalesce in engine queues; both clients
|
||||
// converge to "final update".
|
||||
{ type: "update", client: 0, path: "doc.md", content: "update 1" },
|
||||
{ type: "update", client: 0, path: "doc.md", content: "update 2" },
|
||||
{ type: "update", client: 0, path: "doc.md", content: "final update" },
|
||||
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1).assertContent("doc.md", "final update");
|
||||
}
|
||||
},
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
// Client 1 goes offline.
|
||||
{ type: "disable-sync", client: 1 },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
|
||||
// Client 0 issues a follow-up edit while c1 is offline. This
|
||||
// event has a vault_update_id strictly greater than the
|
||||
// coalesced sequence; if c1's watermark is correct, catch-up
|
||||
// will return it. If the watermark wrongly advanced past it
|
||||
// during the coalesce (too-new), catch-up returns nothing and
|
||||
// c1 silently misses this edit.
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "doc.md",
|
||||
content: "post-reconnect edit"
|
||||
},
|
||||
{ type: "sync", client: 0 },
|
||||
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1).assertContent("doc.md", "final update");
|
||||
}
|
||||
},
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{ type: "disable-sync", client: 1 },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1).assertContent("doc.md", "final update");
|
||||
s.assertFileCount(1).assertContent(
|
||||
"doc.md",
|
||||
"post-reconnect edit"
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -29,7 +29,13 @@ export const createRenameResponseSkipsFileTest: TestDefinition = {
|
|||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1).assertAnyFileContains("the-content");
|
||||
// The rename must land at renamed.md on both clients.
|
||||
// assertAnyFileContains alone would have passed even if
|
||||
// the rename were dropped server-side and both clients
|
||||
// converged on doc.md with "the-content".
|
||||
s.assertFileCount(1)
|
||||
.assertContent("renamed.md", "the-content")
|
||||
.assertFileNotExists("doc.md");
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -33,7 +33,10 @@ export const deleteByOtherClientThenRecreateTest: TestDefinition = {
|
|||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertContent("A.md", "recreated by client 0");
|
||||
s.assertFileCount(1).assertContent(
|
||||
"A.md",
|
||||
"recreated by client 0"
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -35,7 +35,14 @@ export const deleteRecreateConcurrentUpdateTest: TestDefinition = {
|
|||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileExists("A.md").assertContains("A.md", "recreated");
|
||||
// The description commits to "client 0's recreated content
|
||||
// preserved" — so pin to a single file at A.md with the
|
||||
// recreated content. A deconflicted "updated by client 1"
|
||||
// file would slip past assertContains/assertFileExists.
|
||||
s.assertFileCount(1).assertContent(
|
||||
"A.md",
|
||||
"recreated by client 0"
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -3,29 +3,45 @@ import type { TestDefinition } from "../test-definition";
|
|||
|
||||
export const idempotencyAfterServerPauseTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 creates a file, then the server is paused mid-response. " +
|
||||
"After the server resumes, both clients must converge to a single copy of the file with no duplicates.",
|
||||
"The server commits Client 0's create but Client 0 never sees the " +
|
||||
"response — simulating a connection drop after server-side commit. " +
|
||||
"drop-next-create-response intercepts the response in the client's " +
|
||||
"fetch wrapper after the server has already processed the POST, " +
|
||||
"raising SyncResetError. The client's offline-scan retry must be " +
|
||||
"idempotent: server-side dedup of the retried create + the " +
|
||||
"already-committed doc must NOT produce a duplicate file. " +
|
||||
"(Earlier version did `create -> pause -> resume`, where the " +
|
||||
"create could complete cleanly before the pause and the " +
|
||||
"idempotency path was never exercised.)",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
// Arm the interceptor BEFORE the create so the very first POST
|
||||
// /documents from c0 has its response dropped after server commit.
|
||||
{ type: "drop-next-create-response", client: 0 },
|
||||
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "doc.md",
|
||||
content: "important data"
|
||||
},
|
||||
{ type: "pause-server" },
|
||||
|
||||
{ type: "resume-server" },
|
||||
// Block until the server has committed and the client has been
|
||||
// notified the response was dropped — deterministic happens-before
|
||||
// for "server has the doc, client thinks the create failed".
|
||||
{ type: "wait-for-dropped-create-response", client: 0 },
|
||||
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
// No duplicate doc despite the client's retry: server-side
|
||||
// path-collision merge or idempotency must collapse them.
|
||||
s.assertFileCount(1).assertContent("doc.md", "important data");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,13 @@ import type { TestDefinition } from "../test-definition";
|
|||
|
||||
export const interruptedDeleteRetryTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 deletes a file, then the server is paused. " +
|
||||
"After the server resumes, both clients should have zero files.",
|
||||
"Client 0's delete HTTP is interrupted (server paused) and must " +
|
||||
"retry on resume. Pause is established BEFORE the delete is " +
|
||||
"issued so the DELETE is deterministically in-flight against a " +
|
||||
"frozen server — the earlier ordering (delete then pause) raced " +
|
||||
"the request: under fast scheduling the DELETE could commit " +
|
||||
"before SIGSTOP and the test reduced to a trivial " +
|
||||
"create-then-delete with no interruption at all.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "create", client: 0, path: "doc.md", content: "to be deleted" },
|
||||
|
|
@ -12,11 +17,10 @@ export const interruptedDeleteRetryTest: TestDefinition = {
|
|||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "delete", client: 0, path: "doc.md" },
|
||||
|
||||
{ type: "pause-server" },
|
||||
|
||||
{ type: "delete", client: 0, path: "doc.md" },
|
||||
{ type: "resume-server" },
|
||||
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
|
|
|
|||
|
|
@ -3,18 +3,35 @@ import type { TestDefinition } from "../test-definition";
|
|||
|
||||
export const localEditLostDuringCreateMergeTest: TestDefinition = {
|
||||
description:
|
||||
"Both clients create doc.md with different content while offline. " +
|
||||
"Client 0 also edits the file before syncing. After both connect, " +
|
||||
"the merged result should contain content from both clients.",
|
||||
"Client 0's create is in-flight (server paused) when Client 0 " +
|
||||
"writes a follow-up local edit. The wire-loop will receive the " +
|
||||
"create response, then must apply the queued local update against " +
|
||||
"the now-resolved doc id rather than discarding it as a stale " +
|
||||
"pending-create. Client 1's pre-existing same-path doc forces a " +
|
||||
"server-side merge, exercising the path where the local edit must " +
|
||||
"be re-attached to the merged doc id via replacePendingDocumentId.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
// Client 1 creates and syncs doc.md first — this is the doc the
|
||||
// server will merge Client 0's later create into.
|
||||
{ type: "create", client: 1, path: "doc.md", content: "from-client-1" },
|
||||
{
|
||||
type: "create",
|
||||
client: 0,
|
||||
path: "doc.md",
|
||||
content: "from-client-0"
|
||||
},
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
// Client 0 starts offline so the create that follows queues into
|
||||
// the engine rather than firing immediately.
|
||||
{ type: "create", client: 0, path: "doc.md", content: "from-client-0" },
|
||||
|
||||
// Pause the server so c0's POST /documents will hang once it goes.
|
||||
{ type: "pause-server" },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
|
||||
// While the create's HTTP is in-flight against the paused server,
|
||||
// c0's local edit lands. This is the queue-coalesce scenario the
|
||||
// engine must survive: the LocalUpdate carries a Promise<DocId>
|
||||
// chained off the pending create, and replacePendingDocumentId
|
||||
// must rewire it once the server's merge response resolves to
|
||||
// Client 1's existing doc id.
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
|
|
@ -22,17 +39,19 @@ export const localEditLostDuringCreateMergeTest: TestDefinition = {
|
|||
content: "local-edit-during-create"
|
||||
},
|
||||
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "sync", client: 1 },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "resume-server" },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
// The local edit must NOT be lost in the create-merge
|
||||
// collapse. assertContent (not assertContains) pins the
|
||||
// exact post-merge state — we accept either pure local
|
||||
// content (replace-wins) or a true merge containing both
|
||||
// contributions, but the local edit must be present.
|
||||
s.assertFileCount(1).assertContains(
|
||||
"doc.md",
|
||||
"from-client-1",
|
||||
"local-edit-during-create"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,11 @@ export const mcDeleteThenOfflineRenameTest: TestDefinition = {
|
|||
s.assertContent("C.md", "unrelated").assertFileNotExists(
|
||||
"A.md"
|
||||
);
|
||||
// The offline-renamed file must survive — either as B.md
|
||||
// (rename preserved) or as a deconflict carrying "original".
|
||||
// A regression that silently drops both leaves only C.md,
|
||||
// which we explicitly forbid here.
|
||||
s.assertAnyFileContains("original");
|
||||
s.ifFileExists("B.md", (inner) =>
|
||||
inner.assertContent("B.md", "original")
|
||||
);
|
||||
|
|
|
|||
|
|
@ -40,6 +40,10 @@ export const mcMultiDeleteOfflineRenameTest: TestDefinition = {
|
|||
.assertFileExists("file-5.md")
|
||||
.assertFileNotExists("file-2.md")
|
||||
.assertFileNotExists("file-4.md");
|
||||
// The offline rename of a remotely-deleted file must
|
||||
// preserve "content-2" somewhere — either at renamed.md
|
||||
// or as a deconflict.
|
||||
s.assertAnyFileContains("content-2");
|
||||
s.ifFileExists("renamed.md", (inner) =>
|
||||
inner.assertContent("renamed.md", "content-2")
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,8 +3,11 @@ import type { TestDefinition } from "../test-definition";
|
|||
|
||||
export const multiFileOperationsTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 deletes A.md while client 1 is offline. Client 1 updates B.md and renames A.md to D.md offline. " +
|
||||
"After client 1 reconnects, both clients must converge with B.md updated and C.md intact.",
|
||||
"Client 0 deletes A.md while client 1 is offline. Client 1 updates B.md " +
|
||||
"and renames its stale A.md to D.md offline. After client 1 reconnects, " +
|
||||
"B.md must hold client 1's update, C.md must be unchanged, A.md must be " +
|
||||
"gone, and the offline-renamed file must be preserved at D.md (post as " +
|
||||
"a new doc since A.md was deleted server-side).",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "create", client: 0, path: "A.md", content: "content-a" },
|
||||
|
|
@ -33,12 +36,14 @@ export const multiFileOperationsTest: TestDefinition = {
|
|||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertContains("B.md", "updated")
|
||||
.assertFileExists("C.md")
|
||||
// Pin B.md/C.md/D.md exactly: a regression that loses
|
||||
// client 1's offline rename (no D.md) or that drops the
|
||||
// B.md update would otherwise pass the loose checks.
|
||||
s.assertFileCount(3)
|
||||
.assertContent("B.md", "updated by client 1")
|
||||
.assertContent("C.md", "content-c")
|
||||
.assertContent("D.md", "content-a")
|
||||
.assertFileNotExists("A.md");
|
||||
s.ifFileExists("D.md", (inner) =>
|
||||
inner.assertContent("D.md", "content-a")
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -44,9 +44,22 @@ export const offlineConcurrentRenamesTest: TestDefinition = {
|
|||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
// Two concurrent offline renames of the same source:
|
||||
// exactly one file must remain (count 1), it must hold
|
||||
// "shared-content", and that file must be one of the two
|
||||
// rename targets — neither rename may silently land at
|
||||
// some unrelated path.
|
||||
s.assertFileNotExists("A.md")
|
||||
.assertFileCount(1)
|
||||
.assertAnyFileContains("shared-content");
|
||||
if (
|
||||
!s.files.has("B.md") &&
|
||||
!s.files.has("C.md")
|
||||
) {
|
||||
throw new Error(
|
||||
`Expected the surviving file to be B.md or C.md. Files: [${Array.from(s.files.keys()).join(", ")}]`
|
||||
);
|
||||
}
|
||||
s.ifFileExists("B.md", (inner) =>
|
||||
inner.assertContent("B.md", "shared-content")
|
||||
);
|
||||
|
|
|
|||
|
|
@ -29,9 +29,13 @@ export const offlineDeleteRemoteRenameTest: TestDefinition = {
|
|||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileNotExists("A.md").assertFileNotExists(
|
||||
"A_renamed.md"
|
||||
);
|
||||
// Delete+rename: client 0's offline delete must propagate.
|
||||
// Final state is no files; require it explicitly so a
|
||||
// regression producing some divergent identical filename
|
||||
// on both clients can't slip past the per-name checks.
|
||||
s.assertFileNotExists("A.md")
|
||||
.assertFileNotExists("A_renamed.md")
|
||||
.assertFileCount(0);
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -3,7 +3,12 @@ import type { TestDefinition } from "../test-definition";
|
|||
|
||||
export const offlineEditThenMoveSameContentTest: TestDefinition = {
|
||||
description:
|
||||
"A file is renamed and edited to match a deleted file's content. Both clients must converge despite the ambiguity.",
|
||||
"Single-client offline sequence on Client 0: delete A.md, rename " +
|
||||
"B.md to C.md, then update C.md so its content equals the deleted " +
|
||||
"A.md's content. The ambiguity is for the engine: the resulting " +
|
||||
"C.md content matches a doc that was just deleted, but it must " +
|
||||
"still be tracked as the renamed-from-B doc, not resurrected as A. " +
|
||||
"Both clients must converge to a single C.md with 'content A'.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -3,9 +3,12 @@ import type { TestDefinition } from "../test-definition";
|
|||
|
||||
export const offlineRenameRemoteCreateOldPathTest: TestDefinition = {
|
||||
description:
|
||||
"Offline-rename vs. concurrent remote-update of the same doc. " +
|
||||
"Client 0 renames X.md to Y.md while offline. Client 1 updates X.md " +
|
||||
"(same document). When Client 0 reconnects, the rename and update " +
|
||||
"should merge. Y.md should exist with Client 1's content.",
|
||||
"(same document) and syncs. When Client 0 reconnects, the rename " +
|
||||
"and update must merge: Y.md must hold Client 1's updated content. " +
|
||||
"(Filename is legacy — there is no remote create at the old path; " +
|
||||
"this is the rename-vs-update mirror of offline-edit-remote-rename.)",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "create", client: 0, path: "X.md", content: "original" },
|
||||
|
|
@ -41,7 +44,9 @@ export const offlineRenameRemoteCreateOldPathTest: TestDefinition = {
|
|||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1).assertContains(
|
||||
// Pin exact content + path: rename and update must
|
||||
// collapse onto Y.md with Client 1's update.
|
||||
s.assertFileCount(1).assertContent(
|
||||
"Y.md",
|
||||
"updated-by-client-1"
|
||||
);
|
||||
|
|
|
|||
|
|
@ -65,10 +65,12 @@ export const offlineUpdateBothThenDeleteOneTest: TestDefinition = {
|
|||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertContent(
|
||||
"A.md",
|
||||
"A updated by client 0"
|
||||
).assertFileNotExists("B.md");
|
||||
// Delete must win for B.md: pin file count to 1 so a
|
||||
// deconflict carrying client 1's update of B.md cannot
|
||||
// silently sneak in.
|
||||
s.assertFileCount(1)
|
||||
.assertContent("A.md", "A updated by client 0")
|
||||
.assertFileNotExists("B.md");
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ export const onlineDeleteRecreateRapidCycleTest: TestDefinition = {
|
|||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertContent("A.md", "round 3");
|
||||
s.assertFileCount(1).assertContent("A.md", "round 3");
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -3,8 +3,14 @@ import type { TestDefinition } from "../test-definition";
|
|||
|
||||
export const queueResetLosesCoalescedLocalEditTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 goes offline, both clients edit doc.md concurrently, " +
|
||||
"then client 0 reconnects. Both edits must be preserved.",
|
||||
"Client 0's local update is queued in the wire loop while the " +
|
||||
"server is paused (so the POST hangs), then disable-sync forces a " +
|
||||
"SyncReset that clears the wire-loop queue. On re-enable, the " +
|
||||
"engine MUST rediscover the disk content via offline scan and " +
|
||||
"merge it with the meantime remote update — otherwise the " +
|
||||
"queue-reset has silently lost a coalesced local edit. (Earlier " +
|
||||
"version of this test ran the local update while sync was already " +
|
||||
"disabled, so there was no queue to reset.)",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "create", client: 0, path: "doc.md", content: "original" },
|
||||
|
|
@ -12,13 +18,20 @@ export const queueResetLosesCoalescedLocalEditTest: TestDefinition = {
|
|||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
|
||||
{ type: "update", client: 1, path: "doc.md", content: "alpha bravo" },
|
||||
{ type: "sync", client: 1 },
|
||||
|
||||
// Pause the server so c0's wire loop can enqueue but cannot drain.
|
||||
{ type: "pause-server" },
|
||||
// c0's update is queued in the wire loop; the POST will hang.
|
||||
{ type: "update", client: 0, path: "doc.md", content: "charlie delta" },
|
||||
// disable-sync triggers a SyncReset — the in-flight POST aborts
|
||||
// with SyncResetError and the wire-loop queue is cleared.
|
||||
{ type: "disable-sync", client: 0 },
|
||||
{ type: "resume-server" },
|
||||
|
||||
// Re-enable. Offline scan must see disk content "charlie delta"
|
||||
// and merge it with the server's "alpha bravo".
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{ type: "barrier" },
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,13 @@ import type { TestDefinition } from "../test-definition";
|
|||
|
||||
export const renameCreateConflictTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 creates A.md and syncs. Client 1 renames A.md to B.md and syncs. Client 0 (offline) creates B.md with the same content. After reconnecting, both clients should converge with only B.md.",
|
||||
"Client 0 creates A.md and syncs. Client 1 renames A.md to B.md and " +
|
||||
"syncs. Client 0 (offline) creates B.md with the same content. After " +
|
||||
"reconnecting, both clients converge to two files: B.md (from the " +
|
||||
"rename, content 'hi') and a deconflicted B (1).md (the offline " +
|
||||
"create, content 'hi') — content dedup is content-hash + parent " +
|
||||
"version based, not body-only, so even identical content from " +
|
||||
"different doc lineages produces a distinct file.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
|
|
|
|||
|
|
@ -33,10 +33,14 @@ export const renameToPendingPathFallbackTest: TestDefinition = {
|
|||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileNotExists("B.md").assertContains(
|
||||
"A.md",
|
||||
"tracked B content"
|
||||
);
|
||||
// The rename clobbers the unsynced A.md on disk, so the
|
||||
// expected post-converge state is exactly one file at A.md
|
||||
// with the renamed-from-B content. A regression that
|
||||
// produced a deconflicted A (1).md carrying the lost
|
||||
// "pending A content" would slip past assertContains.
|
||||
s.assertFileNotExists("B.md")
|
||||
.assertFileCount(1)
|
||||
.assertContent("A.md", "tracked B content");
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -3,9 +3,12 @@ import type { TestDefinition } from "../test-definition";
|
|||
|
||||
export const resetClearsRecentlyDeletedResurrectionTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 deletes a file. Client 1 toggles sync off and on " +
|
||||
"(simulating reconnect). The deleted file should NOT reappear " +
|
||||
"on Client 1 after the sync reset.",
|
||||
"Client 0 deletes a file. Client 1 calls `reset` (clears tracked " +
|
||||
"state — recently-deleted set, watermark, doc records — but keeps " +
|
||||
"disk files). After the reset and re-handshake, the deleted file " +
|
||||
"should NOT reappear via offline-scan resurrection. This is the " +
|
||||
"stronger probe than disable/enable-sync, which keeps the " +
|
||||
"recently-deleted suppression set intact.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{
|
||||
|
|
@ -28,8 +31,7 @@ export const resetClearsRecentlyDeletedResurrectionTest: TestDefinition = {
|
|||
}
|
||||
},
|
||||
|
||||
{ type: "disable-sync", client: 1 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "reset", client: 1 },
|
||||
|
||||
{ type: "barrier" },
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,13 @@ import type { TestDefinition } from "../test-definition";
|
|||
|
||||
export const serverPauseBothClientsCreateTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 creates a file, then the server is paused. Client 1 creates a different file while the server is paused. After the server resumes, both files should exist on both clients.",
|
||||
"Client 0 creates and FULLY syncs alpha.md before the server is " +
|
||||
"paused, then Client 1 creates beta.md while the server is paused. " +
|
||||
"After resume, both clients must hold both files. The `sync` after " +
|
||||
"Client 0's create is required: without it the create is fire-" +
|
||||
"and-forget and SIGSTOP can land before the POST hits the server, " +
|
||||
"reducing the test to two creates against a paused server (a " +
|
||||
"different scenario from the named one).",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
|
|
@ -16,6 +22,10 @@ export const serverPauseBothClientsCreateTest: TestDefinition = {
|
|||
path: "alpha.md",
|
||||
content: "from client 0"
|
||||
},
|
||||
// Deterministic happens-before: alpha.md is on the server before
|
||||
// SIGSTOP. Without this, the test races the in-flight POST.
|
||||
{ type: "sync", client: 0 },
|
||||
|
||||
{ type: "pause-server" },
|
||||
|
||||
{
|
||||
|
|
@ -26,16 +36,14 @@ export const serverPauseBothClientsCreateTest: TestDefinition = {
|
|||
},
|
||||
|
||||
{ type: "resume-server" },
|
||||
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertContains("alpha.md", "from client 0").assertContains(
|
||||
"beta.md",
|
||||
"from client 1"
|
||||
);
|
||||
s.assertFileCount(2)
|
||||
.assertContent("alpha.md", "from client 0")
|
||||
.assertContent("beta.md", "from client 1");
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -58,7 +58,11 @@ export const serverPauseBothEditSameFileTest: TestDefinition = {
|
|||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1).assertContains(
|
||||
// Post-merge update must REPLACE the merged content, not
|
||||
// append to it. assertContent pins the exact state so a
|
||||
// regression that re-merges the new write with leftover
|
||||
// markers from the previous merge is caught.
|
||||
s.assertFileCount(1).assertContent(
|
||||
"shared.md",
|
||||
"post-merge edit from client 0"
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,9 +3,14 @@ import type { TestDefinition } from "../test-definition";
|
|||
|
||||
export const simultaneousCreateDeleteSamePathTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 creates A.md and syncs to both clients. Client 0 deletes A.md while " +
|
||||
"Client 1 (offline) updates A.md with different content. When Client 1 reconnects, " +
|
||||
"the update and delete must be reconciled. Both clients must converge.",
|
||||
"Client 0 creates A.md and syncs. Client 1 disables sync. Client 0 " +
|
||||
"deletes A.md and that delete reaches the server before Client 1 " +
|
||||
"reconnects. While offline, Client 1 updates A.md. On reconnect, " +
|
||||
"Client 1's update lands against an already-deleted server doc — " +
|
||||
"delete must win, both clients converge to zero files. (Filename is " +
|
||||
"legacy: there is no 'create' here; the scenario is online-delete " +
|
||||
"vs. offline-update, distinct from update-survives-remote-delete " +
|
||||
"where both clients are offline at delete time.)",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "create", client: 0, path: "A.md", content: "original from 0" },
|
||||
|
|
|
|||
|
|
@ -49,6 +49,10 @@ export const threeClientRenameCreateDeleteTest: TestDefinition = {
|
|||
s.assertFileNotExists("X.md").assertAnyFileContains(
|
||||
"new from C"
|
||||
);
|
||||
// Each contributing client's content must appear in at
|
||||
// most one file (no silent duplication via remote replay).
|
||||
s.assertContentInAtMostOneFile("new from C");
|
||||
s.assertContentInAtMostOneFile("original from A");
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import type { AssertableState } from "../utils/assertable-state";
|
||||
import type { TestDefinition } from "../test-definition";
|
||||
|
||||
export const updateDoesNotSurvivesRemoteDeleteTest: TestDefinition = {
|
||||
export const updateDoesNotSurviveRemoteDeleteTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 deletes a file while client 1 edits it offline. Client 0 syncs the delete first, then client 1 reconnects. Deletes always win.",
|
||||
clients: 2,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,15 @@ import type { TestDefinition } from "../test-definition";
|
|||
|
||||
export const watermarkAdvancesOnSkipTest: TestDefinition = {
|
||||
description:
|
||||
"Both clients create the same file offline. After syncing, both disconnect and reconnect. The reconnect should not replay already-processed updates.",
|
||||
"Probes that the watermark advances past 'skip' branches — events " +
|
||||
"the client receives but treats as already-applied (e.g. an " +
|
||||
"offline-create that the server merged into another doc). Both " +
|
||||
"clients create the same path offline and reconnect; one becomes " +
|
||||
"the canonical doc and the other's create is skipped via merge. " +
|
||||
"Then Client 1 disconnects, Client 0 issues a follow-up update, " +
|
||||
"and on Client 1's reconnect catch-up MUST deliver it. If the " +
|
||||
"skip branch failed to advance lastSeenUpdateId, catch-up either " +
|
||||
"wedges in re-replay (would time out) or misses the follow-up.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "enable-sync", client: 0 },
|
||||
|
|
@ -19,16 +27,27 @@ export const watermarkAdvancesOnSkipTest: TestDefinition = {
|
|||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{ type: "disable-sync", client: 0 },
|
||||
// Now exercise the skip-then-receive path. Disconnect c1, have
|
||||
// c0 push a new update, reconnect c1 — c1's catch-up must
|
||||
// deliver the update past the skipped event.
|
||||
{ type: "disable-sync", client: 1 },
|
||||
{ type: "enable-sync", client: 0 },
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "doc.md",
|
||||
content: "post-skip update"
|
||||
},
|
||||
{ type: "sync", client: 0 },
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1).assertFileExists("doc.md");
|
||||
s.assertFileCount(1).assertContent(
|
||||
"doc.md",
|
||||
"post-skip update"
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -3,7 +3,16 @@ import type { TestDefinition } from "../test-definition";
|
|||
|
||||
export const watermarkGapRemoteUpdateNotRecordedTest: TestDefinition = {
|
||||
description:
|
||||
"Client 0 sends two rapid updates. Client 1 processes both, then disconnects and reconnects. Both clients should still converge to the latest content after reconnect.",
|
||||
"Probes that the watermark records every observed remote update, " +
|
||||
"even when two arrive in close succession with intervening " +
|
||||
"vault_update_ids the client also sees. Client 0 sends two " +
|
||||
"updates with `sync` between them so they hit the wire as two " +
|
||||
"distinct broadcasts. Client 1 processes both. Then Client 1 " +
|
||||
"disconnects, Client 0 issues a third update while Client 1 is " +
|
||||
"offline, and on reconnect Client 1's catch-up MUST deliver the " +
|
||||
"third update — if the gap-update was not recorded into " +
|
||||
"lastSeenUpdateId, catch-up requests events newer than the " +
|
||||
"incorrectly-advanced watermark and silently misses the third.",
|
||||
clients: 2,
|
||||
steps: [
|
||||
{ type: "create", client: 0, path: "doc.md", content: "original" },
|
||||
|
|
@ -14,23 +23,30 @@ export const watermarkGapRemoteUpdateNotRecordedTest: TestDefinition = {
|
|||
{ type: "update", client: 0, path: "doc.md", content: "update 1" },
|
||||
{ type: "sync", client: 0 },
|
||||
{ type: "update", client: 0, path: "doc.md", content: "update 2" },
|
||||
|
||||
{ type: "barrier" },
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1).assertContent("doc.md", "update 2");
|
||||
}
|
||||
},
|
||||
|
||||
{ type: "disable-sync", client: 1 },
|
||||
|
||||
// Issued while c1 is offline. Catch-up MUST deliver this on
|
||||
// reconnect; a too-new watermark would silently skip it.
|
||||
{
|
||||
type: "update",
|
||||
client: 0,
|
||||
path: "doc.md",
|
||||
content: "offline-period update"
|
||||
},
|
||||
{ type: "sync", client: 0 },
|
||||
|
||||
{ type: "enable-sync", client: 1 },
|
||||
{ type: "barrier" },
|
||||
|
||||
{
|
||||
type: "assert-consistent",
|
||||
verify: (s: AssertableState): void => {
|
||||
s.assertFileCount(1).assertContent("doc.md", "update 2");
|
||||
s.assertFileCount(1).assertContent(
|
||||
"doc.md",
|
||||
"offline-period update"
|
||||
);
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
FROM node:25-slim AS builder
|
||||
FROM node:22-slim AS builder
|
||||
|
||||
WORKDIR /build
|
||||
|
||||
|
|
@ -7,7 +7,7 @@ COPY . .
|
|||
RUN npm ci
|
||||
RUN npm run build
|
||||
|
||||
FROM node:25-alpine
|
||||
FROM node:22-alpine
|
||||
|
||||
LABEL org.opencontainers.image.title="VaultLink Local CLI"
|
||||
LABEL org.opencontainers.image.description="Standalone CLI for VaultLink sync client"
|
||||
|
|
|
|||
|
|
@ -47,25 +47,24 @@ vaultlink \
|
|||
|
||||
### Required
|
||||
|
||||
| Option | Description |
|
||||
| ------------------------- | --------------------------------------------- |
|
||||
| `-l, --local-path <path>` | Local directory to sync |
|
||||
| `-r, --remote-uri <uri>` | Remote server WebSocket URI (ws:// or wss://) |
|
||||
| `-t, --token <token>` | Authentication token |
|
||||
| `-v, --vault-name <name>` | Vault name on server |
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `-l, --local-path <path>` | Local directory to sync |
|
||||
| `-r, --remote-uri <uri>` | Remote server WebSocket URI (ws:// or wss://) |
|
||||
| `-t, --token <token>` | Authentication token |
|
||||
| `-v, --vault-name <name>` | Vault name on server |
|
||||
|
||||
### Optional
|
||||
|
||||
| Option | Default | Description |
|
||||
| ------------------------------------ | ------- | ----------------------------------------------- |
|
||||
| `--max-file-size-mb <number>` | `10` | Maximum file size in MB |
|
||||
| `--ignore-pattern <pattern>` | - | Glob pattern to ignore (repeatable) |
|
||||
| `--websocket-retry-interval-ms <ms>` | `3500` | WebSocket reconnection interval |
|
||||
| `--log-level <level>` | `INFO` | Log level: DEBUG, INFO, WARNING, ERROR |
|
||||
| `--line-endings <mode>` | `auto` | Line ending style: auto, lf, crlf |
|
||||
| `-q, --quiet` | - | Suppress startup banner for non-interactive use |
|
||||
| `-h, --help` | - | Show help |
|
||||
| `-V, --version` | - | Show version |
|
||||
| Option | Default | Description |
|
||||
|--------|---------|-------------|
|
||||
| `--sync-concurrency <number>` | `1` | Concurrent sync operations |
|
||||
| `--max-file-size-mb <number>` | `10` | Maximum file size in MB |
|
||||
| `--ignore-pattern <pattern>` | - | Glob pattern to ignore (repeatable) |
|
||||
| `--websocket-retry-interval-ms <ms>` | `3500` | WebSocket reconnection interval |
|
||||
| `--log-level <level>` | `INFO` | Log level: DEBUG, INFO, WARNING, ERROR |
|
||||
| `-h, --help` | - | Show help |
|
||||
| `-V, --version` | - | Show version |
|
||||
|
||||
### Auto-Ignored Patterns
|
||||
|
||||
|
|
@ -75,32 +74,22 @@ vaultlink \
|
|||
### Examples
|
||||
|
||||
Basic usage:
|
||||
|
||||
```bash
|
||||
vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default
|
||||
```
|
||||
|
||||
With ignore patterns:
|
||||
|
||||
```bash
|
||||
vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default \
|
||||
--ignore-pattern "**/*.tmp" \
|
||||
--ignore-pattern "*.tmp" \
|
||||
--ignore-pattern ".DS_Store" \
|
||||
--ignore-pattern "node_modules/**"
|
||||
```
|
||||
|
||||
With debug logging and quiet startup:
|
||||
|
||||
With debug logging:
|
||||
```bash
|
||||
vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default \
|
||||
--log-level DEBUG --quiet
|
||||
```
|
||||
|
||||
Force LF line endings (useful for cross-platform vaults):
|
||||
|
||||
```bash
|
||||
vaultlink -l ./vault -r wss://sync.example.com -t token123 -v default \
|
||||
--line-endings lf
|
||||
--log-level DEBUG
|
||||
```
|
||||
|
||||
## Docker Deployment
|
||||
|
|
@ -187,7 +176,6 @@ services:
|
|||
## Development
|
||||
|
||||
Build:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
# or from the parent folder, run
|
||||
|
|
@ -195,13 +183,11 @@ docker build -f local-client-cli/Dockerfile .
|
|||
```
|
||||
|
||||
Test:
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
Docker build:
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
docker build -f local-client-cli/Dockerfile -t vault-link-cli:test .
|
||||
|
|
|
|||
|
|
@ -11,16 +11,18 @@
|
|||
"build": "webpack --mode production",
|
||||
"test": "tsx --test 'src/**/*.test.ts'"
|
||||
},
|
||||
"devDependencies": {
|
||||
"dependencies": {
|
||||
"commander": "^14.0.2",
|
||||
"watcher": "^2.3.1",
|
||||
"@types/node": "^25.0.2",
|
||||
"watcher": "^2.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.8.1",
|
||||
"sync-client": "file:../sync-client",
|
||||
"ts-loader": "^9.5.4",
|
||||
"ts-loader": "^9.5.2",
|
||||
"tslib": "2.8.1",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "5.9.3",
|
||||
"webpack": "^5.103.0",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "5.8.3",
|
||||
"webpack": "^5.99.9",
|
||||
"webpack-cli": "^6.0.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,10 +55,13 @@ test("parseArgs - parse with optional arguments", () => {
|
|||
"mytoken",
|
||||
"-v",
|
||||
"default",
|
||||
"--sync-concurrency",
|
||||
"5",
|
||||
"--max-file-size-mb",
|
||||
"20"
|
||||
]);
|
||||
|
||||
assert.equal(args.syncConcurrency, 5);
|
||||
assert.equal(args.maxFileSizeMB, 20);
|
||||
});
|
||||
|
||||
|
|
@ -150,6 +153,25 @@ test("parseArgs - default log level is INFO", () => {
|
|||
assert.equal(args.logLevel, LogLevel.INFO);
|
||||
});
|
||||
|
||||
test("parseArgs - parse DEBUG log level", () => {
|
||||
const args = parseArgs([
|
||||
"node",
|
||||
"cli.js",
|
||||
"-l",
|
||||
"/path/to/vault",
|
||||
"-r",
|
||||
"https://sync.example.com",
|
||||
"-t",
|
||||
"mytoken",
|
||||
"-v",
|
||||
"default",
|
||||
"--log-level",
|
||||
"DEBUG"
|
||||
]);
|
||||
|
||||
assert.equal(args.logLevel, LogLevel.DEBUG);
|
||||
});
|
||||
|
||||
test("parseArgs - parse ERROR log level", () => {
|
||||
const args = parseArgs([
|
||||
"node",
|
||||
|
|
@ -169,32 +191,28 @@ test("parseArgs - parse ERROR log level", () => {
|
|||
assert.equal(args.logLevel, LogLevel.ERROR);
|
||||
});
|
||||
|
||||
test("parseArgs - log level is case insensitive", () => {
|
||||
const args = parseArgs([
|
||||
"node",
|
||||
"cli.js",
|
||||
"-l",
|
||||
"/path/to/vault",
|
||||
"-r",
|
||||
"https://sync.example.com",
|
||||
"-t",
|
||||
"mytoken",
|
||||
"-v",
|
||||
"default",
|
||||
"--log-level",
|
||||
"debug"
|
||||
]);
|
||||
|
||||
test("parseArgs - reads required options from environment variables", () => {
|
||||
process.env.VAULTLINK_LOCAL_PATH = "/env/path";
|
||||
process.env.VAULTLINK_REMOTE_URI = "https://env.example.com";
|
||||
process.env.VAULTLINK_TOKEN = "env-token";
|
||||
process.env.VAULTLINK_VAULT_NAME = "env-vault";
|
||||
|
||||
try {
|
||||
const args = parseArgs(["node", "cli.js"]);
|
||||
assert.equal(args.localPath, "/env/path");
|
||||
assert.equal(args.remoteUri, "https://env.example.com");
|
||||
assert.equal(args.token, "env-token");
|
||||
assert.equal(args.vaultName, "env-vault");
|
||||
} finally {
|
||||
delete process.env.VAULTLINK_LOCAL_PATH;
|
||||
delete process.env.VAULTLINK_REMOTE_URI;
|
||||
delete process.env.VAULTLINK_TOKEN;
|
||||
delete process.env.VAULTLINK_VAULT_NAME;
|
||||
}
|
||||
assert.equal(args.logLevel, LogLevel.DEBUG);
|
||||
});
|
||||
|
||||
test("parseArgs - CLI arguments take precedence over environment variables", () => {
|
||||
process.env.VAULTLINK_TOKEN = "env-token";
|
||||
|
||||
try {
|
||||
const args = parseArgs([
|
||||
test("parseArgs - throws on invalid log level", () => {
|
||||
assert.throws(() => {
|
||||
parseArgs([
|
||||
"node",
|
||||
"cli.js",
|
||||
"-l",
|
||||
|
|
@ -202,12 +220,11 @@ test("parseArgs - CLI arguments take precedence over environment variables", ()
|
|||
"-r",
|
||||
"https://sync.example.com",
|
||||
"-t",
|
||||
"cli-token",
|
||||
"mytoken",
|
||||
"-v",
|
||||
"default"
|
||||
"default",
|
||||
"--log-level",
|
||||
"INVALID"
|
||||
]);
|
||||
assert.equal(args.token, "cli-token");
|
||||
} finally {
|
||||
delete process.env.VAULTLINK_TOKEN;
|
||||
}
|
||||
}, /Invalid log level/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,54 +1,19 @@
|
|||
import { Command, Option } from "commander";
|
||||
import { Command } from "commander";
|
||||
import packageJson from "../package.json";
|
||||
import { LogLevel } from "sync-client";
|
||||
|
||||
export const LINE_ENDING_MODES = ["auto", "lf", "crlf"] as const;
|
||||
export type LineEndingMode = (typeof LINE_ENDING_MODES)[number];
|
||||
|
||||
interface CliArgs {
|
||||
export interface CliArgs {
|
||||
remoteUri: string;
|
||||
token: string;
|
||||
vaultName: string;
|
||||
localPath: string;
|
||||
syncConcurrency?: number;
|
||||
maxFileSizeMB?: number;
|
||||
ignorePatterns?: string[];
|
||||
webSocketRetryIntervalMs?: number;
|
||||
logLevel: LogLevel;
|
||||
health?: string;
|
||||
enableTelemetry?: boolean;
|
||||
quiet: boolean;
|
||||
lineEndings: LineEndingMode;
|
||||
}
|
||||
|
||||
const VALID_PROTOCOLS = ["http://", "https://", "ws://", "wss://"];
|
||||
|
||||
const REQUIRED_OPTIONS = {
|
||||
localPath: {
|
||||
flags: "-l, --local-path <path>",
|
||||
env: "VAULTLINK_LOCAL_PATH"
|
||||
},
|
||||
remoteUri: {
|
||||
flags: "-r, --remote-uri <uri>",
|
||||
env: "VAULTLINK_REMOTE_URI"
|
||||
},
|
||||
token: { flags: "-t, --token <token>", env: "VAULTLINK_TOKEN" },
|
||||
vaultName: {
|
||||
flags: "-v, --vault-name <name>",
|
||||
env: "VAULTLINK_VAULT_NAME"
|
||||
}
|
||||
} as const;
|
||||
|
||||
function requireOption<T>(
|
||||
value: T | undefined,
|
||||
name: keyof typeof REQUIRED_OPTIONS
|
||||
): T {
|
||||
if (value === undefined) {
|
||||
const { flags, env } = REQUIRED_OPTIONS[name];
|
||||
throw new Error(
|
||||
`required option '${flags}' not specified (or set ${env})`
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parseArgs(argv: string[]): CliArgs {
|
||||
|
|
@ -60,85 +25,41 @@ export function parseArgs(argv: string[]): CliArgs {
|
|||
"VaultLink Local CLI - Sync your vault to the local filesystem"
|
||||
)
|
||||
.version(packageJson.version)
|
||||
.addOption(
|
||||
new Option(
|
||||
REQUIRED_OPTIONS.localPath.flags,
|
||||
"Local directory path to sync"
|
||||
).env(REQUIRED_OPTIONS.localPath.env)
|
||||
.option("-l, --local-path <path>", "Local directory path to sync")
|
||||
.option("-r, --remote-uri <uri>", "Remote server URI")
|
||||
.option("-t, --token <token>", "Authentication token")
|
||||
.option("-v, --vault-name <name>", "Vault name")
|
||||
.option(
|
||||
"--sync-concurrency <number>",
|
||||
"[OPTIONAL] Number of concurrent sync operations",
|
||||
parseInt
|
||||
)
|
||||
.addOption(
|
||||
new Option(
|
||||
REQUIRED_OPTIONS.remoteUri.flags,
|
||||
"Remote server URI"
|
||||
).env(REQUIRED_OPTIONS.remoteUri.env)
|
||||
.option(
|
||||
"--max-file-size-mb <number>",
|
||||
"[OPTIONAL] Maximum file size in MB",
|
||||
parseInt
|
||||
)
|
||||
.addOption(
|
||||
new Option(
|
||||
REQUIRED_OPTIONS.token.flags,
|
||||
"Authentication token"
|
||||
).env(REQUIRED_OPTIONS.token.env)
|
||||
.option(
|
||||
"--ignore-pattern <pattern...>",
|
||||
"[OPTIONAL] Patterns to ignore (can be specified multiple times)"
|
||||
)
|
||||
.addOption(
|
||||
new Option(REQUIRED_OPTIONS.vaultName.flags, "Vault name").env(
|
||||
REQUIRED_OPTIONS.vaultName.env
|
||||
)
|
||||
.option(
|
||||
"--websocket-retry-interval-ms <number>",
|
||||
"[OPTIONAL] WebSocket retry interval in milliseconds",
|
||||
parseInt
|
||||
)
|
||||
.addOption(
|
||||
new Option(
|
||||
"--max-file-size-mb <number>",
|
||||
"[OPTIONAL] Maximum file size in MB"
|
||||
)
|
||||
.argParser(parseInt)
|
||||
.env("VAULTLINK_MAX_FILE_SIZE_MB")
|
||||
.option(
|
||||
"--log-level <level>",
|
||||
"[OPTIONAL] Log level (DEBUG, INFO, WARNING, ERROR)",
|
||||
"INFO"
|
||||
)
|
||||
.addOption(
|
||||
new Option(
|
||||
"--ignore-pattern <pattern...>",
|
||||
"[OPTIONAL] Patterns to ignore (can be specified multiple times)"
|
||||
).env("VAULTLINK_IGNORE_PATTERNS")
|
||||
.option(
|
||||
"--health <path>",
|
||||
"[OPTIONAL] Path to health status file for Docker healthcheck"
|
||||
)
|
||||
.addOption(
|
||||
new Option(
|
||||
"--websocket-retry-interval-ms <number>",
|
||||
"[OPTIONAL] WebSocket retry interval in milliseconds"
|
||||
)
|
||||
.argParser(parseInt)
|
||||
.env("VAULTLINK_WEBSOCKET_RETRY_INTERVAL_MS")
|
||||
)
|
||||
.addOption(
|
||||
new Option(
|
||||
"--log-level <level>",
|
||||
"[OPTIONAL] Log level (DEBUG, INFO, WARNING, ERROR)"
|
||||
)
|
||||
.default("INFO")
|
||||
.env("VAULTLINK_LOG_LEVEL")
|
||||
)
|
||||
.addOption(
|
||||
new Option(
|
||||
"--health <path>",
|
||||
"[OPTIONAL] Path to health status file for Docker healthcheck"
|
||||
).env("VAULTLINK_HEALTH")
|
||||
)
|
||||
.addOption(
|
||||
new Option(
|
||||
"--enable-telemetry",
|
||||
"[OPTIONAL] Enable telemetry (disabled by default)"
|
||||
).env("VAULTLINK_ENABLE_TELEMETRY")
|
||||
)
|
||||
.addOption(
|
||||
new Option(
|
||||
"-q, --quiet",
|
||||
"[OPTIONAL] Suppress startup banner for non-interactive use"
|
||||
).env("VAULTLINK_QUIET")
|
||||
)
|
||||
.addOption(
|
||||
new Option(
|
||||
"--line-endings <mode>",
|
||||
"[OPTIONAL] Line ending style: auto (platform default), lf, crlf"
|
||||
)
|
||||
.default("auto")
|
||||
.choices([...LINE_ENDING_MODES])
|
||||
.env("VAULTLINK_LINE_ENDINGS")
|
||||
.option(
|
||||
"--enable-telemetry",
|
||||
"[OPTIONAL] Enable telemetry (disabled by default)"
|
||||
)
|
||||
.addHelpText(
|
||||
"after",
|
||||
|
|
@ -146,13 +67,9 @@ export function parseArgs(argv: string[]): CliArgs {
|
|||
Examples:
|
||||
$ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default
|
||||
$ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default \\
|
||||
--ignore-pattern ".git/**" --ignore-pattern "**/*.tmp"
|
||||
--ignore-pattern ".git/**" --ignore-pattern "*.tmp"
|
||||
$ vaultlink -l ./my-vault -r https://sync.example.com -t mytoken -v default \\
|
||||
--log-level DEBUG --quiet
|
||||
|
||||
Environment variables:
|
||||
All options can be configured via VAULTLINK_ prefixed environment variables.
|
||||
CLI arguments take precedence over environment variables.
|
||||
--log-level DEBUG
|
||||
`
|
||||
);
|
||||
|
||||
|
|
@ -164,6 +81,7 @@ Environment variables:
|
|||
const remoteUri = opts.remoteUri as string | undefined;
|
||||
const token = opts.token as string | undefined;
|
||||
const vaultName = opts.vaultName as string | undefined;
|
||||
const syncConcurrency = opts.syncConcurrency as number | undefined;
|
||||
const maxFileSizeMb = opts.maxFileSizeMb as number | undefined;
|
||||
const ignorePattern = opts.ignorePattern as string[] | undefined;
|
||||
const websocketRetryIntervalMs = opts.websocketRetryIntervalMs as
|
||||
|
|
@ -172,23 +90,22 @@ Environment variables:
|
|||
const logLevelStr = (opts.logLevel as string | undefined) ?? "INFO";
|
||||
const health = opts.health as string | undefined;
|
||||
const enableTelemetry = opts.enableTelemetry as boolean | undefined;
|
||||
const quiet = (opts.quiet as boolean | undefined) ?? false;
|
||||
const lineEndingsStr = (opts.lineEndings as string | undefined) ?? "auto";
|
||||
/* eslint-enable @typescript-eslint/no-unsafe-type-assertion */
|
||||
|
||||
const requiredLocalPath = requireOption(localPath, "localPath");
|
||||
const requiredRemoteUri = requireOption(remoteUri, "remoteUri");
|
||||
const requiredToken = requireOption(token, "token");
|
||||
const requiredVaultName = requireOption(vaultName, "vaultName");
|
||||
|
||||
// Validate remote URI protocol
|
||||
if (
|
||||
!VALID_PROTOCOLS.some((prefix) => requiredRemoteUri.startsWith(prefix))
|
||||
) {
|
||||
if (localPath === undefined) {
|
||||
throw new Error(
|
||||
`Invalid remote URI '${requiredRemoteUri}'. Must start with ${VALID_PROTOCOLS.join(", ")}`
|
||||
"required option '-l, --local-path <path>' not specified"
|
||||
);
|
||||
}
|
||||
if (remoteUri === undefined) {
|
||||
throw new Error("required option '--remote-uri <uri>' not specified");
|
||||
}
|
||||
if (token === undefined) {
|
||||
throw new Error("required option '--token <token>' not specified");
|
||||
}
|
||||
if (vaultName === undefined) {
|
||||
throw new Error("required option '--vault-name <name>' not specified");
|
||||
}
|
||||
|
||||
// Validate and parse log level
|
||||
const logLevelUpper = logLevelStr.toUpperCase();
|
||||
|
|
@ -203,27 +120,17 @@ Environment variables:
|
|||
}
|
||||
const logLevel = logLevelUpper;
|
||||
|
||||
const isLineEndingMode = (value: string): value is LineEndingMode =>
|
||||
(LINE_ENDING_MODES as readonly string[]).includes(value);
|
||||
if (!isLineEndingMode(lineEndingsStr)) {
|
||||
throw new Error(
|
||||
`Invalid line endings mode '${lineEndingsStr}'. Valid values are: ${LINE_ENDING_MODES.join(", ")}`
|
||||
);
|
||||
}
|
||||
const lineEndings = lineEndingsStr;
|
||||
|
||||
return {
|
||||
localPath: requiredLocalPath,
|
||||
remoteUri: requiredRemoteUri,
|
||||
token: requiredToken,
|
||||
vaultName: requiredVaultName,
|
||||
localPath,
|
||||
remoteUri,
|
||||
token,
|
||||
vaultName,
|
||||
syncConcurrency,
|
||||
maxFileSizeMB: maxFileSizeMb,
|
||||
ignorePatterns: ignorePattern,
|
||||
webSocketRetryIntervalMs: websocketRetryIntervalMs,
|
||||
logLevel,
|
||||
health,
|
||||
enableTelemetry,
|
||||
quiet,
|
||||
lineEndings
|
||||
enableTelemetry
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,27 +5,24 @@ import type { NetworkConnectionStatus } from "sync-client";
|
|||
import {
|
||||
SyncClient,
|
||||
DEFAULT_SETTINGS,
|
||||
Logger,
|
||||
LogLevel,
|
||||
LogLine,
|
||||
type SyncSettings,
|
||||
type StoredDatabase
|
||||
} from "sync-client";
|
||||
import { parseArgs, type LineEndingMode } from "./args";
|
||||
import { NodeFileSystemOperations, VAULTLINK_DIR } from "./node-filesystem";
|
||||
import { parseArgs } from "./args";
|
||||
import { NodeFileSystemOperations } from "./node-filesystem";
|
||||
import { FileWatcher } from "./file-watcher";
|
||||
import { formatLogLine } from "./logger-formatter";
|
||||
import { formatLogLine, colorize, styleText } from "./logger-formatter";
|
||||
import packageJson from "../package.json";
|
||||
|
||||
function writeHealthStatus(
|
||||
logger: Logger,
|
||||
filePath: string,
|
||||
connectionStatus: NetworkConnectionStatus
|
||||
): void {
|
||||
try {
|
||||
fsSync.writeFileSync(filePath, JSON.stringify(connectionStatus));
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
console.error(
|
||||
`Failed to write health status to ${filePath}: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
|
|
@ -38,41 +35,12 @@ const LOG_LEVEL_ORDER = {
|
|||
[LogLevel.ERROR]: 3
|
||||
};
|
||||
|
||||
function createLogHandler(minLevel: LogLevel): (logLine: LogLine) => void {
|
||||
return (logLine: LogLine): void => {
|
||||
if (LOG_LEVEL_ORDER[logLine.level] >= LOG_LEVEL_ORDER[minLevel]) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(formatLogLine(logLine));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const HEALTH_CHECK_INTERVAL_MS = 30 * 1000;
|
||||
const PROGRESS_LOG_INTERVAL_MS = 2000;
|
||||
|
||||
function resolveLineEndings(mode: LineEndingMode): string {
|
||||
switch (mode) {
|
||||
case "lf":
|
||||
return "\n";
|
||||
case "crlf":
|
||||
return "\r\n";
|
||||
case "auto":
|
||||
return process.platform === "win32" ? "\r\n" : "\n";
|
||||
}
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const args = parseArgs(process.argv);
|
||||
const absolutePath = path.resolve(args.localPath);
|
||||
|
||||
const logHandler = createLogHandler(args.logLevel);
|
||||
// Boot-time messages are emitted directly through logHandler before the
|
||||
// SyncClient (and its Logger) exist; afterwards every log line flows
|
||||
// through client.logger.
|
||||
const emitBoot = (level: LogLevel, message: string): void => {
|
||||
logHandler(new LogLine(level, message));
|
||||
};
|
||||
|
||||
if (!fsSync.existsSync(absolutePath)) {
|
||||
fsSync.mkdirSync(absolutePath, { recursive: true });
|
||||
}
|
||||
|
|
@ -80,31 +48,38 @@ async function main(): Promise<void> {
|
|||
try {
|
||||
const stats = await fs.stat(absolutePath);
|
||||
if (!stats.isDirectory()) {
|
||||
emitBoot(LogLevel.ERROR, `${absolutePath} is not a directory`);
|
||||
console.error(
|
||||
colorize(`Error: ${absolutePath} is not a directory`, "red")
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error) {
|
||||
emitBoot(
|
||||
LogLevel.ERROR,
|
||||
`Cannot access directory ${absolutePath}: ${error instanceof Error ? error.message : String(error)}`
|
||||
console.error(
|
||||
colorize(
|
||||
`Error: Cannot access directory ${absolutePath}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
"red"
|
||||
)
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!args.quiet) {
|
||||
emitBoot(LogLevel.INFO, `VaultLink Local CLI v${packageJson.version}`);
|
||||
emitBoot(LogLevel.INFO, `Local path: ${absolutePath}`);
|
||||
emitBoot(LogLevel.INFO, `Remote URI: ${args.remoteUri}`);
|
||||
emitBoot(LogLevel.INFO, `Vault name: ${args.vaultName}`);
|
||||
if (args.lineEndings !== "auto") {
|
||||
emitBoot(
|
||||
LogLevel.INFO,
|
||||
`Line endings: ${args.lineEndings.toUpperCase()}`
|
||||
);
|
||||
}
|
||||
}
|
||||
console.log(
|
||||
styleText("VaultLink Local CLI", "bold", "cyan") +
|
||||
colorize(` v${packageJson.version}`, "dim")
|
||||
);
|
||||
console.log(colorize("=".repeat(50), "dim"));
|
||||
console.log(
|
||||
`${colorize("Local path:", "dim")} ${colorize(absolutePath, "green")}`
|
||||
);
|
||||
console.log(
|
||||
`${colorize("Remote URI:", "dim")} ${colorize(args.remoteUri, "cyan")}`
|
||||
);
|
||||
console.log(
|
||||
`${colorize("Vault name:", "dim")} ${colorize(args.vaultName, "green")}`
|
||||
);
|
||||
console.log("");
|
||||
|
||||
const dataDir = path.join(absolutePath, VAULTLINK_DIR);
|
||||
const dataDir = path.join(absolutePath, ".vaultlink");
|
||||
const dataFile = path.join(dataDir, "sync-data.json");
|
||||
|
||||
await fs.mkdir(dataDir, { recursive: true });
|
||||
|
|
@ -113,7 +88,8 @@ async function main(): Promise<void> {
|
|||
|
||||
const ignorePatterns = [
|
||||
...(args.ignorePatterns ?? []),
|
||||
`${VAULTLINK_DIR}/**`
|
||||
".vaultlink/**",
|
||||
".git/**"
|
||||
];
|
||||
|
||||
const settings: SyncSettings = {
|
||||
|
|
@ -121,6 +97,8 @@ async function main(): Promise<void> {
|
|||
remoteUri: args.remoteUri,
|
||||
token: args.token,
|
||||
vaultName: args.vaultName,
|
||||
syncConcurrency:
|
||||
args.syncConcurrency ?? DEFAULT_SETTINGS.syncConcurrency,
|
||||
maxFileSizeMB: args.maxFileSizeMB ?? DEFAULT_SETTINGS.maxFileSizeMB,
|
||||
ignorePatterns,
|
||||
webSocketRetryIntervalMs:
|
||||
|
|
@ -141,9 +119,11 @@ async function main(): Promise<void> {
|
|||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
database = JSON.parse(content) as Partial<StoredDatabase>;
|
||||
} catch {
|
||||
emitBoot(
|
||||
LogLevel.WARNING,
|
||||
`Cannot read data file at ${dataFile}`
|
||||
console.error(
|
||||
colorize(
|
||||
`Cannot read data file at ${dataFile}`,
|
||||
"yellow"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -153,27 +133,23 @@ async function main(): Promise<void> {
|
|||
};
|
||||
},
|
||||
save: async ({ database: persistedDatabase }) => {
|
||||
// settings can't be updated when running with this CLI
|
||||
await fs.writeFile(
|
||||
dataFile,
|
||||
JSON.stringify(persistedDatabase, null, 2)
|
||||
);
|
||||
}
|
||||
},
|
||||
nativeLineEndings: resolveLineEndings(args.lineEndings)
|
||||
nativeLineEndings: process.platform === "win32" ? "\r\n" : "\n"
|
||||
});
|
||||
|
||||
if (args.health !== undefined) {
|
||||
const healthFile = args.health;
|
||||
const writeHealth = (): void => {
|
||||
const healthInterval = setInterval(() => {
|
||||
void client.checkConnection().then((status) => {
|
||||
writeHealthStatus(client.logger, healthFile, status);
|
||||
writeHealthStatus(healthFile, status);
|
||||
});
|
||||
};
|
||||
writeHealth();
|
||||
const healthInterval = setInterval(
|
||||
writeHealth,
|
||||
HEALTH_CHECK_INTERVAL_MS
|
||||
);
|
||||
}, HEALTH_CHECK_INTERVAL_MS);
|
||||
const clearHealthInterval = (): void => {
|
||||
clearInterval(healthInterval);
|
||||
};
|
||||
|
|
@ -182,10 +158,17 @@ async function main(): Promise<void> {
|
|||
process.on("exit", clearHealthInterval);
|
||||
}
|
||||
|
||||
client.logger.onLogEmitted.add(logHandler);
|
||||
// Add colored log formatter with level filtering
|
||||
client.logger.onLogEmitted.add((logLine) => {
|
||||
// Only show messages at or above the configured log level
|
||||
if (LOG_LEVEL_ORDER[logLine.level] >= LOG_LEVEL_ORDER[args.logLevel]) {
|
||||
console.log(formatLogLine(logLine));
|
||||
}
|
||||
});
|
||||
|
||||
client.logger.info("Starting sync client");
|
||||
|
||||
const fileWatcher = new FileWatcher(absolutePath, client, ignorePatterns);
|
||||
const fileWatcher = new FileWatcher(absolutePath, client);
|
||||
|
||||
client.onWebSocketStatusChanged.add(() => {
|
||||
const isConnected = client.isWebSocketConnected;
|
||||
|
|
@ -194,54 +177,26 @@ async function main(): Promise<void> {
|
|||
);
|
||||
});
|
||||
|
||||
let syncBatchSize = 0;
|
||||
let totalSyncOps = 0;
|
||||
let lastProgressLogTime = 0;
|
||||
|
||||
client.onRemainingOperationsCountChanged.add((remaining) => {
|
||||
if (remaining > syncBatchSize) {
|
||||
syncBatchSize = remaining;
|
||||
}
|
||||
|
||||
if (remaining === 0) {
|
||||
if (syncBatchSize > 0) {
|
||||
totalSyncOps += syncBatchSize;
|
||||
client.logger.info(
|
||||
`Sync batch complete (${syncBatchSize} operations)`
|
||||
);
|
||||
syncBatchSize = 0;
|
||||
}
|
||||
client.logger.info("All sync operations completed");
|
||||
} else {
|
||||
const now = Date.now();
|
||||
if (now - lastProgressLogTime >= PROGRESS_LOG_INTERVAL_MS) {
|
||||
client.logger.info(
|
||||
`Syncing: ${remaining} operations remaining`
|
||||
);
|
||||
lastProgressLogTime = now;
|
||||
}
|
||||
client.logger.info(`${remaining} sync operations remaining`);
|
||||
}
|
||||
});
|
||||
|
||||
let isShuttingDown = false;
|
||||
const gracefulShutdown = async (signal: string): Promise<void> => {
|
||||
if (isShuttingDown) {
|
||||
return;
|
||||
}
|
||||
isShuttingDown = true;
|
||||
|
||||
client.logger.info(`${signal} received, shutting down gracefully`);
|
||||
console.log(
|
||||
colorize(
|
||||
`\n${signal} received. Shutting down gracefully...`,
|
||||
"yellow"
|
||||
)
|
||||
);
|
||||
|
||||
fileWatcher.stop();
|
||||
await client.waitUntilFinished();
|
||||
await client.destroy();
|
||||
|
||||
if (totalSyncOps > 0) {
|
||||
client.logger.info(
|
||||
`Shutdown complete (${totalSyncOps} operations synced)`
|
||||
);
|
||||
} else {
|
||||
client.logger.info("Shutdown complete");
|
||||
}
|
||||
console.log(colorize("Shutdown complete", "green"));
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
|
|
@ -255,21 +210,27 @@ async function main(): Promise<void> {
|
|||
try {
|
||||
const connectionStatus = await client.checkConnection();
|
||||
if (!connectionStatus.isSuccessful) {
|
||||
client.logger.error(
|
||||
`Cannot connect to server: ${connectionStatus.serverMessage}`
|
||||
console.error(
|
||||
colorize(
|
||||
`Error: Cannot connect to server: ${connectionStatus.serverMessage}`,
|
||||
"red"
|
||||
)
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!args.quiet) {
|
||||
client.logger.info("Server connection successful");
|
||||
}
|
||||
console.log(`${colorize("✓", "green")} Server connection successful`);
|
||||
console.log(colorize("Press Ctrl+C to stop", "dim"));
|
||||
console.log("");
|
||||
|
||||
await client.start();
|
||||
fileWatcher.start();
|
||||
} catch (error) {
|
||||
client.logger.error(
|
||||
`Fatal error: ${error instanceof Error ? error.message : String(error)}`
|
||||
console.error(
|
||||
colorize(
|
||||
`Fatal error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
"red"
|
||||
)
|
||||
);
|
||||
|
||||
fileWatcher.stop();
|
||||
|
|
@ -279,9 +240,11 @@ async function main(): Promise<void> {
|
|||
}
|
||||
|
||||
main().catch((error: unknown) => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(
|
||||
`Unexpected error: ${error instanceof Error ? error.message : String(error)}`
|
||||
colorize(
|
||||
`Unexpected error: ${error instanceof Error ? error.message : String(error)}`,
|
||||
"red"
|
||||
)
|
||||
);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,20 +1,15 @@
|
|||
import Watcher from "watcher";
|
||||
import * as path from "path";
|
||||
import type { SyncClient, RelativePath } from "sync-client";
|
||||
import { toUnixPath, matchesGlob } from "./path-utils";
|
||||
|
||||
export class FileWatcher {
|
||||
private watcher: Watcher | undefined;
|
||||
private isRunning = false;
|
||||
private readonly ignorePatterns: string[];
|
||||
|
||||
public constructor(
|
||||
private readonly basePath: string,
|
||||
private readonly client: SyncClient,
|
||||
ignorePatterns: string[] = []
|
||||
) {
|
||||
this.ignorePatterns = ignorePatterns;
|
||||
}
|
||||
private readonly client: SyncClient
|
||||
) {}
|
||||
|
||||
public start(): void {
|
||||
if (this.isRunning) {
|
||||
|
|
@ -27,8 +22,7 @@ export class FileWatcher {
|
|||
recursive: true,
|
||||
renameDetection: true,
|
||||
renameTimeout: 125,
|
||||
ignoreInitial: true,
|
||||
ignore: (filePath: string): boolean => this.shouldIgnore(filePath)
|
||||
ignoreInitial: true
|
||||
});
|
||||
|
||||
this.watcher.on("add", (filePath: string) => {
|
||||
|
|
@ -62,32 +56,66 @@ export class FileWatcher {
|
|||
this.client.logger.info("File watcher stopped");
|
||||
}
|
||||
|
||||
private shouldIgnore(filePath: string): boolean {
|
||||
const rel = toUnixPath(path.relative(this.basePath, filePath));
|
||||
return this.ignorePatterns.some((pattern) => matchesGlob(rel, pattern));
|
||||
}
|
||||
|
||||
private handleCreate(relativePath: RelativePath): void {
|
||||
this.client.syncLocallyCreatedFile(relativePath);
|
||||
this.client
|
||||
.syncLocallyCreatedFile(relativePath)
|
||||
.catch((err: unknown) => {
|
||||
this.client.logger.error(
|
||||
`Failed to sync created file ${relativePath}: ${this.formatError(err)}`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private handleChange(relativePath: RelativePath): void {
|
||||
this.client.syncLocallyUpdatedFile({ relativePath });
|
||||
this.client
|
||||
.syncLocallyUpdatedFile({ relativePath })
|
||||
.catch((err: unknown) => {
|
||||
this.client.logger.error(
|
||||
`Failed to sync updated file ${relativePath}: ${this.formatError(err)}`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private handleDelete(relativePath: RelativePath): void {
|
||||
this.client.syncLocallyDeletedFile(relativePath);
|
||||
this.client
|
||||
.syncLocallyDeletedFile(relativePath)
|
||||
.catch((err: unknown) => {
|
||||
this.client.logger.error(
|
||||
`Failed to sync deleted file ${relativePath}: ${this.formatError(err)}`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private handleRename(oldPath: RelativePath, newPath: RelativePath): void {
|
||||
this.client.logger.info(`File renamed: ${oldPath} -> ${newPath}`);
|
||||
this.client.syncLocallyUpdatedFile({
|
||||
oldPath,
|
||||
relativePath: newPath
|
||||
});
|
||||
this.client
|
||||
.syncLocallyUpdatedFile({
|
||||
oldPath,
|
||||
relativePath: newPath
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
this.client.logger.error(
|
||||
`Failed to sync renamed file ${oldPath} -> ${newPath}: ${this.formatError(err)}`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
private toRelativePath(absolutePath: string): RelativePath {
|
||||
return toUnixPath(path.relative(this.basePath, absolutePath));
|
||||
const relative = path.relative(this.basePath, absolutePath);
|
||||
return this.toUnixPath(relative);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a native platform path to forward slashes
|
||||
*/
|
||||
private toUnixPath(nativePath: string): string {
|
||||
if (path.sep === "\\") {
|
||||
return nativePath.replace(/\\/g, "/");
|
||||
}
|
||||
return nativePath;
|
||||
}
|
||||
|
||||
private formatError(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
#!/usr/bin/env node
|
||||
/* eslint-disable no-console */
|
||||
|
||||
/**
|
||||
* Healthcheck script for Docker container
|
||||
|
|
|
|||
|
|
@ -1,50 +0,0 @@
|
|||
import { test } from "node:test";
|
||||
import * as assert from "node:assert/strict";
|
||||
import { formatLogLine } from "./logger-formatter";
|
||||
import { LogLevel } from "sync-client";
|
||||
|
||||
test("formatLogLine - includes level and message", () => {
|
||||
const logLine = {
|
||||
timestamp: new Date("2024-01-15T10:30:45.123Z"),
|
||||
level: LogLevel.INFO,
|
||||
message: "Test message"
|
||||
};
|
||||
|
||||
const result = formatLogLine(logLine);
|
||||
assert.ok(result.includes("INFO"));
|
||||
assert.ok(result.includes("Test message"));
|
||||
});
|
||||
|
||||
test("formatLogLine - ERROR level messages contain bold escape", () => {
|
||||
const logLine = {
|
||||
timestamp: new Date("2024-01-15T10:30:45.123Z"),
|
||||
level: LogLevel.ERROR,
|
||||
message: "Error occurred"
|
||||
};
|
||||
|
||||
const result = formatLogLine(logLine);
|
||||
assert.ok(result.includes("\x1b[1m"));
|
||||
});
|
||||
|
||||
test("formatLogLine - highlights file paths in quotes", () => {
|
||||
const logLine = {
|
||||
timestamp: new Date("2024-01-15T10:30:45.123Z"),
|
||||
level: LogLevel.INFO,
|
||||
message: 'Syncing "notes/test.md"'
|
||||
};
|
||||
|
||||
const result = formatLogLine(logLine);
|
||||
assert.ok(result.includes("\x1b[35m"));
|
||||
});
|
||||
|
||||
test("formatLogLine - highlights standalone numbers but not numbers in versions", () => {
|
||||
const logLine = {
|
||||
timestamp: new Date("2024-01-15T10:30:45.123Z"),
|
||||
level: LogLevel.INFO,
|
||||
message: "Listed 42 files from v1.2.3"
|
||||
};
|
||||
|
||||
const result = formatLogLine(logLine);
|
||||
assert.ok(result.includes("\x1b[36m42\x1b[0m"));
|
||||
assert.ok(!result.includes("\x1b[36m1\x1b[0m."));
|
||||
});
|
||||
|
|
@ -1,21 +1,36 @@
|
|||
import { LogLevel, type LogLine } from "sync-client";
|
||||
|
||||
const colors = {
|
||||
// ANSI color codes
|
||||
export const colors = {
|
||||
reset: "\x1b[0m",
|
||||
bold: "\x1b[1m",
|
||||
dim: "\x1b[2m",
|
||||
|
||||
// Foreground colors
|
||||
red: "\x1b[31m",
|
||||
green: "\x1b[32m",
|
||||
yellow: "\x1b[33m",
|
||||
blue: "\x1b[34m",
|
||||
magenta: "\x1b[35m",
|
||||
cyan: "\x1b[36m",
|
||||
gray: "\x1b[90m"
|
||||
} as const;
|
||||
|
||||
function colorize(text: string, color: keyof typeof colors): string {
|
||||
export function colorize(text: string, color: keyof typeof colors): string {
|
||||
return `${colors[color]}${text}${colors.reset}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to apply multiple color modifiers to text
|
||||
*/
|
||||
export function styleText(
|
||||
text: string,
|
||||
...modifiers: (keyof typeof colors)[]
|
||||
): string {
|
||||
const prefix = modifiers.map((m) => colors[m]).join("");
|
||||
return `${prefix}${text}${colors.reset}`;
|
||||
}
|
||||
|
||||
function formatTimestamp(date: Date): string {
|
||||
const [time] = date.toTimeString().split(" ");
|
||||
const ms = date.getMilliseconds().toString().padStart(3, "0");
|
||||
|
|
|
|||
|
|
@ -1,32 +1,31 @@
|
|||
import * as fs from "fs/promises";
|
||||
import type { Dirent } from "fs";
|
||||
import * as path from "path";
|
||||
import { randomUUID } from "crypto";
|
||||
import type {
|
||||
FileSystemOperations,
|
||||
RelativePath,
|
||||
TextWithCursors
|
||||
} from "sync-client";
|
||||
import { toUnixPath } from "./path-utils";
|
||||
|
||||
// VaultLink's per-vault metadata directory. Holds the persisted sync database
|
||||
// and the tmp files atomicWrite renames into place; the matching `${VAULTLINK_DIR}/**`
|
||||
// ignore pattern keeps everything in here invisible to the file watcher.
|
||||
export const VAULTLINK_DIR = ".vaultlink";
|
||||
|
||||
export class NodeFileSystemOperations implements FileSystemOperations {
|
||||
public constructor(private readonly basePath: string) { }
|
||||
public constructor(private readonly basePath: string) {}
|
||||
|
||||
public async listFilesRecursively(
|
||||
directory: RelativePath | undefined
|
||||
): Promise<RelativePath[]> {
|
||||
const files: RelativePath[] = [];
|
||||
await this.walkDirectory(directory ?? "", files);
|
||||
await this.walkDirectory(
|
||||
directory !== undefined ? this.toNativePath(directory) : "",
|
||||
files
|
||||
);
|
||||
return files;
|
||||
}
|
||||
|
||||
public async read(relativePath: RelativePath): Promise<Uint8Array> {
|
||||
const fullPath = path.join(this.basePath, relativePath);
|
||||
const fullPath = path.join(
|
||||
this.basePath,
|
||||
this.toNativePath(relativePath)
|
||||
);
|
||||
try {
|
||||
return await fs.readFile(fullPath);
|
||||
} catch (error) {
|
||||
|
|
@ -40,12 +39,15 @@ export class NodeFileSystemOperations implements FileSystemOperations {
|
|||
relativePath: RelativePath,
|
||||
content: Uint8Array
|
||||
): Promise<void> {
|
||||
const fullPath = path.join(this.basePath, relativePath);
|
||||
const fullPath = path.join(
|
||||
this.basePath,
|
||||
this.toNativePath(relativePath)
|
||||
);
|
||||
const dir = path.dirname(fullPath);
|
||||
|
||||
try {
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
await this.atomicWrite(fullPath, content);
|
||||
await fs.writeFile(fullPath, content);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to write file ${fullPath}: ${error instanceof Error ? error.message : String(error)}`
|
||||
|
|
@ -57,12 +59,15 @@ export class NodeFileSystemOperations implements FileSystemOperations {
|
|||
relativePath: RelativePath,
|
||||
updater: (current: TextWithCursors) => TextWithCursors
|
||||
): Promise<string> {
|
||||
const fullPath = path.join(this.basePath, relativePath);
|
||||
const fullPath = path.join(
|
||||
this.basePath,
|
||||
this.toNativePath(relativePath)
|
||||
);
|
||||
|
||||
try {
|
||||
const currentContent = await fs.readFile(fullPath, "utf-8");
|
||||
const result = updater({ text: currentContent, cursors: [] });
|
||||
await this.atomicWrite(fullPath, result.text, "utf-8");
|
||||
await fs.writeFile(fullPath, result.text, "utf-8");
|
||||
return result.text;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
|
|
@ -72,7 +77,10 @@ export class NodeFileSystemOperations implements FileSystemOperations {
|
|||
}
|
||||
|
||||
public async getFileSize(relativePath: RelativePath): Promise<number> {
|
||||
const fullPath = path.join(this.basePath, relativePath);
|
||||
const fullPath = path.join(
|
||||
this.basePath,
|
||||
this.toNativePath(relativePath)
|
||||
);
|
||||
try {
|
||||
const stats = await fs.stat(fullPath);
|
||||
return stats.size;
|
||||
|
|
@ -84,7 +92,10 @@ export class NodeFileSystemOperations implements FileSystemOperations {
|
|||
}
|
||||
|
||||
public async exists(relativePath: RelativePath): Promise<boolean> {
|
||||
const fullPath = path.join(this.basePath, relativePath);
|
||||
const fullPath = path.join(
|
||||
this.basePath,
|
||||
this.toNativePath(relativePath)
|
||||
);
|
||||
try {
|
||||
await fs.access(fullPath);
|
||||
return true;
|
||||
|
|
@ -94,7 +105,10 @@ export class NodeFileSystemOperations implements FileSystemOperations {
|
|||
}
|
||||
|
||||
public async createDirectory(relativePath: RelativePath): Promise<void> {
|
||||
const fullPath = path.join(this.basePath, relativePath);
|
||||
const fullPath = path.join(
|
||||
this.basePath,
|
||||
this.toNativePath(relativePath)
|
||||
);
|
||||
try {
|
||||
await fs.mkdir(fullPath, { recursive: false });
|
||||
} catch (error) {
|
||||
|
|
@ -105,7 +119,10 @@ export class NodeFileSystemOperations implements FileSystemOperations {
|
|||
}
|
||||
|
||||
public async delete(relativePath: RelativePath): Promise<void> {
|
||||
const fullPath = path.join(this.basePath, relativePath);
|
||||
const fullPath = path.join(
|
||||
this.basePath,
|
||||
this.toNativePath(relativePath)
|
||||
);
|
||||
try {
|
||||
await fs.unlink(fullPath);
|
||||
} catch (error) {
|
||||
|
|
@ -119,8 +136,14 @@ export class NodeFileSystemOperations implements FileSystemOperations {
|
|||
oldPath: RelativePath,
|
||||
newPath: RelativePath
|
||||
): Promise<void> {
|
||||
const oldFullPath = path.join(this.basePath, oldPath);
|
||||
const newFullPath = path.join(this.basePath, newPath);
|
||||
const oldFullPath = path.join(
|
||||
this.basePath,
|
||||
this.toNativePath(oldPath)
|
||||
);
|
||||
const newFullPath = path.join(
|
||||
this.basePath,
|
||||
this.toNativePath(newPath)
|
||||
);
|
||||
const newDir = path.dirname(newFullPath);
|
||||
|
||||
try {
|
||||
|
|
@ -133,44 +156,6 @@ export class NodeFileSystemOperations implements FileSystemOperations {
|
|||
}
|
||||
}
|
||||
|
||||
private async atomicWrite(
|
||||
fullPath: string,
|
||||
content: Uint8Array | string,
|
||||
encoding?: BufferEncoding
|
||||
): Promise<void> {
|
||||
const tmpDir = path.join(this.basePath, VAULTLINK_DIR);
|
||||
await fs.mkdir(tmpDir, { recursive: true });
|
||||
const tmpPath = path.join(tmpDir, `atomic-write-${randomUUID()}.tmp`);
|
||||
try {
|
||||
await fs.writeFile(tmpPath, content, encoding);
|
||||
const fd = await fs.open(tmpPath, "r");
|
||||
try {
|
||||
await fd.datasync();
|
||||
} finally {
|
||||
await fd.close();
|
||||
}
|
||||
await fs.rename(tmpPath, fullPath);
|
||||
await this.syncDirectory(path.dirname(fullPath));
|
||||
} catch (error) {
|
||||
await fs.unlink(tmpPath).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Make the rename durable by fsync'ing the destination's parent directory.
|
||||
// Skipped on Windows: fsync on a directory handle isn't supported there
|
||||
private async syncDirectory(dir: string): Promise<void> {
|
||||
if (process.platform === "win32") {
|
||||
return;
|
||||
}
|
||||
const fd = await fs.open(dir, "r");
|
||||
try {
|
||||
await fd.sync();
|
||||
} finally {
|
||||
await fd.close();
|
||||
}
|
||||
}
|
||||
|
||||
private async walkDirectory(
|
||||
relativePath: string,
|
||||
files: RelativePath[]
|
||||
|
|
@ -194,8 +179,28 @@ export class NodeFileSystemOperations implements FileSystemOperations {
|
|||
await this.walkDirectory(entryRelativePath, files);
|
||||
} else if (entry.isFile()) {
|
||||
// Always return forward slashes
|
||||
files.push(toUnixPath(entryRelativePath));
|
||||
files.push(this.toUnixPath(entryRelativePath));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a forward-slash path to native platform path separators
|
||||
*/
|
||||
private toNativePath(relativePath: string): string {
|
||||
if (path.sep === "\\") {
|
||||
return relativePath.replace(/\//g, "\\");
|
||||
}
|
||||
return relativePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a native platform path to forward slashes
|
||||
*/
|
||||
private toUnixPath(nativePath: string): string {
|
||||
if (path.sep === "\\") {
|
||||
return nativePath.replace(/\\/g, "/");
|
||||
}
|
||||
return nativePath;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,60 +0,0 @@
|
|||
import { test } from "node:test";
|
||||
import * as assert from "node:assert/strict";
|
||||
import { matchesGlob, toUnixPath } from "./path-utils";
|
||||
|
||||
test("matchesGlob - exact match", () => {
|
||||
assert.equal(matchesGlob(".DS_Store", ".DS_Store"), true);
|
||||
assert.equal(matchesGlob("other", ".DS_Store"), false);
|
||||
});
|
||||
|
||||
test("matchesGlob - dir/** matches directory and contents", () => {
|
||||
assert.equal(matchesGlob(".git", ".git/**"), true);
|
||||
assert.equal(matchesGlob(".git/config", ".git/**"), true);
|
||||
assert.equal(matchesGlob(".git/refs/heads/main", ".git/**"), true);
|
||||
assert.equal(matchesGlob(".gitignore", ".git/**"), false);
|
||||
});
|
||||
|
||||
test("matchesGlob - * matches within a single segment", () => {
|
||||
assert.equal(matchesGlob("foo.tmp", "*.tmp"), true);
|
||||
assert.equal(matchesGlob("bar.tmp", "*.tmp"), true);
|
||||
assert.equal(matchesGlob("foo.md", "*.tmp"), false);
|
||||
assert.equal(matchesGlob("dir/foo.tmp", "*.tmp"), false);
|
||||
});
|
||||
|
||||
test("matchesGlob - **/*.ext matches at any depth", () => {
|
||||
assert.equal(matchesGlob("foo.tmp", "**/*.tmp"), true);
|
||||
assert.equal(matchesGlob("dir/foo.tmp", "**/*.tmp"), true);
|
||||
assert.equal(matchesGlob("a/b/c/foo.tmp", "**/*.tmp"), true);
|
||||
assert.equal(matchesGlob("foo.md", "**/*.tmp"), false);
|
||||
});
|
||||
|
||||
test("matchesGlob - ? matches single character", () => {
|
||||
assert.equal(matchesGlob("a.md", "?.md"), true);
|
||||
assert.equal(matchesGlob("ab.md", "?.md"), false);
|
||||
assert.equal(matchesGlob(".md", "?.md"), false);
|
||||
});
|
||||
|
||||
test("matchesGlob - dots are literal", () => {
|
||||
assert.equal(matchesGlob(".DS_Store", ".DS_Store"), true);
|
||||
assert.equal(matchesGlob("xDS_Store", ".DS_Store"), false);
|
||||
});
|
||||
|
||||
test("matchesGlob - node_modules/** matches directory tree", () => {
|
||||
assert.equal(matchesGlob("node_modules", "node_modules/**"), true);
|
||||
assert.equal(matchesGlob("node_modules/foo", "node_modules/**"), true);
|
||||
assert.equal(
|
||||
matchesGlob("node_modules/foo/bar/baz.js", "node_modules/**"),
|
||||
true
|
||||
);
|
||||
assert.equal(matchesGlob("not_node_modules", "node_modules/**"), false);
|
||||
});
|
||||
|
||||
test("matchesGlob - **/ prefix matches zero or more segments", () => {
|
||||
assert.equal(matchesGlob("test.log", "**/test.log"), true);
|
||||
assert.equal(matchesGlob("dir/test.log", "**/test.log"), true);
|
||||
assert.equal(matchesGlob("a/b/test.log", "**/test.log"), true);
|
||||
});
|
||||
|
||||
test("toUnixPath - forward slashes unchanged", () => {
|
||||
assert.equal(toUnixPath("foo/bar/baz"), "foo/bar/baz");
|
||||
});
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
import * as path from "path";
|
||||
|
||||
// Convert a native platform path to forward slashes (no-op on non-Windows)
|
||||
export function toUnixPath(nativePath: string): string {
|
||||
return nativePath.split(path.sep).join(path.posix.sep);
|
||||
}
|
||||
|
||||
// Match a file path against a glob pattern.
|
||||
//
|
||||
// Behaves like Node's path.matchesGlob with one extension: `dir/**` matches
|
||||
// the directory `dir` itself, not only its descendants. The watcher feeds us
|
||||
// a directory's relative path (e.g. ".git") at the same time it's about to
|
||||
// recurse into it, and the natural way for users to write the ignore pattern
|
||||
// is `.git/**` — under stdlib semantics that pattern would let the directory
|
||||
// through and only block its children, defeating the prune.
|
||||
export function matchesGlob(filePath: string, pattern: string): boolean {
|
||||
if (pattern.endsWith("/**") && filePath === pattern.slice(0, -3)) {
|
||||
return true;
|
||||
}
|
||||
return path.matchesGlob(filePath, pattern);
|
||||
}
|
||||
|
|
@ -18,5 +18,7 @@
|
|||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"exclude": ["dist"]
|
||||
"exclude": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,32 +2,32 @@ const path = require("path");
|
|||
const webpack = require("webpack");
|
||||
|
||||
module.exports = {
|
||||
entry: {
|
||||
cli: "./src/cli.ts",
|
||||
healthcheck: "./src/healthcheck.ts"
|
||||
},
|
||||
target: "node",
|
||||
mode: "production",
|
||||
optimization: {
|
||||
minimize: false
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.ts$/,
|
||||
use: "ts-loader"
|
||||
}
|
||||
]
|
||||
},
|
||||
resolve: {
|
||||
extensions: [".ts", ".js"]
|
||||
},
|
||||
output: {
|
||||
globalObject: "this",
|
||||
filename: "[name].js",
|
||||
path: path.resolve(__dirname, "dist")
|
||||
},
|
||||
plugins: [
|
||||
entry: {
|
||||
cli: "./src/cli.ts",
|
||||
healthcheck: "./src/healthcheck.ts"
|
||||
},
|
||||
target: "node",
|
||||
mode: "production",
|
||||
optimization: {
|
||||
minimize: false
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.ts$/,
|
||||
use: "ts-loader"
|
||||
}
|
||||
]
|
||||
},
|
||||
resolve: {
|
||||
extensions: [".ts", ".js"]
|
||||
},
|
||||
output: {
|
||||
globalObject: "this",
|
||||
filename: "[name].js",
|
||||
path: path.resolve(__dirname, "dist")
|
||||
},
|
||||
plugins: [
|
||||
new webpack.BannerPlugin({ banner: "#!/usr/bin/env node", raw: true })
|
||||
]
|
||||
]
|
||||
};
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ The repo depends on the latest plugin API (obsidian.d.ts) in TypeScript Definiti
|
|||
**Note:** The Obsidian API is still in early alpha and is subject to change at any time!
|
||||
|
||||
This sample plugin demonstrates some of the basic functionality the plugin API can do.
|
||||
|
||||
- Adds a ribbon icon, which shows a Notice when clicked.
|
||||
- Adds a command "Open Sample Modal" which opens a Modal.
|
||||
- Adds a plugin setting tab to the settings page.
|
||||
|
|
@ -58,6 +57,31 @@ Quick starting guide for new plugin devs:
|
|||
|
||||
- Copy over `main.js`, `styles.css`, `manifest.json` to your vault `VaultFolder/.obsidian/plugins/your-plugin-id/`.
|
||||
|
||||
|
||||
## Funding URL
|
||||
|
||||
You can include funding URLs where people who use your plugin can financially support it.
|
||||
|
||||
The simple way is to set the `fundingUrl` field to your link in your `manifest.json` file:
|
||||
|
||||
```json
|
||||
{
|
||||
"fundingUrl": "https://buymeacoffee.com"
|
||||
}
|
||||
```
|
||||
|
||||
If you have multiple URLs, you can also do:
|
||||
|
||||
```json
|
||||
{
|
||||
"fundingUrl": {
|
||||
"Buy Me a Coffee": "https://buymeacoffee.com",
|
||||
"GitHub Sponsor": "https://github.com/sponsors",
|
||||
"Patreon": "https://www.patreon.com/"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## API Documentation
|
||||
|
||||
See https://github.com/obsidianmd/obsidian-api
|
||||
|
|
|
|||
|
|
@ -13,25 +13,25 @@
|
|||
"author": "",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@types/node": "^25.0.2",
|
||||
"@types/node": "^24.8.1",
|
||||
"css-loader": "^7.1.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"file-loader": "^6.2.0",
|
||||
"fs-extra": "^11.3.2",
|
||||
"mini-css-extract-plugin": "^2.9.4",
|
||||
"obsidian": "1.11.0",
|
||||
"reconcile-text": "^0.11.0",
|
||||
"fs-extra": "^11.3.0",
|
||||
"mini-css-extract-plugin": "^2.9.2",
|
||||
"obsidian": "1.10.2",
|
||||
"reconcile-text": "^0.8.0",
|
||||
"resolve-url-loader": "^5.0.0",
|
||||
"sass": "^1.96.0",
|
||||
"sass": "^1.91.0",
|
||||
"sass-loader": "^16.0.6",
|
||||
"sync-client": "file:../sync-client",
|
||||
"terser-webpack-plugin": "^5.3.16",
|
||||
"ts-loader": "^9.5.4",
|
||||
"terser-webpack-plugin": "^5.3.14",
|
||||
"ts-loader": "^9.5.2",
|
||||
"tslib": "2.8.1",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "5.9.3",
|
||||
"tsx": "^4.20.6",
|
||||
"typescript": "5.8.3",
|
||||
"url": "^0.11.4",
|
||||
"webpack": "^5.103.0",
|
||||
"webpack": "^5.99.9",
|
||||
"webpack-cli": "^6.0.1"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -135,14 +135,14 @@ export default class VaultLinkPlugin extends Plugin {
|
|||
nativeLineEndings: Platform.isWin ? "\r\n" : "\n",
|
||||
...(IS_DEBUG_BUILD
|
||||
? {
|
||||
fetch: debugging.slowFetchFactory(1),
|
||||
webSocket: debugging.slowWebSocketFactory(1, new Logger())
|
||||
}
|
||||
fetch: debugging.slowFetchFactory(1),
|
||||
webSocket: debugging.slowWebSocketFactory(1, new Logger())
|
||||
}
|
||||
: {})
|
||||
});
|
||||
|
||||
if (IS_DEBUG_BUILD) {
|
||||
debugging.logToConsole(client.logger);
|
||||
debugging.logToConsole(client);
|
||||
}
|
||||
|
||||
return client;
|
||||
|
|
@ -231,9 +231,9 @@ export default class VaultLinkPlugin extends Plugin {
|
|||
}
|
||||
}
|
||||
),
|
||||
this.app.vault.on("create", (file: TAbstractFile) => {
|
||||
this.app.vault.on("create", async (file: TAbstractFile) => {
|
||||
if (file instanceof TFile) {
|
||||
client.syncLocallyCreatedFile(file.path);
|
||||
await client.syncLocallyCreatedFile(file.path);
|
||||
}
|
||||
}),
|
||||
this.app.vault.on("modify", async (file: TAbstractFile) => {
|
||||
|
|
@ -241,14 +241,14 @@ export default class VaultLinkPlugin extends Plugin {
|
|||
await this.rateLimitedUpdate(file.path, client);
|
||||
}
|
||||
}),
|
||||
this.app.vault.on("delete", (file: TAbstractFile) => {
|
||||
client.syncLocallyDeletedFile(file.path);
|
||||
this.app.vault.on("delete", async (file: TAbstractFile) => {
|
||||
await client.syncLocallyDeletedFile(file.path);
|
||||
}),
|
||||
this.app.vault.on(
|
||||
"rename",
|
||||
(file: TAbstractFile, oldPath: string) => {
|
||||
async (file: TAbstractFile, oldPath: string) => {
|
||||
if (file instanceof TFile) {
|
||||
client.syncLocallyUpdatedFile({
|
||||
await client.syncLocallyUpdatedFile({
|
||||
oldPath,
|
||||
relativePath: file.path
|
||||
});
|
||||
|
|
@ -267,11 +267,13 @@ export default class VaultLinkPlugin extends Plugin {
|
|||
if (!this.rateLimitedUpdatesPerFile.has(path)) {
|
||||
this.rateLimitedUpdatesPerFile.set(
|
||||
path,
|
||||
rateLimit(async () => {
|
||||
client.syncLocallyUpdatedFile({
|
||||
relativePath: path
|
||||
});
|
||||
}, MIN_WAIT_BETWEEN_UPDATES_IN_MS)
|
||||
rateLimit(
|
||||
async () =>
|
||||
client.syncLocallyUpdatedFile({
|
||||
relativePath: path
|
||||
}),
|
||||
MIN_WAIT_BETWEEN_UPDATES_IN_MS
|
||||
)
|
||||
);
|
||||
}
|
||||
await this.rateLimitedUpdatesPerFile.get(path)?.();
|
||||
|
|
|
|||
|
|
@ -14,9 +14,7 @@ export function renderCursorsInFileExplorer(
|
|||
app: App
|
||||
): void {
|
||||
const fileExplorers = app.workspace.getLeavesOfType("file-explorer");
|
||||
if (fileExplorers.length == 0) {
|
||||
return;
|
||||
}
|
||||
if (fileExplorers.length == 0) return;
|
||||
|
||||
const [fileExplorer] = fileExplorers;
|
||||
|
||||
|
|
@ -36,7 +34,7 @@ export function renderCursorsInFileExplorer(
|
|||
(parent) => {
|
||||
cursors.forEach((cursor) => {
|
||||
cursor.documentsWithCursors.forEach((document) => {
|
||||
if (document.relativePath.startsWith(key)) {
|
||||
if (document.relative_path.startsWith(key)) {
|
||||
parent.appendChild(
|
||||
createSpan({
|
||||
text: cursor.userName,
|
||||
|
|
|
|||
|
|
@ -61,7 +61,7 @@ export class RemoteCursorsPluginValue implements PluginValue {
|
|||
return clientCursors.flatMap((cursor) =>
|
||||
cursor.cursors.map((span) => ({
|
||||
name: client.userName,
|
||||
path: cursor.relativePath,
|
||||
path: cursor.relative_path,
|
||||
deviceId: client.deviceId,
|
||||
isOutdated: client.isOutdated,
|
||||
span: { ...span }
|
||||
|
|
@ -132,8 +132,7 @@ export class RemoteCursorsPluginValue implements PluginValue {
|
|||
]
|
||||
)
|
||||
},
|
||||
edited,
|
||||
"Markdown"
|
||||
edited
|
||||
);
|
||||
|
||||
reconciled.cursors.forEach(({ id, position }) => {
|
||||
|
|
|
|||
|
|
@ -266,8 +266,9 @@ export class SyncSettingsTab extends PluginSettingTab {
|
|||
|
||||
new Notice("Checking connection to the server...");
|
||||
new Notice(
|
||||
(await this.syncClient.checkConnection())
|
||||
.serverMessage
|
||||
(
|
||||
await this.syncClient.checkConnection()
|
||||
).serverMessage
|
||||
);
|
||||
await this.statusDescription.updateConnectionState();
|
||||
} else {
|
||||
|
|
@ -350,6 +351,22 @@ export class SyncSettingsTab extends PluginSettingTab {
|
|||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Sync concurrency")
|
||||
.setDesc(
|
||||
"How many concurrent sync operations to run. Setting this value higher may increase the overall performance, however, it will require more memory as well. If you notice frequent crashes, especially on mobile, set this to 1."
|
||||
)
|
||||
.addSlider((text) =>
|
||||
text
|
||||
.setLimits(1, 16, 1)
|
||||
.setDynamicTooltip()
|
||||
.setInstant(false)
|
||||
.setValue(this.syncClient.getSettings().syncConcurrency)
|
||||
.onChange(async (value) =>
|
||||
this.syncClient.setSetting("syncConcurrency", value)
|
||||
)
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Maximum file size to be uploaded (MB)")
|
||||
.setDesc(
|
||||
|
|
@ -467,6 +484,40 @@ export class SyncSettingsTab extends PluginSettingTab {
|
|||
);
|
||||
})
|
||||
);
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName("Minimum save interval (ms)")
|
||||
.setDesc(
|
||||
"The minimum time between saving settings and database to disk, in milliseconds. Lower values save more frequently but may impact performance."
|
||||
)
|
||||
.addText((input) =>
|
||||
input
|
||||
.setValue(
|
||||
this.syncClient
|
||||
.getSettings()
|
||||
.minimumSaveIntervalMs.toString()
|
||||
)
|
||||
.onChange(async (value) => {
|
||||
if (value === "") {
|
||||
return;
|
||||
}
|
||||
let parsedValue = Number.parseInt(value, 10);
|
||||
if (Number.isNaN(parsedValue) || parsedValue < 0) {
|
||||
parsedValue =
|
||||
this.syncClient.getSettings()
|
||||
.minimumSaveIntervalMs;
|
||||
}
|
||||
|
||||
if (value !== parsedValue.toString()) {
|
||||
input.setValue(parsedValue.toString());
|
||||
}
|
||||
|
||||
return this.syncClient.setSetting(
|
||||
"minimumSaveIntervalMs",
|
||||
parsedValue
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private setStatusDescriptionSubscription(
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ export class StatusDescription {
|
|||
text: ` and has indexed approximately `
|
||||
});
|
||||
container.createSpan({
|
||||
text: `${this.syncClient.syncedDocumentCount}`,
|
||||
text: `${this.syncClient.documentCount}`,
|
||||
cls: "number"
|
||||
});
|
||||
container.createSpan({
|
||||
|
|
|
|||
|
|
@ -6,7 +6,12 @@
|
|||
"strict": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"lib": ["DOM", "ES2024"]
|
||||
"lib": [
|
||||
"DOM",
|
||||
"ES2024"
|
||||
]
|
||||
},
|
||||
"exclude": ["./dist"]
|
||||
"exclude": [
|
||||
"./dist"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -46,7 +46,8 @@ module.exports = (env, argv) => ({
|
|||
const source = path.resolve(__dirname, "dist");
|
||||
const destinations = [
|
||||
"/volumes/syncthing/Desktop/test/test/.obsidian/plugins/vault-link",
|
||||
"/volumes/syncthing/Desktop/test/test2/.obsidian/plugins/vault-link"
|
||||
"/volumes/syncthing/Desktop/test/test2/.obsidian/plugins/vault-link",
|
||||
// "/home/andras/obsidian-test/.obsidian/plugins/vault-link"
|
||||
];
|
||||
destinations.forEach((destination) => {
|
||||
fs.copy(source, destination)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue